package photos import ( "context" "errors" "io" "net/http" "os" "strconv" "strings" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/nextcloud" photospkg "github.com/ultisuite/ulti-backend/internal/photos" ) 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) } 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 { Assets []photospkg.Asset `json:"assets"` Page int `json:"page"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) ListAssets(ctx context.Context, apiKey string, params query.ListParams) (AssetsList, error) { assets, err := s.client.GetAllAssets(ctx, apiKey) if err != nil { return AssetsList{}, mapPhotosError(err) } page, total := applyAssetList(assets, params) return AssetsList{ Assets: page, Page: params.Page, Pagination: params.Meta(&total), }, nil } type AlbumsList struct { Albums []photospkg.Album `json:"albums"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } 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{}, mapPhotosError(err) } 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) { 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) { 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 mapPhotosError(s.client.DeleteAsset(ctx, apiKey, assetID)) } func (s *Service) ensureQuota(ctx context.Context, userID string) error { if s.quota == nil { return nil } incomingBytes := requestContentLength(ctx) if incomingBytes > 0 && s.maxUploadBytes > 0 && incomingBytes > s.maxUploadBytes { return ErrQuotaExceeded } 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 requestContentLength(ctx context.Context) int64 { v := ctx.Value(contentLengthContextKey{}) n, ok := v.(int64) if !ok { return 0 } 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 }