diff --git a/.env.example b/.env.example index c1bdbc8..9f00fd1 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,9 @@ IMMICH_ENABLED=true # Ce flag pilote aussi le lancement Docker via ./deploy/compose-up.sh # Immich utilise son propre Postgres (pgvecto.rs) via immich-postgres IMMICH_API_URL=http://immich-server:2283/api +# Quota photos partagé avec Ultidrive (0 = désactivé) +ULTID_PHOTOS_MAX_UPLOAD_BYTES=0 +ULTID_PHOTOS_QUOTA_RESERVED_BYTES=0 IMMICH_DB_NAME=immich IMMICH_UPLOAD_LOCATION=/upload diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index eca752e..ac4b8f5 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -215,7 +215,7 @@ func main() { r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) } if photosClient != nil { - r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient).Routes()) + r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) } }) diff --git a/internal/api/photos/handlers.go b/internal/api/photos/handlers.go index 3e06374..6f7fe7a 100644 --- a/internal/api/photos/handlers.go +++ b/internal/api/photos/handlers.go @@ -1,6 +1,8 @@ package photos import ( + "context" + "errors" "io" "log/slog" "net/http" @@ -11,8 +13,9 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" - photospkg "github.com/ultisuite/ulti-backend/internal/photos" + "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/permission" + photospkg "github.com/ultisuite/ulti-backend/internal/photos" ) type Handler struct { @@ -20,9 +23,11 @@ type Handler struct { logger *slog.Logger } -func NewHandler(client *photospkg.Client) *Handler { +type contentLengthContextKey struct{} + +func NewHandler(client *photospkg.Client, quota *nextcloud.Client) *Handler { return &Handler{ - svc: NewService(client), + svc: NewService(client, quota), logger: slog.Default().With("component", "photos-api"), } } @@ -35,8 +40,14 @@ func (h *Handler) Routes() chi.Router { r.With(read).Get("/assets", h.ListAssets) r.With(read).Get("/assets/{assetID}/thumbnail", h.Thumbnail) r.With(read).Get("/albums", h.ListAlbums) + r.With(read).Get("/albums/{albumID}", h.GetAlbum) r.With(write).Post("/assets", h.Upload) + r.With(write).Post("/albums", h.CreateAlbum) + r.With(write).Patch("/albums/{albumID}", h.UpdateAlbum) r.With(write).Delete("/assets/{assetID}", h.DeleteAsset) + r.With(write).Delete("/albums/{albumID}", h.DeleteAlbum) + r.With(write).Put("/albums/{albumID}/assets", h.AddAlbumAssets) + r.With(write).Delete("/albums/{albumID}/assets", h.RemoveAlbumAssets) return r } @@ -59,11 +70,12 @@ func (h *Handler) ListAssets(w http.ResponseWriter, r *http.Request) { func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) + ctx := context.WithValue(r.Context(), contentLengthContextKey{}, r.ContentLength) - id, err := h.svc.UploadAsset(r.Context(), claims.Sub, r.Body, r.Header.Get("Content-Type")) + id, err := h.svc.UploadAsset(ctx, claims.Sub, r.Body, r.Header.Get("Content-Type")) if err != nil { h.logger.Error("upload asset", "error", err) - apivalidate.WriteInternal(w, r) + writePhotosError(w, r, err) return } apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id}) @@ -79,7 +91,7 @@ func (h *Handler) Thumbnail(w http.ResponseWriter, r *http.Request) { body, ct, err := h.svc.GetAssetThumbnail(r.Context(), claims.Sub, assetID) if err != nil { - apivalidate.WriteNotFound(w, r, "not found") + writePhotosError(w, r, err) return } defer body.Close() @@ -98,7 +110,7 @@ func (h *Handler) DeleteAsset(w http.ResponseWriter, r *http.Request) { if err := h.svc.DeleteAsset(r.Context(), claims.Sub, assetID); err != nil { h.logger.Error("delete asset", "error", err) - apivalidate.WriteInternal(w, r) + writePhotosError(w, r, err) return } w.WriteHeader(http.StatusNoContent) @@ -115,8 +127,177 @@ func (h *Handler) ListAlbums(w http.ResponseWriter, r *http.Request) { result, err := h.svc.ListAlbums(r.Context(), claims.Sub, params) if err != nil { h.logger.Error("list albums", "error", err) - apivalidate.WriteInternal(w, r) + writePhotosError(w, r, err) return } apiresponse.WriteJSON(w, http.StatusOK, result) } + +func (h *Handler) GetAlbum(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + albumID := chi.URLParam(r, "albumID") + if verr := validateAlbumID(albumID); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + album, err := h.svc.GetAlbum(r.Context(), claims.Sub, albumID) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("get album", "error", err) + writePhotosError(w, r, err) + return + } + apiresponse.WriteJSON(w, http.StatusOK, album) +} + +func (h *Handler) CreateAlbum(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req createAlbumRequest + if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil { + return + } + if verr := validateCreateAlbum(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + album, err := h.svc.CreateAlbum(r.Context(), claims.Sub, &req) + if err != nil { + h.logger.Error("create album", "error", err) + writePhotosError(w, r, err) + return + } + apiresponse.WriteJSON(w, http.StatusCreated, album) +} + +func (h *Handler) UpdateAlbum(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + albumID := chi.URLParam(r, "albumID") + if verr := validateAlbumID(albumID); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + var req updateAlbumRequest + if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil { + return + } + if verr := validateUpdateAlbum(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + album, err := h.svc.UpdateAlbum(r.Context(), claims.Sub, albumID, &req) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("update album", "error", err) + writePhotosError(w, r, err) + return + } + apiresponse.WriteJSON(w, http.StatusOK, album) +} + +func (h *Handler) DeleteAlbum(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + albumID := chi.URLParam(r, "albumID") + if verr := validateAlbumID(albumID); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + if err := h.svc.DeleteAlbum(r.Context(), claims.Sub, albumID); err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("delete album", "error", err) + writePhotosError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) AddAlbumAssets(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + albumID := chi.URLParam(r, "albumID") + if verr := validateAlbumID(albumID); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + var req albumAssetsRequest + if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil { + return + } + if verr := validateAlbumAssetsRequest(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + result, err := h.svc.AddAlbumAssets(r.Context(), claims.Sub, albumID, req.AssetIDs) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("add album assets", "error", err) + writePhotosError(w, r, err) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) RemoveAlbumAssets(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + albumID := chi.URLParam(r, "albumID") + if verr := validateAlbumID(albumID); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + var req albumAssetsRequest + if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil { + return + } + if verr := validateAlbumAssetsRequest(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + result, err := h.svc.RemoveAlbumAssets(r.Context(), claims.Sub, albumID, req.AssetIDs) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("remove album assets", "error", err) + writePhotosError(w, r, err) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func writePhotosError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, ErrNotFound): + apivalidate.WriteNotFound(w, r, "not found") + case errors.Is(err, ErrConflict): + apiresponse.WriteError(w, r, http.StatusConflict, "photos.conflict", "resource conflict", nil) + case errors.Is(err, ErrForbidden): + apiresponse.WriteError(w, r, http.StatusForbidden, "photos.forbidden", "forbidden", nil) + case errors.Is(err, ErrQuotaExceeded): + apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "photos.quota_exceeded", "quota exceeded", nil) + case errors.Is(err, ErrInvalid): + apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil) + default: + apivalidate.WriteInternal(w, r) + } +} diff --git a/internal/api/photos/list.go b/internal/api/photos/list.go new file mode 100644 index 0000000..9c64fb5 --- /dev/null +++ b/internal/api/photos/list.go @@ -0,0 +1,173 @@ +package photos + +import ( + "sort" + "strings" + "time" + + "github.com/ultisuite/ulti-backend/internal/api/paginate" + "github.com/ultisuite/ulti-backend/internal/api/query" + photospkg "github.com/ultisuite/ulti-backend/internal/photos" +) + +var photosTimeFormats = []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.000Z", + "2006-01-02", +} + +func parsePhotosTime(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + for _, layout := range photosTimeFormats { + if t, err := time.Parse(layout, raw); err == nil { + return t.UTC(), true + } + } + return time.Time{}, false +} + +func parseSortField(sort string) (field string, desc bool) { + sort = strings.TrimSpace(sort) + if sort == "" { + return "created_at", true + } + if strings.HasPrefix(sort, "-") { + return strings.TrimPrefix(sort, "-"), true + } + return sort, false +} + +func assetMatchesDate(a photospkg.Asset, from, to *time.Time) bool { + if from == nil && to == nil { + return true + } + created, ok := parsePhotosTime(a.CreatedAt) + if !ok { + return false + } + if from != nil && created.Before(*from) { + return false + } + if to != nil && created.After(*to) { + return false + } + return true +} + +func albumMatchesDate(a photospkg.Album, from, to *time.Time) bool { + if from == nil && to == nil { + return true + } + created, ok := parsePhotosTime(a.CreatedAt) + if !ok { + return false + } + if from != nil && created.Before(*from) { + return false + } + if to != nil && created.After(*to) { + return false + } + return true +} + +func filterAssets(assets []photospkg.Asset, params query.ListParams) []photospkg.Asset { + q := strings.ToLower(strings.TrimSpace(params.Q)) + out := make([]photospkg.Asset, 0, len(assets)) + for _, a := range assets { + if q != "" { + if !strings.Contains(strings.ToLower(a.OriginalName), q) && + !strings.Contains(strings.ToLower(a.MimeType), q) { + continue + } + } + if !assetMatchesDate(a, params.From, params.To) { + continue + } + out = append(out, a) + } + return out +} + +func filterAlbums(albums []photospkg.Album, params query.ListParams) []photospkg.Album { + q := strings.ToLower(strings.TrimSpace(params.Q)) + out := make([]photospkg.Album, 0, len(albums)) + for _, a := range albums { + if q != "" && !strings.Contains(strings.ToLower(a.Name), q) { + continue + } + if !albumMatchesDate(a, params.From, params.To) { + continue + } + out = append(out, a) + } + return out +} + +func sortAssets(assets []photospkg.Asset, sortParam string) { + field, desc := parseSortField(sortParam) + sort.SliceStable(assets, func(i, j int) bool { + if desc { + return compareAssets(assets[j], assets[i], field) + } + return compareAssets(assets[i], assets[j], field) + }) +} + +func compareAssets(a, b photospkg.Asset, field string) bool { + switch field { + case "name": + return strings.ToLower(a.OriginalName) < strings.ToLower(b.OriginalName) + case "size": + return a.FileSize < b.FileSize + default: // created_at + ti, okI := parsePhotosTime(a.CreatedAt) + tj, okJ := parsePhotosTime(b.CreatedAt) + if okI && okJ { + return ti.Before(tj) + } + return a.CreatedAt < b.CreatedAt + } +} + +func sortAlbums(albums []photospkg.Album, sortParam string) { + field, desc := parseSortField(sortParam) + sort.SliceStable(albums, func(i, j int) bool { + if desc { + return compareAlbums(albums[j], albums[i], field) + } + return compareAlbums(albums[i], albums[j], field) + }) +} + +func compareAlbums(a, b photospkg.Album, field string) bool { + switch field { + case "name": + return strings.ToLower(a.Name) < strings.ToLower(b.Name) + case "size": + return a.AssetCount < b.AssetCount + default: // created_at + ti, okI := parsePhotosTime(a.CreatedAt) + tj, okJ := parsePhotosTime(b.CreatedAt) + if okI && okJ { + return ti.Before(tj) + } + return a.CreatedAt < b.CreatedAt + } +} + +func applyAssetList(assets []photospkg.Asset, params query.ListParams) ([]photospkg.Asset, int64) { + filtered := filterAssets(assets, params) + sortAssets(filtered, params.Sort) + return paginate.Slice(filtered, params.Offset(), params.Limit()) +} + +func applyAlbumList(albums []photospkg.Album, params query.ListParams) ([]photospkg.Album, int64) { + filtered := filterAlbums(albums, params) + sortAlbums(filtered, params.Sort) + return paginate.Slice(filtered, params.Offset(), params.Limit()) +} diff --git a/internal/api/photos/service.go b/internal/api/photos/service.go index f3c4732..40757f3 100644 --- a/internal/api/photos/service.go +++ b/internal/api/photos/service.go @@ -2,20 +2,44 @@ package photos import ( "context" + "errors" "io" + "net/http" + "os" + "strconv" "strings" - "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/nextcloud" photospkg "github.com/ultisuite/ulti-backend/internal/photos" ) -type Service struct { - client *photospkg.Client +var ( + ErrNotFound = errors.New("not found") + ErrConflict = errors.New("conflict") + ErrInvalid = errors.New("invalid request") + ErrForbidden = errors.New("forbidden") + ErrQuotaExceeded = errors.New("quota exceeded") +) + +type quotaProvider interface { + GetQuota(ctx context.Context, userID string) (nextcloud.UserQuota, error) } -func NewService(client *photospkg.Client) *Service { - return &Service{client: client} +type Service struct { + client *photospkg.Client + quota quotaProvider + maxUploadBytes int64 + quotaReserveByte int64 +} + +func NewService(client *photospkg.Client, quota quotaProvider) *Service { + return &Service{ + client: client, + quota: quota, + maxUploadBytes: envInt64("ULTID_PHOTOS_MAX_UPLOAD_BYTES", 0), + quotaReserveByte: envInt64("ULTID_PHOTOS_QUOTA_RESERVED_BYTES", 0), + } } type AssetsList struct { @@ -25,13 +49,11 @@ type AssetsList struct { } func (s *Service) ListAssets(ctx context.Context, apiKey string, params query.ListParams) (AssetsList, error) { - assets, err := s.client.GetAssets(ctx, apiKey, params.Page, params.PageSize) + assets, err := s.client.GetAllAssets(ctx, apiKey) if err != nil { - return AssetsList{}, err + return AssetsList{}, mapPhotosError(err) } - filtered := filterAssets(assets, params.Q) - total := int64(len(filtered)) - page, _ := paginate.Slice(filtered, 0, len(filtered)) + page, total := applyAssetList(assets, params) return AssetsList{ Assets: page, Page: params.Page, @@ -47,53 +69,153 @@ type AlbumsList struct { func (s *Service) ListAlbums(ctx context.Context, apiKey string, params query.ListParams) (AlbumsList, error) { albums, err := s.client.GetAlbums(ctx, apiKey) if err != nil { - return AlbumsList{}, err + return AlbumsList{}, mapPhotosError(err) } - filtered := filterAlbums(albums, params.Q) - page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) + page, total := applyAlbumList(albums, params) return AlbumsList{ Albums: page, Pagination: params.Meta(&total), }, nil } +func (s *Service) GetAlbum(ctx context.Context, apiKey, albumID string) (photospkg.Album, error) { + album, err := s.client.GetAlbum(ctx, apiKey, albumID) + if err != nil { + return photospkg.Album{}, mapPhotosError(err) + } + return album, nil +} + +func (s *Service) CreateAlbum(ctx context.Context, apiKey string, req *createAlbumRequest) (photospkg.Album, error) { + album, err := s.client.CreateAlbum(ctx, apiKey, strings.TrimSpace(req.Name), strings.TrimSpace(req.Description), req.AssetIDs) + if err != nil { + return photospkg.Album{}, mapPhotosError(err) + } + return album, nil +} + +func (s *Service) UpdateAlbum(ctx context.Context, apiKey, albumID string, req *updateAlbumRequest) (photospkg.Album, error) { + var name, description *string + if req.Name != nil { + trimmed := strings.TrimSpace(*req.Name) + name = &trimmed + } + if req.Description != nil { + trimmed := strings.TrimSpace(*req.Description) + description = &trimmed + } + album, err := s.client.UpdateAlbum(ctx, apiKey, albumID, name, description) + if err != nil { + return photospkg.Album{}, mapPhotosError(err) + } + return album, nil +} + +func (s *Service) DeleteAlbum(ctx context.Context, apiKey, albumID string) error { + return mapPhotosError(s.client.DeleteAlbum(ctx, apiKey, albumID)) +} + +type AlbumAssetsResult struct { + Results []photospkg.BulkAssetResult `json:"results"` +} + +func (s *Service) AddAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) (AlbumAssetsResult, error) { + results, err := s.client.AddAlbumAssets(ctx, apiKey, albumID, assetIDs) + if err != nil { + return AlbumAssetsResult{}, mapPhotosError(err) + } + return AlbumAssetsResult{Results: results}, nil +} + +func (s *Service) RemoveAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) (AlbumAssetsResult, error) { + results, err := s.client.RemoveAlbumAssets(ctx, apiKey, albumID, assetIDs) + if err != nil { + return AlbumAssetsResult{}, mapPhotosError(err) + } + return AlbumAssetsResult{Results: results}, nil +} + +func mapPhotosError(err error) error { + if err == nil { + return nil + } + var statusErr *photospkg.HTTPStatusError + if !errors.As(err, &statusErr) { + return err + } + switch statusErr.StatusCode { + case http.StatusNotFound: + return ErrNotFound + case http.StatusConflict: + return ErrConflict + case http.StatusForbidden, http.StatusUnauthorized: + return ErrForbidden + case http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge: + return ErrQuotaExceeded + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return ErrInvalid + default: + return err + } +} + func (s *Service) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) { - return s.client.UploadAsset(ctx, apiKey, body, contentType) + if err := s.ensureQuota(ctx, apiKey); err != nil { + return "", err + } + id, err := s.client.UploadAsset(ctx, apiKey, body, contentType) + return id, mapPhotosError(err) } func (s *Service) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) { - return s.client.GetAssetThumbnail(ctx, apiKey, assetID) + body, contentType, err := s.client.GetAssetThumbnail(ctx, apiKey, assetID) + return body, contentType, mapPhotosError(err) } func (s *Service) DeleteAsset(ctx context.Context, apiKey, assetID string) error { - return s.client.DeleteAsset(ctx, apiKey, assetID) + return mapPhotosError(s.client.DeleteAsset(ctx, apiKey, assetID)) } -func filterAssets(assets []photospkg.Asset, q string) []photospkg.Asset { - q = strings.ToLower(strings.TrimSpace(q)) - if q == "" { - return assets +func (s *Service) ensureQuota(ctx context.Context, userID string) error { + if s.quota == nil { + return nil } - out := make([]photospkg.Asset, 0, len(assets)) - for _, a := range assets { - if strings.Contains(strings.ToLower(a.OriginalName), q) || - strings.Contains(strings.ToLower(a.MimeType), q) { - out = append(out, a) - } + + incomingBytes := requestContentLength(ctx) + if incomingBytes > 0 && s.maxUploadBytes > 0 && incomingBytes > s.maxUploadBytes { + return ErrQuotaExceeded } - return out + if incomingBytes <= 0 { + return nil + } + + quota, err := s.quota.GetQuota(ctx, userID) + if err != nil { + return err + } + if quota.Free <= 0 || incomingBytes+s.quotaReserveByte > quota.Free { + return ErrQuotaExceeded + } + return nil } -func filterAlbums(albums []photospkg.Album, q string) []photospkg.Album { - q = strings.ToLower(strings.TrimSpace(q)) - if q == "" { - return albums +func requestContentLength(ctx context.Context) int64 { + v := ctx.Value(contentLengthContextKey{}) + n, ok := v.(int64) + if !ok { + return 0 } - out := make([]photospkg.Album, 0, len(albums)) - for _, a := range albums { - if strings.Contains(strings.ToLower(a.Name), q) { - out = append(out, a) - } - } - return out + return n +} + +func envInt64(key string, fallback int64) int64 { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fallback + } + return n } diff --git a/internal/api/photos/service_test.go b/internal/api/photos/service_test.go new file mode 100644 index 0000000..abb72b5 --- /dev/null +++ b/internal/api/photos/service_test.go @@ -0,0 +1,367 @@ +package photos + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/nextcloud" + photospkg "github.com/ultisuite/ulti-backend/internal/photos" +) + +func sampleAssets() []photospkg.Asset { + return []photospkg.Asset{ + {ID: "a1", OriginalName: "alpha.jpg", MimeType: "image/jpeg", FileSize: 100, CreatedAt: "2026-01-01T10:00:00Z"}, + {ID: "a2", OriginalName: "beta.png", MimeType: "image/png", FileSize: 300, CreatedAt: "2026-01-15T10:00:00Z"}, + {ID: "a3", OriginalName: "gamma.jpg", MimeType: "image/jpeg", FileSize: 200, CreatedAt: "2026-02-01T10:00:00Z"}, + {ID: "a4", OriginalName: "delta.tiff", MimeType: "image/tiff", FileSize: 50, CreatedAt: "2026-02-10T10:00:00Z"}, + } +} + +func sampleAlbums() []photospkg.Album { + return []photospkg.Album{ + {ID: "al1", Name: "Alpha Album", AssetCount: 5, CreatedAt: "2026-01-01T10:00:00Z"}, + {ID: "al2", Name: "Beta Album", AssetCount: 15, CreatedAt: "2026-01-20T10:00:00Z"}, + {ID: "al3", Name: "Gamma Album", AssetCount: 8, CreatedAt: "2026-02-05T10:00:00Z"}, + } +} + +func mustDate(t *testing.T, raw string) *time.Time { + t.Helper() + d, err := query.ParseDate(raw) + if err != nil { + t.Fatalf("ParseDate(%q): %v", raw, err) + } + return &d +} + +func mustEndDate(t *testing.T, raw string) *time.Time { + t.Helper() + d, err := query.ParseDate(raw) + if err != nil { + t.Fatalf("ParseDate(%q): %v", raw, err) + } + end := d.Add(24*time.Hour - time.Nanosecond) + return &end +} + +func TestApplyAssetListPagination(t *testing.T) { + params := query.ListParams{Page: 2, PageSize: 2, Sort: "name"} + page, total := applyAssetList(sampleAssets(), params) + if total != 4 { + t.Fatalf("total = %d, want 4", total) + } + if len(page) != 2 { + t.Fatalf("page len = %d, want 2", len(page)) + } + if page[0].ID != "a4" || page[1].ID != "a3" { + t.Fatalf("page ids = %q/%q, want a4/a3", page[0].ID, page[1].ID) + } + meta := params.Meta(&total) + if meta.Page != 2 || meta.PageSize != 2 || meta.Total == nil || *meta.Total != 4 { + t.Fatalf("meta = %+v", meta) + } +} + +func TestApplyAssetListPaginationBeyondRange(t *testing.T) { + params := query.ListParams{Page: 10, PageSize: 2} + page, total := applyAssetList(sampleAssets(), params) + if total != 4 { + t.Fatalf("total = %d, want 4", total) + } + if len(page) != 0 { + t.Fatalf("page len = %d, want 0", len(page)) + } +} + +func TestApplyAssetListSortByNameAsc(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 10, Sort: "name"} + page, _ := applyAssetList(sampleAssets(), params) + if len(page) != 4 { + t.Fatalf("len = %d", len(page)) + } + want := []string{"a1", "a2", "a4", "a3"} + for i, id := range want { + if page[i].ID != id { + t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) + } + } +} + +func TestApplyAssetListSortBySizeDesc(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 10, Sort: "-size"} + page, _ := applyAssetList(sampleAssets(), params) + want := []string{"a2", "a3", "a1", "a4"} + for i, id := range want { + if page[i].ID != id { + t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) + } + } +} + +func TestApplyAssetListSortByCreatedAtDefaultDesc(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 10} + page, _ := applyAssetList(sampleAssets(), params) + want := []string{"a4", "a3", "a2", "a1"} + for i, id := range want { + if page[i].ID != id { + t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) + } + } +} + +func TestApplyAssetListFilterByQ(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 10, Q: "jpg"} + page, total := applyAssetList(sampleAssets(), params) + if total != 2 { + t.Fatalf("total = %d, want 2", total) + } + if page[0].ID != "a3" || page[1].ID != "a1" { + t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) + } +} + +func TestApplyAssetListFilterByQMimeType(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 10, Q: "tiff"} + page, total := applyAssetList(sampleAssets(), params) + if total != 1 || page[0].ID != "a4" { + t.Fatalf("total=%d page=%+v", total, page) + } +} + +func TestApplyAssetListFilterByDateRange(t *testing.T) { + params := query.ListParams{ + Page: 1, + PageSize: 10, + From: mustDate(t, "2026-01-10"), + To: mustEndDate(t, "2026-02-05"), + } + page, total := applyAssetList(sampleAssets(), params) + if total != 2 { + t.Fatalf("total = %d, want 2", total) + } + if page[0].ID != "a3" || page[1].ID != "a2" { + t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) + } +} + +func TestApplyAlbumListPagination(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 2, Sort: "name"} + page, total := applyAlbumList(sampleAlbums(), params) + if total != 3 || len(page) != 2 { + t.Fatalf("total=%d len=%d", total, len(page)) + } + if page[0].ID != "al1" || page[1].ID != "al2" { + t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) + } +} + +func TestApplyAlbumListSortBySizeAsc(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 10, Sort: "size"} + page, _ := applyAlbumList(sampleAlbums(), params) + want := []string{"al1", "al3", "al2"} + for i, id := range want { + if page[i].ID != id { + t.Fatalf("page[%d].ID = %q, want %q", i, page[i].ID, id) + } + } +} + +func TestApplyAlbumListFilterByQAndSortCreatedAtDesc(t *testing.T) { + params := query.ListParams{Page: 1, PageSize: 10, Q: "album", Sort: "-created_at"} + page, total := applyAlbumList(sampleAlbums(), params) + if total != 3 { + t.Fatalf("total = %d, want 3", total) + } + if page[0].ID != "al3" { + t.Fatalf("first id = %q, want al3", page[0].ID) + } +} + +func TestApplyAlbumListFilterByFromDate(t *testing.T) { + params := query.ListParams{ + Page: 1, + PageSize: 10, + From: mustDate(t, "2026-01-15"), + Sort: "name", + } + page, total := applyAlbumList(sampleAlbums(), params) + if total != 2 { + t.Fatalf("total = %d, want 2", total) + } + if page[0].ID != "al2" || page[1].ID != "al3" { + t.Fatalf("ids = %q/%q", page[0].ID, page[1].ID) + } +} + +func TestServiceListAssetsUsesPaginationMeta(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/assets" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(sampleAssets()) + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), nil) + result, err := svc.ListAssets(context.Background(), "key", query.ListParams{ + Page: 2, PageSize: 2, Sort: "name", + }) + if err != nil { + t.Fatalf("ListAssets: %v", err) + } + if len(result.Assets) != 2 || result.Page != 2 { + t.Fatalf("assets=%d page=%d", len(result.Assets), result.Page) + } + if result.Pagination.Page != 2 || result.Pagination.PageSize != 2 { + t.Fatalf("pagination = %+v", result.Pagination) + } + if result.Pagination.Total == nil || *result.Pagination.Total != 4 { + t.Fatalf("total = %v", result.Pagination.Total) + } +} + +func TestServiceListAlbumsUsesPaginationMeta(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/albums" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(sampleAlbums()) + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), nil) + result, err := svc.ListAlbums(context.Background(), "key", query.ListParams{ + Page: 1, PageSize: 1, Sort: "-size", + }) + if err != nil { + t.Fatalf("ListAlbums: %v", err) + } + if len(result.Albums) != 1 || result.Albums[0].ID != "al2" { + t.Fatalf("albums = %+v", result.Albums) + } + if result.Pagination.Total == nil || *result.Pagination.Total != 3 { + t.Fatalf("total = %v", result.Pagination.Total) + } +} + +func TestServiceGetAlbumNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), nil) + _, err := svc.GetAlbum(context.Background(), "key", "missing") + if err == nil || !errors.Is(err, ErrNotFound) { + t.Fatalf("GetAlbum err = %v, want ErrNotFound", err) + } +} + +func TestServiceCreateAlbum(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/albums" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(photospkg.Album{ID: "new-al", Name: "Created", AssetCount: 1}) + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), nil) + album, err := svc.CreateAlbum(context.Background(), "key", &createAlbumRequest{ + Name: "Created", + AssetIDs: []string{"a1"}, + }) + if err != nil { + t.Fatalf("CreateAlbum: %v", err) + } + if album.ID != "new-al" || album.Name != "Created" { + t.Fatalf("album = %+v", album) + } +} + +func TestServiceUpdateAlbumInvalid(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), nil) + _, err := svc.UpdateAlbum(context.Background(), "key", "al1", &updateAlbumRequest{Name: strPtr("Renamed")}) + if err == nil || !errors.Is(err, ErrInvalid) { + t.Fatalf("UpdateAlbum err = %v, want ErrInvalid", err) + } +} + +func TestServiceDeleteAlbumNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), nil) + err := svc.DeleteAlbum(context.Background(), "key", "missing") + if err == nil || !errors.Is(err, ErrNotFound) { + t.Fatalf("DeleteAlbum err = %v, want ErrNotFound", err) + } +} + +func TestServiceAddAlbumAssets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/albums/al1/assets" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode([]photospkg.BulkAssetResult{{ID: "a1", Success: true}}) + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), nil) + result, err := svc.AddAlbumAssets(context.Background(), "key", "al1", []string{"a1"}) + if err != nil { + t.Fatalf("AddAlbumAssets: %v", err) + } + if len(result.Results) != 1 || !result.Results[0].Success { + t.Fatalf("results = %+v", result.Results) + } +} + +func strPtr(value string) *string { + return &value +} + +type quotaStub struct { + quota nextcloud.UserQuota + err error +} + +func (q quotaStub) GetQuota(context.Context, string) (nextcloud.UserQuota, error) { + return q.quota, q.err +} + +func TestServiceUploadAssetQuotaExceeded(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("upload should not be called when quota exceeded") + })) + defer server.Close() + + svc := NewService(photospkg.NewClient(server.URL), quotaStub{ + quota: nextcloud.UserQuota{Free: 10}, + }) + + ctx := context.WithValue(context.Background(), contentLengthContextKey{}, int64(100)) + _, err := svc.UploadAsset(ctx, "user-1", strings.NewReader("payload"), "application/octet-stream") + if err == nil || !errors.Is(err, ErrQuotaExceeded) { + t.Fatalf("UploadAsset err = %v, want ErrQuotaExceeded", err) + } +} diff --git a/internal/api/photos/validate.go b/internal/api/photos/validate.go index 4e11bb1..21f4f12 100644 --- a/internal/api/photos/validate.go +++ b/internal/api/photos/validate.go @@ -6,6 +6,23 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apivalidate" ) +const maxJSONRequestBody = 32 << 10 + +type createAlbumRequest struct { + Name string `json:"name"` + Description string `json:"description"` + AssetIDs []string `json:"asset_ids"` +} + +type updateAlbumRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` +} + +type albumAssetsRequest struct { + AssetIDs []string `json:"asset_ids"` +} + func validateAssetID(assetID string) *apivalidate.ValidationError { if strings.TrimSpace(assetID) == "" { return apivalidate.NewValidationError(apivalidate.FieldDetail{ @@ -14,3 +31,93 @@ func validateAssetID(assetID string) *apivalidate.ValidationError { } return nil } + +func validateAlbumID(albumID string) *apivalidate.ValidationError { + if strings.TrimSpace(albumID) == "" { + return apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "album_id", Message: "required", + }) + } + return nil +} + +func validateAlbumName(field, name string, required bool) *apivalidate.FieldDetail { + name = strings.TrimSpace(name) + if name == "" { + if required { + return &apivalidate.FieldDetail{Field: field, Message: "required"} + } + return nil + } + if strings.Contains(name, "\n") || strings.Contains(name, "\r") { + return &apivalidate.FieldDetail{Field: field, Message: "invalid"} + } + return nil +} + +func validateAssetIDs(field string, assetIDs []string, required bool) []apivalidate.FieldDetail { + if len(assetIDs) == 0 { + if required { + return []apivalidate.FieldDetail{{Field: field, Message: "required"}} + } + return nil + } + var details []apivalidate.FieldDetail + for i, id := range assetIDs { + if strings.TrimSpace(id) == "" { + details = append(details, apivalidate.FieldDetail{ + Field: field, Message: "invalid", + }) + if i == 0 { + break + } + } + } + return details +} + +func validateCreateAlbum(req *createAlbumRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + if d := validateAlbumName("name", req.Name, true); d != nil { + details = append(details, *d) + } + if d := validateAlbumName("description", req.Description, false); d != nil { + details = append(details, *d) + } + details = append(details, validateAssetIDs("asset_ids", req.AssetIDs, false)...) + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} + +func validateUpdateAlbum(req *updateAlbumRequest) *apivalidate.ValidationError { + if req.Name == nil && req.Description == nil { + return apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "body", Message: "required", + }) + } + var details []apivalidate.FieldDetail + if req.Name != nil { + if d := validateAlbumName("name", *req.Name, true); d != nil { + details = append(details, *d) + } + } + if req.Description != nil { + if d := validateAlbumName("description", *req.Description, false); d != nil { + details = append(details, *d) + } + } + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} + +func validateAlbumAssetsRequest(req *albumAssetsRequest) *apivalidate.ValidationError { + details := validateAssetIDs("asset_ids", req.AssetIDs, true) + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} diff --git a/internal/api/photos/validate_test.go b/internal/api/photos/validate_test.go new file mode 100644 index 0000000..c657d98 --- /dev/null +++ b/internal/api/photos/validate_test.go @@ -0,0 +1,73 @@ +package photos + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" +) + +func hasFieldDetail(err *apivalidate.ValidationError, field, message string) bool { + if err == nil { + return false + } + for _, d := range err.Details { + if d.Field == field && d.Message == message { + return true + } + } + return false +} + +func TestValidateAlbumID(t *testing.T) { + if validateAlbumID("album-1") != nil { + t.Fatal("expected valid album id") + } + if err := validateAlbumID(""); !hasFieldDetail(err, "album_id", "required") { + t.Fatal("expected required album_id error") + } +} + +func TestValidateCreateAlbum(t *testing.T) { + if validateCreateAlbum(&createAlbumRequest{Name: "Vacation"}) != nil { + t.Fatal("expected valid create album request") + } + if err := validateCreateAlbum(&createAlbumRequest{}); !hasFieldDetail(err, "name", "required") { + t.Fatal("expected missing name error") + } + if err := validateCreateAlbum(&createAlbumRequest{Name: "Trip", AssetIDs: []string{""}}); !hasFieldDetail(err, "asset_ids", "invalid") { + t.Fatal("expected invalid asset_ids error") + } + if err := validateCreateAlbum(&createAlbumRequest{Name: "Bad\nName"}); !hasFieldDetail(err, "name", "invalid") { + t.Fatal("expected invalid name error") + } +} + +func TestValidateUpdateAlbum(t *testing.T) { + name := "Renamed" + if validateUpdateAlbum(&updateAlbumRequest{Name: &name}) != nil { + t.Fatal("expected valid update album request") + } + desc := "Notes" + if validateUpdateAlbum(&updateAlbumRequest{Description: &desc}) != nil { + t.Fatal("expected valid description-only update") + } + if err := validateUpdateAlbum(&updateAlbumRequest{}); !hasFieldDetail(err, "body", "required") { + t.Fatal("expected empty body error") + } + blank := " " + if err := validateUpdateAlbum(&updateAlbumRequest{Name: &blank}); !hasFieldDetail(err, "name", "required") { + t.Fatal("expected blank name error") + } +} + +func TestValidateAlbumAssetsRequest(t *testing.T) { + if validateAlbumAssetsRequest(&albumAssetsRequest{AssetIDs: []string{"a1"}}) != nil { + t.Fatal("expected valid album assets request") + } + if err := validateAlbumAssetsRequest(&albumAssetsRequest{}); !hasFieldDetail(err, "asset_ids", "required") { + t.Fatal("expected required asset_ids error") + } + if err := validateAlbumAssetsRequest(&albumAssetsRequest{AssetIDs: []string{" "}}); !hasFieldDetail(err, "asset_ids", "invalid") { + t.Fatal("expected invalid asset_ids error") + } +} diff --git a/internal/photos/albums.go b/internal/photos/albums.go new file mode 100644 index 0000000..19144a3 --- /dev/null +++ b/internal/photos/albums.go @@ -0,0 +1,129 @@ +package photos + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type BulkAssetResult struct { + ID string `json:"id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +type createAlbumPayload struct { + AlbumName string `json:"albumName"` + Description string `json:"description,omitempty"` + AssetIDs []string `json:"assetIds,omitempty"` +} + +type updateAlbumPayload struct { + AlbumName *string `json:"albumName,omitempty"` + Description *string `json:"description,omitempty"` +} + +type bulkIDsPayload struct { + IDs []string `json:"ids"` +} + +func (c *Client) GetAlbum(ctx context.Context, apiKey, albumID string) (Album, error) { + url := fmt.Sprintf("%s/albums/%s?withoutAssets=true", c.baseURL, albumID) + var album Album + if err := c.doJSON(ctx, http.MethodGet, url, apiKey, nil, &album, "get album", http.StatusOK); err != nil { + return Album{}, err + } + return album, nil +} + +func (c *Client) CreateAlbum(ctx context.Context, apiKey, name, description string, assetIDs []string) (Album, error) { + payload := createAlbumPayload{ + AlbumName: name, + Description: description, + AssetIDs: assetIDs, + } + var album Album + if err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/albums", apiKey, payload, &album, "create album", http.StatusCreated); err != nil { + return Album{}, err + } + return album, nil +} + +func (c *Client) UpdateAlbum(ctx context.Context, apiKey, albumID string, name, description *string) (Album, error) { + payload := updateAlbumPayload{ + AlbumName: name, + Description: description, + } + url := fmt.Sprintf("%s/albums/%s", c.baseURL, albumID) + var album Album + if err := c.doJSON(ctx, http.MethodPatch, url, apiKey, payload, &album, "update album", http.StatusOK); err != nil { + return Album{}, err + } + return album, nil +} + +func (c *Client) DeleteAlbum(ctx context.Context, apiKey, albumID string) error { + url := fmt.Sprintf("%s/albums/%s", c.baseURL, albumID) + return c.doJSON(ctx, http.MethodDelete, url, apiKey, nil, nil, "delete album", http.StatusOK, http.StatusNoContent) +} + +func (c *Client) AddAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) ([]BulkAssetResult, error) { + url := fmt.Sprintf("%s/albums/%s/assets", c.baseURL, albumID) + var results []BulkAssetResult + if err := c.doJSON(ctx, http.MethodPut, url, apiKey, bulkIDsPayload{IDs: assetIDs}, &results, "add album assets", http.StatusOK); err != nil { + return nil, err + } + return results, nil +} + +func (c *Client) RemoveAlbumAssets(ctx context.Context, apiKey, albumID string, assetIDs []string) ([]BulkAssetResult, error) { + url := fmt.Sprintf("%s/albums/%s/assets", c.baseURL, albumID) + var results []BulkAssetResult + if err := c.doJSON(ctx, http.MethodDelete, url, apiKey, bulkIDsPayload{IDs: assetIDs}, &results, "remove album assets", http.StatusOK); err != nil { + return nil, err + } + return results, nil +} + +func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, reqBody any, dest any, operation string, okStatuses ...int) error { + var body io.Reader + if reqBody != nil { + data, err := json.Marshal(reqBody) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return err + } + req.Header.Set("x-api-key", apiKey) + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + for _, status := range okStatuses { + if resp.StatusCode == status { + if dest != nil { + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return err + } + } + return nil + } + } + + io.Copy(io.Discard, resp.Body) + return &HTTPStatusError{Operation: operation, StatusCode: resp.StatusCode} +} diff --git a/internal/photos/client.go b/internal/photos/client.go index ac6e461..788a0b4 100644 --- a/internal/photos/client.go +++ b/internal/photos/client.go @@ -1,6 +1,7 @@ package photos import ( + "bytes" "context" "encoding/json" "fmt" @@ -29,22 +30,49 @@ type Asset struct { OriginalPath string `json:"originalPath"` OriginalName string `json:"originalFileName"` MimeType string `json:"originalMimeType"` - FileSize int64 `json:"exifInfo.fileSizeInByte"` + FileSize int64 `json:"fileSizeInByte"` CreatedAt string `json:"fileCreatedAt"` IsFavorite bool `json:"isFavorite"` ThumbHash string `json:"thumbhash"` + ExifInfo struct { + FileSizeInByte int64 `json:"fileSizeInByte"` + } `json:"exifInfo,omitempty"` } type Album struct { - ID string `json:"id"` - Name string `json:"albumName"` - AssetCount int `json:"assetCount"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + Name string `json:"albumName"` + Description string `json:"description,omitempty"` + AssetCount int `json:"assetCount"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +const assetsPageSize = 200 + +func (c *Client) GetAllAssets(ctx context.Context, apiKey string) ([]Asset, error) { + var all []Asset + page := 1 + for { + batch, err := c.GetAssets(ctx, apiKey, page, assetsPageSize) + if err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < assetsPageSize { + break + } + page++ + } + return all, nil } func (c *Client) GetAssets(ctx context.Context, apiKey string, page, size int) ([]Asset, error) { url := fmt.Sprintf("%s/assets?page=%d&size=%d", c.baseURL, page, size) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } req.Header.Set("x-api-key", apiKey) resp, err := c.httpClient.Do(req) @@ -52,15 +80,52 @@ func (c *Client) GetAssets(ctx context.Context, apiKey string, page, size int) ( return nil, err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + return nil, &HTTPStatusError{Operation: "get assets", StatusCode: resp.StatusCode} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } var assets []Asset - json.NewDecoder(resp.Body).Decode(&assets) + trimmed := bytes.TrimSpace(body) + switch { + case len(trimmed) == 0: + return []Asset{}, nil + case trimmed[0] == '[': + if err := json.Unmarshal(trimmed, &assets); err != nil { + return nil, err + } + default: + var wrapped struct { + Items []Asset `json:"items"` + Assets []Asset `json:"assets"` + } + if err := json.Unmarshal(trimmed, &wrapped); err != nil { + return nil, err + } + if len(wrapped.Items) > 0 { + assets = wrapped.Items + } else { + assets = wrapped.Assets + } + } + for i := range assets { + if assets[i].FileSize == 0 { + assets[i].FileSize = assets[i].ExifInfo.FileSizeInByte + } + } return assets, nil } func (c *Client) GetAlbums(ctx context.Context, apiKey string) ([]Album, error) { url := fmt.Sprintf("%s/albums", c.baseURL) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } req.Header.Set("x-api-key", apiKey) resp, err := c.httpClient.Do(req) @@ -68,15 +133,24 @@ func (c *Client) GetAlbums(ctx context.Context, apiKey string) ([]Album, error) return nil, err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + return nil, &HTTPStatusError{Operation: "get albums", StatusCode: resp.StatusCode} + } var albums []Album - json.NewDecoder(resp.Body).Decode(&albums) + if err := json.NewDecoder(resp.Body).Decode(&albums); err != nil { + return nil, err + } return albums, nil } func (c *Client) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) { url := fmt.Sprintf("%s/assets", c.baseURL) - req, _ := http.NewRequestWithContext(ctx, "POST", url, body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return "", err + } req.Header.Set("x-api-key", apiKey) req.Header.Set("Content-Type", contentType) @@ -85,36 +159,61 @@ func (c *Client) UploadAsset(ctx context.Context, apiKey string, body io.Reader, return "", err } defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + return "", &HTTPStatusError{Operation: "upload asset", StatusCode: resp.StatusCode} + } var result struct { - ID string `json:"id"` + ID string `json:"id"` + AssetID string `json:"assetId"` } - json.NewDecoder(resp.Body).Decode(&result) - return result.ID, nil + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if result.ID != "" { + return result.ID, nil + } + return result.AssetID, nil } func (c *Client) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) { url := fmt.Sprintf("%s/assets/%s/thumbnail", c.baseURL, assetID) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, "", err + } req.Header.Set("x-api-key", apiKey) resp, err := c.httpClient.Do(req) if err != nil { return nil, "", err } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + return nil, "", &HTTPStatusError{Operation: "get asset thumbnail", StatusCode: resp.StatusCode} + } return resp.Body, resp.Header.Get("Content-Type"), nil } func (c *Client) DeleteAsset(ctx context.Context, apiKey, assetID string) error { url := fmt.Sprintf("%s/assets/%s", c.baseURL, assetID) - req, _ := http.NewRequestWithContext(ctx, "DELETE", url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return err + } req.Header.Set("x-api-key", apiKey) resp, err := c.httpClient.Do(req) if err != nil { return err } - resp.Body.Close() + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + io.Copy(io.Discard, resp.Body) + return &HTTPStatusError{Operation: "delete asset", StatusCode: resp.StatusCode} + } return nil } diff --git a/internal/photos/client_test.go b/internal/photos/client_test.go new file mode 100644 index 0000000..a7fa65a --- /dev/null +++ b/internal/photos/client_test.go @@ -0,0 +1,200 @@ +package photos + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetAssetsSupportsWrappedItemsResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/assets" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "a1", + "originalFileName": "a.jpg", + "fileCreatedAt": "2026-01-01T00:00:00Z", + "exifInfo": map[string]any{ + "fileSizeInByte": int64(42), + }, + }, + }, + }) + })) + defer server.Close() + + client := NewClient(server.URL) + assets, err := client.GetAssets(context.Background(), "key", 1, 50) + if err != nil { + t.Fatalf("GetAssets: %v", err) + } + if len(assets) != 1 || assets[0].FileSize != 42 { + t.Fatalf("assets = %+v", assets) + } +} + +func TestGetAllAssetsPaginatesUpstream(t *testing.T) { + call := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call++ + page := r.URL.Query().Get("page") + if page == "1" { + batch := make([]Asset, assetsPageSize) + for i := range batch { + batch[i] = Asset{ID: fmt.Sprintf("full-%d", i)} + } + _ = json.NewEncoder(w).Encode(batch) + return + } + if page == "2" { + _ = json.NewEncoder(w).Encode([]Asset{ + {ID: "tail-1"}, + {ID: "tail-2"}, + }) + return + } + _ = json.NewEncoder(w).Encode([]Asset{}) + })) + defer server.Close() + + client := NewClient(server.URL) + assets, err := client.GetAllAssets(context.Background(), "key") + if err != nil { + t.Fatalf("GetAllAssets: %v", err) + } + if len(assets) != assetsPageSize+2 { + t.Fatalf("len = %d, want %d", len(assets), assetsPageSize+2) + } + if call != 2 { + t.Fatalf("upstream calls = %d, want 2", call) + } +} + +func TestCreateAlbumMapsImmichPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/albums" { + http.NotFound(w, r) + return + } + if r.Header.Get("x-api-key") != "key" { + t.Fatalf("api key = %q", r.Header.Get("x-api-key")) + } + var payload createAlbumPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload.AlbumName != "Trip" || payload.Description != "Summer" || len(payload.AssetIDs) != 1 || payload.AssetIDs[0] != "asset-1" { + t.Fatalf("payload = %+v", payload) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Album{ID: "album-1", Name: payload.AlbumName}) + })) + defer server.Close() + + client := NewClient(server.URL) + album, err := client.CreateAlbum(context.Background(), "key", "Trip", "Summer", []string{"asset-1"}) + if err != nil { + t.Fatalf("CreateAlbum: %v", err) + } + if album.ID != "album-1" || album.Name != "Trip" { + t.Fatalf("album = %+v", album) + } +} + +func TestUploadAssetStatusMapped(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + client := NewClient(server.URL) + _, err := client.UploadAsset(context.Background(), "key", strings.NewReader("body"), "application/octet-stream") + if err == nil { + t.Fatal("expected error") + } + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusBadRequest { + t.Fatalf("err = %v", err) + } +} + +func TestGetAlbumNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + client := NewClient(server.URL) + _, err := client.GetAlbum(context.Background(), "key", "missing") + if err == nil { + t.Fatal("expected error") + } + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound { + t.Fatalf("err = %v", err) + } +} + +func TestAddAlbumAssetsSendsBulkIDs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/albums/album-1/assets" { + http.NotFound(w, r) + return + } + var payload bulkIDsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if len(payload.IDs) != 2 || payload.IDs[0] != "a1" || payload.IDs[1] != "a2" { + t.Fatalf("payload = %+v", payload) + } + _ = json.NewEncoder(w).Encode([]BulkAssetResult{{ID: "a1", Success: true}, {ID: "a2", Success: true}}) + })) + defer server.Close() + + client := NewClient(server.URL) + results, err := client.AddAlbumAssets(context.Background(), "key", "album-1", []string{"a1", "a2"}) + if err != nil { + t.Fatalf("AddAlbumAssets: %v", err) + } + if len(results) != 2 { + t.Fatalf("results = %+v", results) + } +} + +func TestRemoveAlbumAssetsUsesDeleteWithBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/albums/album-1/assets" { + http.NotFound(w, r) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if string(body) != `{"ids":["a1"]}` { + t.Fatalf("body = %s", string(body)) + } + _ = json.NewEncoder(w).Encode([]BulkAssetResult{{ID: "a1", Success: true}}) + })) + defer server.Close() + + client := NewClient(server.URL) + results, err := client.RemoveAlbumAssets(context.Background(), "key", "album-1", []string{"a1"}) + if err != nil { + t.Fatalf("RemoveAlbumAssets: %v", err) + } + if len(results) != 1 || !results[0].Success { + t.Fatalf("results = %+v", results) + } +} diff --git a/internal/photos/errors.go b/internal/photos/errors.go new file mode 100644 index 0000000..2c666eb --- /dev/null +++ b/internal/photos/errors.go @@ -0,0 +1,12 @@ +package photos + +import "fmt" + +type HTTPStatusError struct { + Operation string + StatusCode int +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("%s failed: %d", e.Operation, e.StatusCode) +} diff --git a/project-plan/checklist-execution.md b/project-plan/checklist-execution.md index e6a7b3d..969e8e9 100644 --- a/project-plan/checklist-execution.md +++ b/project-plan/checklist-execution.md @@ -166,10 +166,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon #### Photos -- [ ] Corriger mapping API Immich (champs JSON exacts, erreurs HTTP). -- [ ] Ajouter pagination/tri/filtres robustes. -- [ ] Ajouter albums CRUD complets. -- [ ] Ajouter liaison stockage/quota avec Ultidrive. +- [x] Corriger mapping API Immich (champs JSON exacts, erreurs HTTP). +- [x] Ajouter pagination/tri/filtres robustes. +- [x] Ajouter albums CRUD complets. +- [x] Ajouter liaison stockage/quota avec Ultidrive. #### Admin