mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-17 02:32:59 +03:00
fix: create reusable default profile pictures (#406)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -4,6 +4,10 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// @title Pocket ID API
|
||||||
|
// @version 1.0
|
||||||
|
// @description API for Pocket ID
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
bootstrap.Bootstrap()
|
bootstrap.Bootstrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ import (
|
|||||||
// This is used to register additional controllers for tests
|
// This is used to register additional controllers for tests
|
||||||
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService)
|
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) {
|
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||||
// Set the appropriate Gin mode based on the environment
|
// Set the appropriate Gin mode based on the environment
|
||||||
switch common.EnvConfig.AppEnv {
|
switch common.EnvConfig.AppEnv {
|
||||||
@@ -62,6 +59,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
|
|
||||||
job.RegisterLdapJobs(ldapService, appConfigService)
|
job.RegisterLdapJobs(ldapService, appConfigService)
|
||||||
job.RegisterDbCleanupJobs(db)
|
job.RegisterDbCleanupJobs(db)
|
||||||
|
job.RegisterFileCleanupJobs(db)
|
||||||
|
|
||||||
// Initialize middleware for specific routes
|
// Initialize middleware for specific routes
|
||||||
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
|
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -17,7 +16,7 @@ func RegisterDbCleanupJobs(db *gorm.DB) {
|
|||||||
log.Fatalf("Failed to create a new scheduler: %s", err)
|
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, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
|
||||||
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
|
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
|
||||||
@@ -27,50 +26,31 @@ func RegisterDbCleanupJobs(db *gorm.DB) {
|
|||||||
scheduler.Start()
|
scheduler.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
type Jobs struct {
|
type DbCleanupJobs struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
// 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
|
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
// 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
|
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// 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
|
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// 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
|
return j.db.Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAuditLogs deletes audit logs older than 90 days
|
// 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
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
78
backend/internal/job/file_cleanup_job.go
Normal file
78
backend/internal/job/file_cleanup_job.go
Normal 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
|
||||||
|
}
|
||||||
27
backend/internal/job/job.go
Normal file
27
backend/internal/job/job.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
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) 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 {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string
|
Token string
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -59,28 +60,58 @@ func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error)
|
|||||||
return nil, 0, &common.InvalidUUIDError{}
|
return nil, 0, &common.InvalidUUIDError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First check for a custom uploaded profile picture (userID.png)
|
||||||
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
|
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
|
||||||
file, err := os.Open(profilePicturePath)
|
file, err := os.Open(profilePicturePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Get the file size
|
// Get the file size
|
||||||
fileInfo, err := file.Stat()
|
fileInfo, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return file, fileInfo.Size(), nil
|
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)
|
user, err := s.GetUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
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
|
return defaultPicture, int64(defaultPicture.Len()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/disintegration/imageorient"
|
"github.com/disintegration/imageorient"
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
@@ -42,17 +41,7 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateDefaultProfilePicture creates a profile picture with the initials
|
// CreateDefaultProfilePicture creates a profile picture with the initials
|
||||||
func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, error) {
|
func CreateDefaultProfilePicture(initials 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)
|
|
||||||
|
|
||||||
// Create a blank image with a white background
|
// Create a blank image with a white background
|
||||||
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||||
|
|
||||||
|
|||||||
@@ -52,14 +52,14 @@
|
|||||||
|
|
||||||
async function updateProfilePicture(image: File) {
|
async function updateProfilePicture(image: File) {
|
||||||
await userService
|
await userService
|
||||||
.updateProfilePicture(userId, image)
|
.updateCurrentUsersProfilePicture(image)
|
||||||
.then(() => toast.success(m.profile_picture_updated_successfully()))
|
.then(() => toast.success(m.profile_picture_updated_successfully()))
|
||||||
.catch(axiosErrorToast);
|
.catch(axiosErrorToast);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetProfilePicture() {
|
async function resetProfilePicture() {
|
||||||
await userService
|
await userService
|
||||||
.resetProfilePicture(userId)
|
.resetCurrentUserProfilePicture()
|
||||||
.then(() => toast.success(m.profile_picture_has_been_reset()))
|
.then(() => toast.success(m.profile_picture_has_been_reset()))
|
||||||
.catch(axiosErrorToast);
|
.catch(axiosErrorToast);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user