From 734c6813eaef166235ae801747e3652d17ae0e2a Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 3 Apr 2025 08:06:56 -0500 Subject: [PATCH] fix: create reusable default profile pictures (#406) Co-authored-by: Elias Schneider --- backend/cmd/main.go | 4 + .../internal/bootstrap/router_bootstrap.go | 4 +- .../job/{db_cleanup.go => db_cleanup_job.go} | 34 ++------ backend/internal/job/file_cleanup_job.go | 78 +++++++++++++++++++ backend/internal/job/job.go | 27 +++++++ backend/internal/model/user.go | 13 ++++ backend/internal/service/user_service.go | 35 ++++++++- .../internal/utils/image/profile_picture.go | 13 +--- .../settings/account/account-form.svelte | 4 +- 9 files changed, 166 insertions(+), 46 deletions(-) rename backend/internal/job/{db_cleanup.go => db_cleanup_job.go} (67%) create mode 100644 backend/internal/job/file_cleanup_job.go create mode 100644 backend/internal/job/job.go diff --git a/backend/cmd/main.go b/backend/cmd/main.go index dd801bce..b93b8489 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -4,6 +4,10 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/bootstrap" ) +// @title Pocket ID API +// @version 1.0 +// @description API for Pocket ID + func main() { bootstrap.Bootstrap() } diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 2f892edd..f87d4022 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -19,9 +19,6 @@ import ( // This is used to register additional controllers for tests var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService) -// @title Pocket ID API -// @version 1 -// @description API for Pocket ID func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { // Set the appropriate Gin mode based on the environment switch common.EnvConfig.AppEnv { @@ -62,6 +59,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { job.RegisterLdapJobs(ldapService, appConfigService) job.RegisterDbCleanupJobs(db) + job.RegisterFileCleanupJobs(db) // Initialize middleware for specific routes authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService) diff --git a/backend/internal/job/db_cleanup.go b/backend/internal/job/db_cleanup_job.go similarity index 67% rename from backend/internal/job/db_cleanup.go rename to backend/internal/job/db_cleanup_job.go index 80ec195e..0e63bc34 100644 --- a/backend/internal/job/db_cleanup.go +++ b/backend/internal/job/db_cleanup_job.go @@ -5,7 +5,6 @@ import ( "time" "github.com/go-co-op/gocron/v2" - "github.com/google/uuid" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "gorm.io/gorm" @@ -17,7 +16,7 @@ func RegisterDbCleanupJobs(db *gorm.DB) { log.Fatalf("Failed to create a new scheduler: %s", err) } - jobs := &Jobs{db: db} + jobs := &DbCleanupJobs{db: db} registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions) registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens) @@ -27,50 +26,31 @@ func RegisterDbCleanupJobs(db *gorm.DB) { scheduler.Start() } -type Jobs struct { +type DbCleanupJobs struct { db *gorm.DB } // ClearWebauthnSessions deletes WebAuthn sessions that have expired -func (j *Jobs) clearWebauthnSessions() error { +func (j *DbCleanupJobs) clearWebauthnSessions() error { return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error } // ClearOneTimeAccessTokens deletes one-time access tokens that have expired -func (j *Jobs) clearOneTimeAccessTokens() error { +func (j *DbCleanupJobs) clearOneTimeAccessTokens() error { return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error } // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired -func (j *Jobs) clearOidcAuthorizationCodes() error { +func (j *DbCleanupJobs) clearOidcAuthorizationCodes() error { return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error } // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired -func (j *Jobs) clearOidcRefreshTokens() error { +func (j *DbCleanupJobs) clearOidcRefreshTokens() error { return j.db.Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error } // ClearAuditLogs deletes audit logs older than 90 days -func (j *Jobs) clearAuditLogs() error { +func (j *DbCleanupJobs) clearAuditLogs() error { return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error } - -func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) { - _, err := scheduler.NewJob( - gocron.CronJob(interval, false), - gocron.NewTask(job), - gocron.WithEventListeners( - gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) { - log.Printf("Job %q run successfully", name) - }), - gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) { - log.Printf("Job %q failed with error: %v", name, err) - }), - ), - ) - - if err != nil { - log.Fatalf("Failed to register job %q: %v", name, err) - } -} diff --git a/backend/internal/job/file_cleanup_job.go b/backend/internal/job/file_cleanup_job.go new file mode 100644 index 00000000..ec9637ed --- /dev/null +++ b/backend/internal/job/file_cleanup_job.go @@ -0,0 +1,78 @@ +package job + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/go-co-op/gocron/v2" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + "gorm.io/gorm" +) + +func RegisterFileCleanupJobs(db *gorm.DB) { + scheduler, err := gocron.NewScheduler() + if err != nil { + log.Fatalf("Failed to create a new scheduler: %s", err) + } + + jobs := &FileCleanupJobs{db: db} + + registerJob(scheduler, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures) + + scheduler.Start() +} + +type FileCleanupJobs struct { + db *gorm.DB +} + +// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials +func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures() error { + var users []model.User + if err := j.db.Find(&users).Error; err != nil { + return fmt.Errorf("failed to fetch users: %w", err) + } + + // Create a map to track which initials are in use + initialsInUse := make(map[string]bool) + for _, user := range users { + initialsInUse[user.Initials()] = true + } + + defaultPicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults" + if _, err := os.Stat(defaultPicturesDir); os.IsNotExist(err) { + return nil + } + + files, err := os.ReadDir(defaultPicturesDir) + if err != nil { + return fmt.Errorf("failed to read default profile pictures directory: %w", err) + } + + filesDeleted := 0 + for _, file := range files { + if file.IsDir() { + continue // Skip directories + } + + filename := file.Name() + initials := strings.TrimSuffix(filename, ".png") + + // If these initials aren't used by any user, delete the file + if !initialsInUse[initials] { + filePath := filepath.Join(defaultPicturesDir, filename) + if err := os.Remove(filePath); err != nil { + log.Printf("Failed to delete unused default profile picture %s: %v", filePath, err) + } else { + filesDeleted++ + } + } + } + + log.Printf("Deleted %d unused default profile pictures", filesDeleted) + return nil +} diff --git a/backend/internal/job/job.go b/backend/internal/job/job.go new file mode 100644 index 00000000..628d4926 --- /dev/null +++ b/backend/internal/job/job.go @@ -0,0 +1,27 @@ +package job + +import ( + "log" + + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" +) + +func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) { + _, err := scheduler.NewJob( + gocron.CronJob(interval, false), + gocron.NewTask(job), + gocron.WithEventListeners( + gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) { + log.Printf("Job %q run successfully", name) + }), + gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) { + log.Printf("Job %q failed with error: %v", name, err) + }), + ), + ) + + if err != nil { + log.Fatalf("Failed to register job %q: %v", name, err) + } +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index a2251b66..16834f24 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -1,6 +1,8 @@ package model import ( + "strings" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -63,6 +65,17 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential func (u User) FullName() string { return u.FirstName + " " + u.LastName } +func (u User) Initials() string { + initials := "" + if len(u.FirstName) > 0 { + initials += string(u.FirstName[0]) + } + if len(u.LastName) > 0 { + initials += string(u.LastName[0]) + } + return strings.ToUpper(initials) +} + type OneTimeAccessToken struct { Base Token string diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 27add3ba..ddf0edb8 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "errors" "fmt" "io" @@ -59,28 +60,58 @@ func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error) return nil, 0, &common.InvalidUUIDError{} } + // First check for a custom uploaded profile picture (userID.png) profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png" file, err := os.Open(profilePicturePath) if err == nil { // Get the file size fileInfo, err := file.Stat() if err != nil { + file.Close() return nil, 0, err } return file, fileInfo.Size(), nil } - // If the file does not exist, return the default profile picture + // If no custom picture exists, get the user's data for creating initials user, err := s.GetUser(userID) if err != nil { return nil, 0, err } - defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName) + // Check if we have a cached default picture for these initials + defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/" + defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png" + file, err = os.Open(defaultPicturePath) + if err == nil { + fileInfo, err := file.Stat() + if err != nil { + file.Close() + return nil, 0, err + } + return file, fileInfo.Size(), nil + } + + // If no cached default picture exists, create one and save it for future use + defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials()) if err != nil { return nil, 0, err } + // Save the default picture for future use (in a goroutine to avoid blocking) + defaultPictureCopy := bytes.NewBuffer(defaultPicture.Bytes()) + go func() { + // Ensure the directory exists + err = os.MkdirAll(defaultProfilePicturesDir, os.ModePerm) + if err != nil { + log.Printf("Failed to create directory for default profile picture: %v", err) + return + } + if err := utils.SaveFileStream(defaultPictureCopy, defaultPicturePath); err != nil { + log.Printf("Failed to cache default profile picture for initials %s: %v", user.Initials(), err) + } + }() + return defaultPicture, int64(defaultPicture.Len()), nil } diff --git a/backend/internal/utils/image/profile_picture.go b/backend/internal/utils/image/profile_picture.go index 2307a126..0bbba5ea 100644 --- a/backend/internal/utils/image/profile_picture.go +++ b/backend/internal/utils/image/profile_picture.go @@ -6,7 +6,6 @@ import ( "image" "image/color" "io" - "strings" "github.com/disintegration/imageorient" "github.com/disintegration/imaging" @@ -42,17 +41,7 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) { } // CreateDefaultProfilePicture creates a profile picture with the initials -func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, error) { - // Get the initials - initials := "" - if len(firstName) > 0 { - initials += string(firstName[0]) - } - if len(lastName) > 0 { - initials += string(lastName[0]) - } - initials = strings.ToUpper(initials) - +func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) { // Create a blank image with a white background img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255}) diff --git a/frontend/src/routes/settings/account/account-form.svelte b/frontend/src/routes/settings/account/account-form.svelte index 62167e26..3f1e806f 100644 --- a/frontend/src/routes/settings/account/account-form.svelte +++ b/frontend/src/routes/settings/account/account-form.svelte @@ -52,14 +52,14 @@ async function updateProfilePicture(image: File) { await userService - .updateProfilePicture(userId, image) + .updateCurrentUsersProfilePicture(image) .then(() => toast.success(m.profile_picture_updated_successfully())) .catch(axiosErrorToast); } async function resetProfilePicture() { await userService - .resetProfilePicture(userId) + .resetCurrentUserProfilePicture() .then(() => toast.success(m.profile_picture_has_been_reset())) .catch(axiosErrorToast); }