package service import ( "context" "errors" "fmt" "io" "mime/multipart" "path" "strings" "sync" "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image" "github.com/pocket-id/pocket-id/backend/resources" ) type AppImagesService struct { mu sync.RWMutex extensions map[string]string storage storage.FileStorage } func NewAppImagesService(extensions map[string]string, storage storage.FileStorage) *AppImagesService { return &AppImagesService{extensions: extensions, storage: storage} } func (s *AppImagesService) GetImage(ctx context.Context, name string) (io.ReadCloser, int64, string, error) { ext, err := s.getExtension(name) if err != nil { return nil, 0, "", err } mimeType := utils.GetImageMimeType(ext) if mimeType == "" { return nil, 0, "", fmt.Errorf("unsupported image type '%s'", ext) } imagePath := path.Join("application-images", name+"."+ext) reader, size, err := s.storage.Open(ctx, imagePath) if err != nil { if storage.IsNotExist(err) { return nil, 0, "", apperror.ImageNotFound() } return nil, 0, "", err } return reader, size, mimeType, nil } // GetImageWithDefault behaves like GetImage, but falls back to the image embedded in the binary if no custom image has been uploaded func (s *AppImagesService) GetImageWithDefault(ctx context.Context, name string) (f io.ReadCloser, size int64, mimeType string, err error) { f, size, mimeType, err = s.GetImage(ctx, name) if err == nil || !apperror.IsCode(err, apperror.CodeImageNotFound) { return f, size, mimeType, err } return getDefaultImage(name) } func getDefaultImage(name string) (io.ReadCloser, int64, string, error) { // Map an image name to an image embedded in the binary var imagePath string switch name { case "logoLight": imagePath = "default-images/logoLight.svg" case "logoDark": imagePath = "default-images/logoDark.svg" default: return nil, 0, "", apperror.ImageNotFound() } file, err := resources.FS.Open(imagePath) if err != nil { return nil, 0, "", fmt.Errorf("failed to open default image '%s': %w", name, err) } stat, err := file.Stat() if err != nil { file.Close() return nil, 0, "", fmt.Errorf("failed to get size of default image '%s': %w", name, err) } return file, stat.Size(), utils.GetImageMimeType(utils.GetFileExtension(imagePath)), nil } func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.FileHeader, imageName string) error { fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) mimeType := utils.GetImageMimeType(fileType) if mimeType == "" { return apperror.UnsupportedFileType("") } s.mu.Lock() defer s.mu.Unlock() currentExt := s.extensions[imageName] imagePath := path.Join("application-images", imageName+"."+fileType) fileReader, err := file.Open() if err != nil { return err } defer fileReader.Close() strippedReader, err := imageutil.StripMetadata(fileReader, fileType) if errors.Is(err, imageutil.ErrInvalidImage) { return apperror.InvalidImage(err) } if err != nil { return err } if err := s.storage.Save(ctx, imagePath, strippedReader); err != nil { return err } if currentExt != "" && currentExt != fileType { oldImagePath := path.Join("application-images", imageName+"."+currentExt) if err := s.storage.Delete(ctx, oldImagePath); err != nil { return err } } s.extensions[imageName] = fileType if err := s.storage.Delete(ctx, deletedApplicationImagePath(imageName)); err != nil { return err } return nil } func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) error { s.mu.Lock() defer s.mu.Unlock() ext, ok := s.extensions[imageName] if !ok || ext == "" { return apperror.ImageNotFound() } if err := s.storage.Save(ctx, deletedApplicationImagePath(imageName), strings.NewReader("")); err != nil { return err } imagePath := path.Join("application-images", imageName+"."+ext) if err := s.storage.Delete(ctx, imagePath); err != nil { return err } delete(s.extensions, imageName) return nil } func deletedApplicationImagePath(imageName string) string { return path.Join("application-images", ".deleted", imageName) } func (s *AppImagesService) IsDefaultProfilePictureSet() bool { s.mu.RLock() defer s.mu.RUnlock() _, ok := s.extensions["default-profile-picture"] return ok } func (s *AppImagesService) getExtension(name string) (string, error) { s.mu.RLock() defer s.mu.RUnlock() ext, ok := s.extensions[name] if !ok || ext == "" { return "", apperror.ImageNotFound() } return strings.ToLower(ext), nil }