fix: create reusable default profile pictures (#406)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-04-03 08:06:56 -05:00
committed by GitHub
parent 0d31c0ec6c
commit 734c6813ea
9 changed files with 166 additions and 46 deletions

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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})

View File

@@ -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);
}