test
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2024-01-09 09:51:32 +11:00
parent 20dc745a64
commit dbc2276d68
10 changed files with 223 additions and 186 deletions

View File

@@ -168,44 +168,45 @@ func RegisterUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "user registration success"})
}
func AddRole(c *gin.Context) {
var input AddRoleInput
/*
func AddRole(c *gin.Context) {
var input AddRoleInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// define the new role properties
r := models.Role{}
r.RoleName = input.RoleName
r.ReadOnly = input.ReadOnly
r.Admin = input.Admin
r.LdapGroup = input.LdapGroup
// Check if role already exists
testRole, _ := models.GetRoleByName(r.RoleName)
log.Printf("AddRole checking if role '%s' already exists\n", r.RoleName)
if (models.Role{} == testRole) {
log.Printf("AddRole confirmed no existing rolename\n")
} else {
errorString := fmt.Sprintf("attempt to register conflicting rolename '%s'", r.RoleName)
log.Printf("Register error : '%s'\n", errorString)
c.JSON(http.StatusBadRequest, gin.H{"error": errorString})
return
}
_, err := r.AddRole()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"Error creating role": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "role creation success"})
}
// define the new role properties
r := models.Role{}
r.RoleName = input.RoleName
r.ReadOnly = input.ReadOnly
r.Admin = input.Admin
r.LdapGroup = input.LdapGroup
// Check if role already exists
testRole, _ := models.GetRoleByName(r.RoleName)
log.Printf("AddRole checking if role '%s' already exists\n", r.RoleName)
if (models.Role{} == testRole) {
log.Printf("AddRole confirmed no existing rolename\n")
} else {
errorString := fmt.Sprintf("attempt to register conflicting rolename '%s'", r.RoleName)
log.Printf("Register error : '%s'\n", errorString)
c.JSON(http.StatusBadRequest, gin.H{"error": errorString})
return
}
_, err := r.AddRole()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"Error creating role": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "role creation success"})
}
*/
func Login(c *gin.Context) {
var input LoginInput
@@ -254,17 +255,18 @@ func CurrentUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success", "data": u})
}
func GetRoles(c *gin.Context) {
roles, err := models.QueryRoles()
/*
func GetRoles(c *gin.Context) {
roles, err := models.QueryRoles()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": roles})
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": roles})
}
*/
func GetUsers(c *gin.Context) {
users, err := models.UserList()

View File

@@ -4,7 +4,6 @@ import (
"log"
"net/http"
"smt/models"
"smt/utils/token"
"github.com/gin-gonic/gin"
)
@@ -165,12 +164,14 @@ func retrieveSpecifiedSecret(s *models.Secret, c *gin.Context) {
*/
var results []models.Secret
var userIsAdmin = false
user_id, err := token.ExtractTokenID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "error determining user"})
return
}
/*
user_id, err := token.ExtractTokenID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "error determining user"})
return
}
*/
user_id := c.GetInt("user-id")
// Work out which safe to query for this user if the safe was not specified
safeList, err := models.UserGetSafesAllowed(int(user_id))
@@ -182,21 +183,13 @@ func retrieveSpecifiedSecret(s *models.Secret, c *gin.Context) {
// If there was only one result then just use that
if len(safeList) == 0 {
// check if the user is an admin, if not then they seem to have access to zero safes
if !models.UserCheckIfAdmin(int(user_id)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "user has no access to any secrets"})
return
} else {
// Don't apply a role filter if user has admin role
results, err = models.SecretsGetMultipleSafes(s, true, []int{})
}
errString := "no matching secret or user has no access to specified secret"
log.Printf("retrieveSpecifiedSecret %s\n", errString)
c.JSON(http.StatusBadRequest, gin.H{"error": errString})
return
} else if len(safeList) == 1 {
s.SafeId = safeList[0].SafeId
userIsAdmin = safeList[0].AdminUser || safeList[0].AdminGroup
// Don't apply a role filter if user has admin role
//results, err = models.GetSecrets(&s, userIsAdmin)
results, err = models.SecretsGetMultipleSafes(s, userIsAdmin, []int{s.SafeId})
results, err = models.SecretsGetMultipleSafes(s, []int{s.SafeId})
} else {
// Create a list of all the safes this user can access
var safeIds []int
@@ -204,7 +197,7 @@ func retrieveSpecifiedSecret(s *models.Secret, c *gin.Context) {
safeIds = append(safeIds, safe.SafeId)
}
results, err = models.SecretsGetMultipleSafes(s, false, safeIds)
results, err = models.SecretsGetMultipleSafes(s, safeIds)
}
if err != nil {
@@ -250,12 +243,8 @@ func ListSecrets(c *gin.Context) {
*/
var results []models.Secret
var userIsAdmin = false
user_id, err := token.ExtractTokenID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "error determining user"})
return
}
s := models.Secret{}
user_id := c.GetInt("user-id")
// Work out which safe to query for this user if the safe was not specified
safeList, err := models.UserGetSafesAllowed(int(user_id))
@@ -267,18 +256,13 @@ func ListSecrets(c *gin.Context) {
// If there was only one result then just use that
if len(safeList) == 0 {
// check if the user is an admin, if not then they seem to have access to zero safes
if !models.UserCheckIfAdmin(int(user_id)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "user has no access to any secrets"})
return
} else {
// Don't apply a role filter if user has admin role
results, err = models.SecretsGetMultipleSafes(&models.Secret{}, true, []int{})
}
errString := "no matching secret or user has no access to specified secret"
log.Printf("ListSecrets %s\n", errString)
c.JSON(http.StatusBadRequest, gin.H{"error": errString})
return
} else if len(safeList) == 1 {
userIsAdmin = safeList[0].AdminUser || safeList[0].AdminGroup
results, err = models.SecretsGetMultipleSafes(&models.Secret{}, userIsAdmin, []int{safeList[0].SafeId})
s.SafeId = safeList[0].SafeId
results, err = models.SecretsGetMultipleSafes(&s, []int{s.SafeId})
} else {
// Create a list of all the safes this user can access
var safeIds []int
@@ -286,7 +270,7 @@ func ListSecrets(c *gin.Context) {
safeIds = append(safeIds, safe.SafeId)
}
results, err = models.SecretsGetMultipleSafes(&models.Secret{}, false, safeIds)
results, err = models.SecretsGetMultipleSafes(&s, safeIds)
}
if err != nil {

View File

@@ -111,6 +111,23 @@ func StoreSecret(c *gin.Context) {
// CheckUpdateSecretAllowed checks to see if a user has access to the specified secret. If so, the corresponding SafeId is returned
func CheckUpdateSecretAllowed(s *models.Secret, user_id int) (int, error) {
// If user has Admin access then perform update
// If user has normal access to the safe the secret is stored in then perform update
// If matching secret is found in multiple safes then generate error
// If user doesn't have access to the safe the matching secret is in then generate error
// NO. That is too complicated!
// Lets try to make this more simple
// A user can only be in one group
// A group can have permissions on multiple safes
// If a user is an admin they can do user related functions like create users, groups, assign permissions
// But a user has to have a permission that maps the group to the safe in order to perform CRUD operations
// What does a group being an admin give them? All users in that group can do user related function
// Query all safes for secrets matching parameters specified
matchingSecrets, err := models.SecretsSearchAllSafes(s)
if err != nil {
@@ -133,14 +150,7 @@ func CheckUpdateSecretAllowed(s *models.Secret, user_id int) (int, error) {
return 0, errors.New(errString)
} else if len(matchingSecrets) == 1 {
log.Printf("CheckUpdateSecretAllowed found a single matching secret :\n'%+v'\n", matchingSecrets[0])
// Check if user is admin
for _, val := range userSafes {
if val.User.Admin || val.AdminGroup {
return matchingSecrets[0].SafeId, nil
}
}
// If we reach here then user is not admin
// Check to see user is allowed to access the safe holding the secret
for _, secret := range matchingSecrets {
for _, user := range userSafes {
@@ -167,19 +177,6 @@ func CheckUpdateSecretAllowed(s *models.Secret, user_id int) (int, error) {
return 0, errors.New(errString)
}
}
if user.User.Admin || user.AdminGroup {
log.Printf("CheckUpdateSecretAllowed found user to be admin, assuming SafeId '%d'\n", user.SafeId)
if !matchFound {
matchFound = true
matchingSafeId = user.SafeId
} else {
// Found more than one applicable secret, how do we know which one to update?
errString := "CheckUpdateSecretAllowed found multiple secrets matching supplied parameters, supply more specific parameters"
log.Println(errString)
return 0, errors.New(errString)
}
}
}
}
@@ -213,11 +210,14 @@ func UpdateSecret(c *gin.Context) {
}
*/
user_id, err := token.ExtractTokenID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "error determining user"})
return
}
/*
user_id, err := token.ExtractTokenID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "error determining user"})
return
}
*/
user_id := c.GetInt("user-id")
// Populate fields
s := models.Secret{}
@@ -226,6 +226,15 @@ func UpdateSecret(c *gin.Context) {
s.DeviceName = input.DeviceName
s.DeviceCategory = input.DeviceCategory
// TODO:
// Get a list of matching secrets - SecretsSearchAllSafes
//secretList, err := models.SecretsSearchAllSafes(&s)
// Check if user has access to the safes containing those secrets - something like UserGetSafesAllowed but not quite
//allowedSafes, err := models.UserGetSafesAllowed(user_id)
// Make sure that the access is not readonly
// If user has access to more than one safe containing the secret, generate an error
// Otherwise, update the secret
allowedUpdate, err := CheckUpdateSecretAllowed(&s, int(user_id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("error determining secret : '%s'", err)})
@@ -236,11 +245,6 @@ func UpdateSecret(c *gin.Context) {
s.SafeId = allowedUpdate
}
// If user has Admin access then perform update
// If user has normal access to the safe the secret is stored in then perform update
// If matching secret is found in multiple safes then generate error
// If user doesn't have access to the safe the matching secret is in then generate error
// Query which safes the current user is allowed to access
/*
@@ -319,10 +323,8 @@ func SecretCheckSafeAllowed(user_id int, input StoreInput) int {
return safe.SafeId
} else if input.SafeId > 0 && safe.SafeId == input.SafeId { // Safe specified by id
return safe.SafeId
} else if safe.User.Admin || safe.AdminGroup { // User has admin role so they're allowed this safe anyway
return safe.SafeId
} else {
log.Printf("SecretCheckSafeAllowed ")
log.Printf("SecretCheckSafeAllowed unexpected\n")
}
}

View File

@@ -246,11 +246,13 @@ func main() {
adminOnly.POST("/user/add", controllers.RegisterUser)
// TODO
//adminOnly.POST("/user/update", controllers.UpdateUser)
adminOnly.GET("/roles", controllers.GetRoles)
adminOnly.POST("/role/add", controllers.AddRole)
adminOnly.GET("/users", controllers.GetUsers)
adminOnly.POST("/unlock", controllers.Unlock)
// Deprecated
//adminOnly.GET("/roles", controllers.GetRoles)
//adminOnly.POST("/role/add", controllers.AddRole)
// Get secrets
protected := router.Group("/api/secret")
protected.Use(middlewares.JwtAuthMiddleware())

View File

@@ -19,6 +19,19 @@ func JwtAuthMiddleware() gin.HandlerFunc {
c.Abort()
return
}
// Token is valid, extract user_id
user_id, err := token.ExtractTokenID(c)
if err != nil {
log.Printf("JwtAuthMiddleware user_id could not be parsed : '%s'\n", err)
c.String(http.StatusUnauthorized, "Unauthorized")
c.Abort()
return
}
// Store user id in context for accessing later
log.Printf("JwtAuthMiddleware storing user-id '%d'\n", user_id)
c.Set("user-id", user_id)
c.Next()
}
}

View File

@@ -111,7 +111,7 @@ func SecretsSearchAllSafes(s *Secret) ([]Secret, error) {
}
// SecretsGetMultipleSafes queries the specified safes for matching secrets
func SecretsGetMultipleSafes(s *Secret, adminRole bool, safeIds []int) ([]Secret, error) {
func SecretsGetMultipleSafes(s *Secret, safeIds []int) ([]Secret, error) {
var err error
var secretResults []Secret
@@ -130,25 +130,19 @@ func SecretsGetMultipleSafes(s *Secret, adminRole bool, safeIds []int) ([]Secret
args := []interface{}{}
var query string
if adminRole {
log.Printf("SecretsGetMultipleSafes using admin role so not limiting to specific safes\n")
// No need to limit query to any safe
query = "SELECT * FROM secrets WHERE 1=1 "
} else {
// Generate placeholders for the IN clause to match multiple SafeId values
placeholders := make([]string, len(safeIds))
for i := range safeIds {
placeholders[i] = "?"
}
placeholderStr := strings.Join(placeholders, ",")
// Generate placeholders for the IN clause to match multiple SafeId values
placeholders := make([]string, len(safeIds))
for i := range safeIds {
placeholders[i] = "?"
}
placeholderStr := strings.Join(placeholders, ",")
// Create query with the necessary placeholders
query = fmt.Sprintf("SELECT * FROM secrets WHERE SafeId IN (%s) ", placeholderStr)
// Create query with the necessary placeholders
query = fmt.Sprintf("SELECT * FROM secrets WHERE SafeId IN (%s) ", placeholderStr)
// Add the Safe Ids to the arguments list
for _, g := range safeIds {
args = append(args, g)
}
// Add the Safe Ids to the arguments list
for _, g := range safeIds {
args = append(args, g)
}
// Add any other arguments to the query if they were specified

View File

@@ -20,8 +20,7 @@ const (
sqlFile = "smt.db"
)
// TODO drop LdapGroup column
/*
const createRoles string = `
CREATE TABLE IF NOT EXISTS roles (
RoleId INTEGER PRIMARY KEY ASC,
@@ -29,6 +28,7 @@ const createRoles string = `
ReadOnly BOOLEAN
);
`
*/
const createUsers string = `
CREATE TABLE IF NOT EXISTS users (
@@ -62,11 +62,11 @@ const createGroups string = `
const createPermissions = `
CREATE TABLE IF NOT EXISTS permissions (
PermissionId INTEGER PRIMARY KEY ASC,
RoleId INTEGER,
Description VARCHAR DEFAULT '',
ReadOnly BOOLEAN DEFAULT 0,
SafeId INTEGER,
UserId INTEGER,
GroupId INTEGER,
FOREIGN KEY (RoleId) REFERENCES roles(RoleId),
FOREIGN KEY (SafeId) REFERENCES safes(SafeId),
FOREIGN KEY (UserId) REFERENCES users(UserId),
FOREIGN KEY (GroupId) REFERENCES groups(GroupId)
@@ -131,23 +131,44 @@ func CreateTables() {
var err error
var rowCount int
// Create database tables if it doesn't exist
// Roles table should go first since other tables refer to it
if _, err = db.Exec(createRoles); err != nil {
log.Printf("Error checking roles table : '%s'", err)
/*
// Roles table should go first since other tables refer to it
if _, err = db.Exec(createRoles); err != nil {
log.Printf("Error checking roles table : '%s'", err)
os.Exit(1)
}
rowCount, _ = CheckCount("roles")
if rowCount == 0 {
if _, err = db.Exec("INSERT INTO roles VALUES(1, 'Admin', false);"); err != nil {
log.Printf("Error adding initial admin role : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO roles VALUES(2, 'UserRole', false);"); err != nil {
log.Printf("Error adding initial user role : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO roles VALUES(3, 'GuestRole', true);"); err != nil {
log.Printf("Error adding initial guest role : '%s'", err)
os.Exit(1)
}
}
*/
// groups table
if _, err = db.Exec(createGroups); err != nil {
log.Printf("Error checking groups table : '%s'", err)
os.Exit(1)
}
rowCount, _ = CheckCount("roles")
// Add initial groups
rowCount, _ = CheckCount("groups")
if rowCount == 0 {
if _, err = db.Exec("INSERT INTO roles VALUES(1, 'Admin', false);"); err != nil {
log.Printf("Error adding initial admin role : '%s'", err)
if _, err = db.Exec("INSERT INTO groups (GroupId, GroupName, Admin) VALUES(1, 'Administrators', 1);"); err != nil {
log.Printf("Error adding initial group entry id 1 : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO roles VALUES(2, 'UserRole', false);"); err != nil {
log.Printf("Error adding initial user role : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO roles VALUES(3, 'GuestRole', true);"); err != nil {
log.Printf("Error adding initial guest role : '%s'", err)
if _, err = db.Exec("INSERT INTO groups (GroupId, GroupName, Admin) VALUES(2, 'Users', 0);"); err != nil {
log.Printf("Error adding initial group entry id 2 : '%s'", err)
os.Exit(1)
}
}
@@ -169,7 +190,11 @@ func CreateTables() {
cryptText, _ := bcrypt.GenerateFromPassword([]byte(initialPassword), bcrypt.DefaultCost)
initialPassword = string(cryptText)
}
if _, err = db.Exec("INSERT INTO users (RoleId, UserName, Password, LdapUser) VALUES(1, 1, 'Administrator', ?, 0);", initialPassword); err != nil {
if _, err = db.Exec("INSERT INTO users (UserId, GroupId, UserName, Password, LdapUser, Admin) VALUES(1, 1, 'Administrator', ?, false, true);", initialPassword); err != nil {
log.Printf("Error adding initial admin role : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO users (UserId, GroupId, UserName, Password, LdapUser, Admin) VALUES(2, 2, 'User', ?, false, false);", initialPassword); err != nil {
log.Printf("Error adding initial admin role : '%s'", err)
os.Exit(1)
}
@@ -201,46 +226,23 @@ func CreateTables() {
os.Exit(1)
}
// groups table
if _, err = db.Exec(createGroups); err != nil {
log.Printf("Error checking groups table : '%s'", err)
os.Exit(1)
}
// permissions table
if _, err = db.Exec(createPermissions); err != nil {
log.Printf("Error checking permissions table : '%s'", err)
os.Exit(1)
}
// Add initial groups
rowCount, _ = CheckCount("groups")
if rowCount == 0 {
if _, err = db.Exec("INSERT INTO groups (GroupId, GroupName, Admin) VALUES(1, 'Administrators', 1);"); err != nil {
log.Printf("Error adding initial group entry id 1 : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO groups (GroupId, GroupName, Admin) VALUES(2, 'Users', 0);"); err != nil {
log.Printf("Error adding initial group entry id 2 : '%s'", err)
os.Exit(1)
}
}
// Add initial permissions
rowCount, _ = CheckCount("permissions")
if rowCount == 0 {
if _, err = db.Exec("INSERT INTO permissions (RoleId, SafeId, UserId) VALUES(1, 1, 1);"); err != nil {
if _, err = db.Exec("INSERT INTO permissions (Description, ReadOnly, GroupId, SafeId) VALUES('Default Admin Group Permission', false, 1, 1);"); err != nil {
log.Printf("Error adding initial permissions entry userid 1 : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO permissions (RoleId, SafeId, UserId) VALUES(1, 1, 2);"); err != nil {
if _, err = db.Exec("INSERT INTO permissions (Description, ReadOnly, SafeId, GroupId) VALUES('Default User Group Permission', false, 1, 2);"); err != nil {
log.Printf("Error adding initial permissions entry userid 2 : '%s'", err)
os.Exit(1)
}
if _, err = db.Exec("INSERT INTO permissions (RoleId, SafeId, UserId) VALUES(1, 1, 3);"); err != nil {
log.Printf("Error adding initial permissions entry userid 3 : '%s'", err)
os.Exit(1)
}
}
// Schema table should go last so we know if the database has a value in the schema table then everything was created properly
@@ -326,7 +328,7 @@ func CreateTables() {
DROP TABLE _secrets_old;
`)
if err != nil {
log.Printf("Error altering secrets table to renmove RoleId column : '%s'\n", err)
log.Printf("Error altering secrets table to remove RoleId column : '%s'\n", err)
os.Exit(1)
}
}
@@ -342,6 +344,47 @@ func CreateTables() {
}
}
// Remove the Admin column from roles table
rolesAdminCheck, _ := CheckColumnExists("roles", "Admin")
if rolesAdminCheck {
_, err := db.Exec("ALTER TABLE roles DROP COLUMN Admin;")
if err != nil {
log.Printf("Error altering roles table to remove Admin column : '%s'\n", err)
os.Exit(1)
}
}
// Remove the RoleId from permissiosn table
permissionsRoleIdCheck, _ := CheckColumnExists("permissions", "RoleId")
if permissionsRoleIdCheck {
_, err := db.Exec(`
PRAGMA foreign_keys=off;
BEGIN TRANSACTION;
ALTER TABLE permissions RENAME TO _permissions_old;
CREATE TABLE permissions
(
PermissionId INTEGER PRIMARY KEY ASC,
Description VARCHAR DEFAULT '',
ReadOnly BOOLEAN DEFAULT 0,
SafeId INTEGER,
UserId INTEGER,
GroupId INTEGER,
FOREIGN KEY (SafeId) REFERENCES safes(SafeId),
FOREIGN KEY (UserId) REFERENCES users(UserId),
FOREIGN KEY (GroupId) REFERENCES groups(GroupId)
);
INSERT INTO permissions SELECT * FROM _permissions_old;
ALTER TABLE permissions DROP COLUMN RoleId;
COMMIT;
PRAGMA foreign_keys=on;
DROP TABLE _permissions_old;
`)
if err != nil {
log.Printf("Error altering permissions table to remove RoleId column : '%s'\n", err)
os.Exit(1)
}
}
/*
// Database updates added after initial version released
ldapCheck, _ := CheckColumnExists("roles", "LdapGroup")

View File

@@ -37,11 +37,9 @@ type UserGroup struct {
type UserSafe struct {
User
AdminUser bool `db:"AdminUser"`
AdminGroup bool `db:"AdminGroup"`
SafeId int `db:"SafeId"`
SafeName string `db:"SafeName"`
GroupId int `db:"GroupId"`
SafeId int `db:"SafeId"`
SafeName string `db:"SafeName"`
GroupId int `db:"GroupId"`
}
func (u *User) SaveUser() (*User, error) {
@@ -360,7 +358,6 @@ func UserGetSafesAllowed(userId int) ([]UserSafe, error) {
// join users, groups and permissions
rows, err := db.Queryx(`
SELECT users.UserId, users.GroupId,
groups.Admin as AdminGroup,
permissions.SafeId, safes.SafeName FROM users
INNER JOIN groups ON users.GroupId = groups.GroupId
INNER JOIN permissions ON groups.GroupId = permissions.GroupId

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 78 KiB