diff --git a/backend/cmd/backfill-exif/main.go b/backend/cmd/backfill-exif/main.go
new file mode 100644
index 0000000..a0d9309
--- /dev/null
+++ b/backend/cmd/backfill-exif/main.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "log/slog"
+ "os"
+ "strings"
+
+ "shared-album/internal/config"
+ "shared-album/internal/db"
+ "shared-album/internal/photos"
+ "shared-album/internal/storage"
+)
+
+func main() {
+ loadDotEnv(".env")
+ loadDotEnv("../.env")
+
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
+ slog.SetDefault(logger)
+
+ cfg, err := config.Load()
+ if err != nil {
+ slog.Error("config error", "error", err)
+ os.Exit(1)
+ }
+
+ ctx := context.Background()
+
+ pool, err := db.Connect(ctx, cfg.Postgres.DSN())
+ if err != nil {
+ slog.Error("database connect failed", "error", err)
+ os.Exit(1)
+ }
+ defer pool.Close()
+
+ store, err := storage.New(ctx, cfg.S3)
+ if err != nil {
+ slog.Error("storage init failed", "error", err)
+ os.Exit(1)
+ }
+
+ photoStore := photos.NewStore(pool)
+ updated, err := photoStore.BackfillEXIF(ctx, store.Get)
+ if err != nil {
+ slog.Error("backfill failed", "error", err)
+ os.Exit(1)
+ }
+ slog.Info("exif backfill complete", "updated", updated)
+}
+
+func loadDotEnv(path string) {
+ f, err := os.Open(path)
+ if err != nil {
+ return
+ }
+ defer f.Close()
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ value = strings.TrimSpace(value)
+ if _, exists := os.LookupEnv(key); !exists {
+ _ = os.Setenv(key, value)
+ }
+ }
+}
diff --git a/backend/go.mod b/backend/go.mod
index f371bbf..119006b 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -9,13 +9,13 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
github.com/aws/smithy-go v1.28.5
github.com/disintegration/imaging v1.6.2
+ github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/webp v0.6.4
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.20.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
- github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/crypto v0.58.0
)
@@ -37,14 +37,18 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
+ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-errors/errors v1.4.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -68,4 +72,5 @@ require (
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index c27a063..3e61305 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -68,6 +68,23 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
+github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
+github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
+github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No=
+github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0=
+github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc=
+github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc=
+github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
+github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
+github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
+github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
+github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
+github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -80,6 +97,11 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
+github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
+github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -100,6 +122,10 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
+github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
+github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
+github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
+github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -115,10 +141,16 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -155,8 +187,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
-github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
-github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -189,23 +221,44 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go
index 168b0f4..79022a1 100644
--- a/backend/internal/albums/service.go
+++ b/backend/internal/albums/service.go
@@ -51,7 +51,24 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
}
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
- return s.store.ListForUser(ctx, userID)
+ out, err := s.store.ListForUser(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ for i := range out {
+ s.attachCover(ctx, &out[i])
+ }
+ return out, nil
+}
+
+// attachCover fills CoverThumbURL from the presigned thumbnail key, when present.
+func (s *Service) attachCover(ctx context.Context, a *models.Album) {
+ if s.storage == nil || a == nil || a.CoverThumbKey == "" {
+ return
+ }
+ if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
+ a.CoverThumbURL = url
+ }
}
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
@@ -76,7 +93,12 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
}
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
- return s.Access(ctx, albumID, userID)
+ a, role, err := s.Access(ctx, albumID, userID)
+ if err != nil {
+ return nil, "", err
+ }
+ s.attachCover(ctx, a)
+ return a, role, nil
}
type UpdateInput struct {
@@ -112,6 +134,10 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
if err := s.store.Update(ctx, a); err != nil {
return nil, err
}
+ if refreshed, err := s.store.GetByID(ctx, a.ID); err == nil {
+ a = refreshed
+ }
+ s.attachCover(ctx, a)
s.hub.Publish(albumID, "album.updated", a)
return a, nil
}
@@ -124,11 +150,11 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
if role != roles.Owner {
return httpx.Forbidden("forbidden", "only the owner can delete the album")
}
- if err := s.store.Delete(ctx, a.ID); err != nil {
+ if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
return err
}
- if s.storage != nil {
- _ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
+ if err := s.store.Delete(ctx, a.ID); err != nil {
+ return err
}
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
return nil
diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go
index 3d947d8..b4cd255 100644
--- a/backend/internal/albums/store.go
+++ b/backend/internal/albums/store.go
@@ -43,9 +43,14 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
var a models.Album
err := s.pool.QueryRow(ctx,
- `SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
- FROM albums WHERE id = $1`, id).
- Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
+ `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
+ COALESCE(p.thumb_key, ''),
+ (SELECT count(*) FROM photos WHERE album_id = a.id),
+ (SELECT count(*) FROM album_members WHERE album_id = a.id)
+ FROM albums a
+ LEFT JOIN photos p ON p.id = a.cover_photo_id
+ WHERE a.id = $1`, id).
+ Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -57,9 +62,13 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.pool.Query(ctx,
- `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at
+ `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
+ COALESCE(p.thumb_key, ''),
+ (SELECT count(*) FROM photos WHERE album_id = a.id),
+ (SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
+ LEFT JOIN photos p ON p.id = a.cover_photo_id
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID)
if err != nil {
@@ -70,7 +79,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
out := []models.Album{}
for rows.Next() {
var a models.Album
- if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil {
+ if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
return nil, err
}
out = append(out, a)
@@ -98,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error {
}
return nil
}
+
+// TrashPhotosByAlbum soft-deletes all photos of an album so they can be restored
+// or permanently removed from the trash after the album is gone.
+func (s *Store) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
+ _, err := s.pool.Exec(ctx,
+ `UPDATE photos SET deleted_at = now() WHERE album_id = $1 AND deleted_at IS NULL`, albumID)
+ return err
+}
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index 7330727..6dfdc8b 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -115,6 +115,7 @@ func (s *Server) Router() *gin.Engine {
authed.Use(s.auth.Middleware())
authed.GET("/auth/me", authHandler.Me)
+ authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences)
authed.GET("/sse/albums/:id", s.albumSSE)
authed.GET("/sse/me", s.meSSE)
@@ -133,8 +134,20 @@ func (s *Server) Router() *gin.Engine {
authed.GET("/albums/:id/photos", photosHandler.List)
authed.POST("/albums/:id/photos", photosHandler.Upload)
authed.GET("/photos/:id", photosHandler.Get)
+ authed.PATCH("/photos/:id", photosHandler.Rename)
authed.DELETE("/photos/:id", photosHandler.Delete)
authed.GET("/photos/:id/download", photosHandler.Download)
+ authed.POST("/photos/:id/restore", photosHandler.Restore)
+ authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
+ authed.POST("/photos/:id/tags", photosHandler.AddTag)
+ authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
+ authed.GET("/tags", photosHandler.Tags)
+
+ authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
+ authed.POST("/photo-batch/move", photosHandler.MoveBatch)
+ authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
+ authed.GET("/trash", photosHandler.Trash)
+ authed.GET("/trash/count", photosHandler.TrashCount)
authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
diff --git a/backend/internal/auth/handlers.go b/backend/internal/auth/handlers.go
index 3b0700e..352ec41 100644
--- a/backend/internal/auth/handlers.go
+++ b/backend/internal/auth/handlers.go
@@ -1,6 +1,9 @@
package auth
import (
+ "encoding/json"
+ "fmt"
+ "io"
"net/http"
"github.com/gin-gonic/gin"
@@ -89,5 +92,79 @@ func (h *Handler) Me(c *gin.Context) {
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
return
}
- c.JSON(http.StatusOK, gin.H{"user": u.Public()})
+ prefs := u.Preferences
+ if prefs == nil {
+ prefs = map[string]any{}
+ }
+ c.JSON(http.StatusOK, gin.H{"user": u.Public(), "preferences": prefs})
+}
+
+const (
+ maxVisibleAttrs = 500
+ maxLabelEntries = 500
+ maxPreferenceKeyLen = 128
+ maxPreferenceValueLen = 128
+)
+
+type photoInfoPreferences struct {
+ Visible []string `json:"visible"`
+ Labels map[string]string `json:"labels,omitempty"`
+}
+
+type preferencesPayload struct {
+ PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"`
+}
+
+func (p preferencesPayload) validate() error {
+ pi := p.PhotoInfo
+ if pi == nil {
+ return nil
+ }
+ if len(pi.Visible) > maxVisibleAttrs {
+ return fmt.Errorf("too many visible attributes")
+ }
+ for _, k := range pi.Visible {
+ if k == "" || len(k) > maxPreferenceKeyLen {
+ return fmt.Errorf("invalid visible attribute key")
+ }
+ }
+ if len(pi.Labels) > maxLabelEntries {
+ return fmt.Errorf("too many label overrides")
+ }
+ for k, v := range pi.Labels {
+ if k == "" || len(k) > maxPreferenceKeyLen {
+ return fmt.Errorf("invalid label key")
+ }
+ if len(v) > maxPreferenceValueLen {
+ return fmt.Errorf("label value too long")
+ }
+ }
+ return nil
+}
+
+func (h *Handler) UpdatePreferences(c *gin.Context) {
+ body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
+ if err != nil {
+ httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not read request body"))
+ return
+ }
+ var payload preferencesPayload
+ if err := json.Unmarshal(body, &payload); err != nil {
+ httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object"))
+ return
+ }
+ if err := payload.validate(); err != nil {
+ httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error()))
+ return
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
+ return
+ }
+ if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"preferences": payload})
}
diff --git a/backend/internal/auth/store.go b/backend/internal/auth/store.go
index 5b3173d..054375c 100644
--- a/backend/internal/auth/store.go
+++ b/backend/internal/auth/store.go
@@ -2,6 +2,7 @@ package auth
import (
"context"
+ "encoding/json"
"errors"
"github.com/jackc/pgx/v5"
@@ -29,27 +30,45 @@ func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx,
- `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
+ `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
+ COALESCE(preferences, '{}'::jsonb)::text, created_at
FROM users WHERE lower(email) = lower($1)`, email))
}
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx,
- `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
+ `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
+ COALESCE(preferences, '{}'::jsonb)::text, created_at
FROM users WHERE id = $1`, id))
}
func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
var u models.User
- if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil {
+ var prefs string
+ if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
+ if prefs != "" && prefs != "null" {
+ _ = json.Unmarshal([]byte(prefs), &u.Preferences)
+ }
return &u, nil
}
+func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
+ tag, err := s.pool.Exec(ctx,
+ `UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw))
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go
index 7456174..c6835dc 100644
--- a/backend/internal/models/models.go
+++ b/backend/internal/models/models.go
@@ -3,12 +3,13 @@ package models
import "time"
type User struct {
- ID string `json:"id"`
- Email string `json:"email"`
- PasswordHash string `json:"-"`
- Name string `json:"name"`
- AvatarURL string `json:"avatar_url,omitempty"`
- CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ Email string `json:"email"`
+ PasswordHash string `json:"-"`
+ Name string `json:"name"`
+ AvatarURL string `json:"avatar_url,omitempty"`
+ Preferences map[string]any `json:"preferences,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
type PublicUser struct {
@@ -26,13 +27,17 @@ func (u *User) Public() PublicUser {
}
type Album struct {
- ID string `json:"id"`
- OwnerID string `json:"owner_id"`
- Title string `json:"title"`
- Description string `json:"description"`
- CoverPhotoID *string `json:"cover_photo_id,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID string `json:"id"`
+ OwnerID string `json:"owner_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ CoverPhotoID *string `json:"cover_photo_id,omitempty"`
+ CoverThumbKey string `json:"-"`
+ CoverThumbURL string `json:"cover_thumb_url,omitempty"`
+ PhotosCount int `json:"photos_count"`
+ MembersCount int `json:"members_count"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
type AlbumMember struct {
@@ -71,8 +76,12 @@ type Photo struct {
TakenAt *time.Time `json:"taken_at,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
- Caption string `json:"caption"`
- CreatedAt time.Time `json:"created_at"`
+ Caption string `json:"caption"`
+ EXIF map[string]any `json:"exif,omitempty"`
+ Tags []Tag `json:"tags,omitempty"`
+ DeletedAt *time.Time `json:"deleted_at,omitempty"`
+ AlbumTitle string `json:"album_title,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
Uploader PublicUser `json:"uploader"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
@@ -83,6 +92,11 @@ type Photo struct {
DownloadURL string `json:"download_url,omitempty"`
}
+type Tag struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
type Comment struct {
ID string `json:"id"`
PhotoID string `json:"photo_id"`
diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go
index 4d0190c..c207078 100644
--- a/backend/internal/photos/handlers.go
+++ b/backend/internal/photos/handlers.go
@@ -1,6 +1,7 @@
package photos
import (
+ "errors"
"io"
"net/http"
"strconv"
@@ -12,7 +13,7 @@ import (
"shared-album/internal/httpx"
)
-const maxUploadSize = 50 << 20
+const maxUploadSize = 100 << 20
type Handler struct {
svc *Service
@@ -43,7 +44,12 @@ func (h *Handler) Upload(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
form, err := c.MultipartForm()
if err != nil {
- httpx.RespondError(c, httpx.BadRequest("invalid_request", "expected multipart form"))
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", "files exceed the maximum upload size (100 MB)"))
+ return
+ }
+ httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid multipart form"))
return
}
files := form.File["files"]
@@ -93,6 +99,24 @@ func (h *Handler) Get(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photo": p})
}
+type renameRequest struct {
+ OriginalFilename string `json:"original_filename" binding:"required"`
+}
+
+func (h *Handler) Rename(c *gin.Context) {
+ var req renameRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ httpx.RespondError(c, httpx.BadRequest("invalid_request", "original_filename is required"))
+ return
+ }
+ p, err := h.svc.Rename(c.Request.Context(), c.Param("id"), auth.UserID(c), req.OriginalFilename)
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"photo": p})
+}
+
func (h *Handler) Download(c *gin.Context) {
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil {
@@ -109,3 +133,123 @@ func (h *Handler) Delete(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
+
+func (h *Handler) PermanentDelete(c *gin.Context) {
+ if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+func (h *Handler) Restore(c *gin.Context) {
+ var req struct {
+ AlbumID string `json:"album_id"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c), req.AlbumID)
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"photo": p})
+}
+
+func (h *Handler) Trash(c *gin.Context) {
+ list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c))
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"photos": list})
+}
+
+func (h *Handler) TrashCount(c *gin.Context) {
+ n, err := h.svc.TrashCount(c.Request.Context(), auth.UserID(c))
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"count": n})
+}
+
+func (h *Handler) Tags(c *gin.Context) {
+ tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"tags": tags})
+}
+
+func (h *Handler) AddTag(c *gin.Context) {
+ var req struct {
+ Name string `json:"name"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
+ httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required"))
+ return
+ }
+ p, err := h.svc.AddTag(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Name)
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"photo": p})
+}
+
+func (h *Handler) RemoveTag(c *gin.Context) {
+ p, err := h.svc.RemoveTag(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("tag_id"))
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"photo": p})
+}
+
+type batchRequest struct {
+ IDs []string `json:"ids" binding:"required"`
+ AlbumID string `json:"album_id"`
+}
+
+func (h *Handler) DeleteBatch(c *gin.Context) {
+ var req batchRequest
+ if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
+ httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required"))
+ return
+ }
+ n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c))
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": n})
+}
+
+func (h *Handler) MoveBatch(c *gin.Context) {
+ var req batchRequest
+ if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
+ httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
+ return
+ }
+ n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"moved": n})
+}
+
+func (h *Handler) CopyBatch(c *gin.Context) {
+ var req batchRequest
+ if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
+ httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
+ return
+ }
+ created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
+ if err != nil {
+ httpx.RespondError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"photos": created})
+}
diff --git a/backend/internal/photos/pipeline.go b/backend/internal/photos/pipeline.go
index 789c1a2..2ed36f6 100644
--- a/backend/internal/photos/pipeline.go
+++ b/backend/internal/photos/pipeline.go
@@ -2,12 +2,16 @@ package photos
import (
"bytes"
+ "fmt"
"image"
+ "strconv"
+ "strings"
"time"
+ exif "github.com/dsoprea/go-exif/v3"
+ exifcommon "github.com/dsoprea/go-exif/v3/common"
"github.com/disintegration/imaging"
"github.com/gen2brain/webp"
- "github.com/rwcarlsen/goexif/exif"
)
type ImageMeta struct {
@@ -29,23 +33,90 @@ func inspect(data []byte) (width, height int, ok bool) {
return cfg.Width, cfg.Height, true
}
-func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
- x, err := exif.Decode(bytes.NewReader(data))
- if err != nil {
- return nil, nil, nil
+type exifInfo struct {
+ TakenAt *time.Time
+ Latitude *float64
+ Longitude *float64
+ Attributes map[string]any
+}
+
+func collectTags(ifd *exif.Ifd, dst map[string]any) {
+ if ifd == nil {
+ return
}
- if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
- if s, err := dt.StringVal(); err == nil {
- if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
- utc := t.UTC()
- taken = &utc
- }
+ for _, ite := range ifd.Entries() {
+ name := ite.TagName()
+ if name == "" || strings.HasPrefix(name, "UnknownTag") {
+ continue
+ }
+ if _, exists := dst[name]; exists {
+ continue
+ }
+ s, err := ite.FormatFirst()
+ if err != nil {
+ continue
+ }
+ s = strings.TrimSpace(strings.TrimRight(s, "\x00"))
+ if s == "" {
+ continue
+ }
+ dst[name] = normalizeExifValue(s)
+ }
+ for _, child := range ifd.Children() {
+ collectTags(child, dst)
+ }
+}
+
+func normalizeExifValue(s string) any {
+ base := strings.TrimSuffix(s, "...")
+ if i := strings.IndexByte(base, '/'); i > 0 {
+ num, err1 := strconv.ParseInt(strings.TrimSpace(base[:i]), 10, 64)
+ den, err2 := strconv.ParseInt(strings.TrimSpace(base[i+1:]), 10, 64)
+ if err1 == nil && err2 == nil && den != 0 {
+ return float64(num) / float64(den)
}
}
- if la, lo, err := x.LatLong(); err == nil {
- lat, lon = &la, &lo
+ if n, err := strconv.ParseInt(base, 10, 64); err == nil {
+ return n
}
- return taken, lat, lon
+ if f, err := strconv.ParseFloat(base, 64); err == nil {
+ return f
+ }
+ return s
+}
+
+func extractEXIF(data []byte) exifInfo {
+ out := exifInfo{Attributes: map[string]any{}}
+ raw, err := exif.SearchAndExtractExif(data)
+ if err != nil {
+ return out
+ }
+ im, err := exifcommon.NewIfdMappingWithStandard()
+ if err != nil {
+ return out
+ }
+ ti := exif.NewTagIndex()
+ _, index, err := exif.Collect(im, ti, raw)
+ if err != nil || index.RootIfd == nil {
+ return out
+ }
+ collectTags(index.RootIfd, out.Attributes)
+ if v, ok := out.Attributes["DateTimeOriginal"]; ok {
+ if t, err := time.Parse("2006:01:02 15:04:05", fmt.Sprintf("%v", v)); err == nil {
+ utc := t.UTC()
+ out.TakenAt = &utc
+ }
+ }
+ if gi, err := index.RootIfd.GpsInfo(); err == nil {
+ lat := gi.Latitude.Decimal()
+ lon := gi.Longitude.Decimal()
+ out.Latitude, out.Longitude = &lat, &lon
+ }
+ return out
+}
+
+func ExtractEXIFAttributes(data []byte) map[string]any {
+ return extractEXIF(data).Attributes
}
func buildVariants(data []byte) (thumb, preview []byte, err error) {
diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go
index 7696dce..c58bfc4 100644
--- a/backend/internal/photos/service.go
+++ b/backend/internal/photos/service.go
@@ -62,7 +62,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
if !ok {
return nil, httpx.BadRequest("invalid_image", "could not decode image")
}
- taken, lat, lon := extractEXIF(in.Data)
+ meta := extractEXIF(in.Data)
id := auth.NewID()
ext := strings.ToLower(filepath.Ext(in.Filename))
@@ -85,9 +85,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
Width: width,
Height: height,
MimeType: mime,
- TakenAt: taken,
- Latitude: lat,
- Longitude: lon,
+ TakenAt: meta.TakenAt,
+ Latitude: meta.Latitude,
+ Longitude: meta.Longitude,
+ EXIF: meta.Attributes,
}
if err := s.store.Create(ctx, p); err != nil {
return nil, err
@@ -121,7 +122,7 @@ func (s *Service) processVariants(photoID, albumID string, data []byte) {
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
return
}
- s.store.SetCover(ctx, albumID, photoID)
+ s.store.SetCoverIfEmpty(ctx, albumID, photoID)
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(albumID, "photo.created", p)
@@ -206,6 +207,9 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
}
return nil, err
}
+ if p.DeletedAt != nil {
+ return nil, httpx.NotFound("photo_not_found", "photo not found")
+ }
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
@@ -229,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot
}
return nil, err
}
+ if p.DeletedAt != nil {
+ return nil, httpx.NotFound("photo_not_found", "photo not found")
+ }
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
@@ -250,6 +257,40 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string,
return url, nil
}
+func (s *Service) Rename(ctx context.Context, photoID, userID, filename string) (*models.Photo, error) {
+ p, err := s.store.GetByID(ctx, photoID, userID)
+ if err != nil {
+ if errors.Is(err, ErrNotFound) {
+ return nil, httpx.NotFound("photo_not_found", "photo not found")
+ }
+ return nil, err
+ }
+ _, role, err := s.albums.Access(ctx, p.AlbumID, userID)
+ if err != nil {
+ return nil, err
+ }
+ own := p.UploaderID == userID
+ if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
+ return nil, httpx.Forbidden("forbidden", "you cannot rename this photo")
+ }
+ name := strings.TrimSpace(filename)
+ if name == "" {
+ return nil, httpx.BadRequest("invalid_filename", "filename is required")
+ }
+ if len(name) > 255 {
+ name = name[:255]
+ }
+ if err := s.store.UpdateFilename(ctx, photoID, name); err != nil {
+ return nil, err
+ }
+ p.OriginalFilename = name
+ if err := s.attachURLs(ctx, p); err != nil {
+ return nil, err
+ }
+ s.hub.Publish(p.AlbumID, "photo.updated", p)
+ return p, nil
+}
+
func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
@@ -262,23 +303,286 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
if err != nil {
return err
}
- own := p.UploaderID == userID
- if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
+ if !canDeletePhoto(role, p.UploaderID == userID) {
return httpx.Forbidden("forbidden", "you cannot delete this photo")
}
- if err := s.store.Delete(ctx, photoID); err != nil {
+ if p.DeletedAt != nil {
+ return nil
+ }
+ if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
return err
}
- keys := []string{p.StorageKey}
- if p.ThumbKey != "" {
- keys = append(keys, p.ThumbKey)
- }
- if p.PreviewKey != "" {
- keys = append(keys, p.PreviewKey)
- }
- _ = s.storage.Delete(ctx, keys...)
+ s.publishDeleted(p)
+ return nil
+}
+
+func canDeletePhoto(role string, own bool) bool {
+ return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))
+}
+
+func (s *Service) publishDeleted(p *models.Photo) {
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
})
+}
+
+// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete.
+func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) {
+ deleted := 0
+ for _, id := range ids {
+ if err := s.Delete(ctx, id, userID); err != nil {
+ continue
+ }
+ deleted++
+ }
+ return deleted, nil
+}
+
+// Restore returns a photo from the trash back to its album.
+// Restore returns a photo from the trash back to an album. When targetAlbumID is
+// empty the photo goes back to its own album; if that album is gone, an
+// album_required error is returned so the client can prompt for a target.
+func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID string) (*models.Photo, error) {
+ p, err := s.store.GetByID(ctx, photoID, userID)
+ if err != nil {
+ if errors.Is(err, ErrNotFound) {
+ return nil, httpx.NotFound("photo_not_found", "photo not found")
+ }
+ return nil, err
+ }
+
+ if targetAlbumID != "" {
+ _, role, err := s.albums.Access(ctx, targetAlbumID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if !roles.CanUpload(role) {
+ return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
+ }
+ if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
+ return nil, err
+ }
+ p.AlbumID = targetAlbumID
+ p.DeletedAt = nil
+ } else {
+ if p.AlbumID == "" {
+ return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
+ }
+ exists, err := s.store.AlbumExists(ctx, p.AlbumID)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
+ }
+ _, role, err := s.albums.Access(ctx, p.AlbumID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if !canDeletePhoto(role, p.UploaderID == userID) {
+ return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
+ }
+ if p.DeletedAt != nil {
+ if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
+ return nil, err
+ }
+ }
+ p.DeletedAt = nil
+ }
+
+ if err := s.attachURLs(ctx, p); err != nil {
+ return nil, err
+ }
+ if p.AlbumID != "" {
+ s.hub.Publish(p.AlbumID, "photo.created", p)
+ }
+ return p, nil
+}
+
+// PermanentDelete removes the photo row; stored objects are deleted only when no
+// other album references the same storage key.
+func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error {
+ p, err := s.store.GetByID(ctx, photoID, userID)
+ if err != nil {
+ if errors.Is(err, ErrNotFound) {
+ return httpx.NotFound("photo_not_found", "photo not found")
+ }
+ return err
+ }
+ _, role, err := s.albums.Access(ctx, p.AlbumID, userID)
+ if err != nil {
+ return err
+ }
+ if !canDeletePhoto(role, p.UploaderID == userID) {
+ return httpx.Forbidden("forbidden", "you cannot delete this photo")
+ }
+ if err := s.store.HardDelete(ctx, photoID); err != nil {
+ return err
+ }
+ if p.AlbumID != "" {
+ s.publishDeleted(p)
+ }
+ if n, err := s.store.StorageKeyRefCount(ctx, p.StorageKey); err == nil && n == 0 {
+ keys := []string{p.StorageKey}
+ if p.ThumbKey != "" {
+ keys = append(keys, p.ThumbKey)
+ }
+ if p.PreviewKey != "" {
+ keys = append(keys, p.PreviewKey)
+ }
+ _ = s.storage.Delete(ctx, keys...)
+ }
return nil
}
+
+// MoveBatch moves photos to another album (metadata only; stored objects untouched).
+func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) {
+ _, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
+ if err != nil {
+ return 0, err
+ }
+ if !roles.CanUpload(targetRole) {
+ return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
+ }
+ moved := 0
+ for _, id := range ids {
+ p, err := s.store.GetByID(ctx, id, userID)
+ if err != nil || p.DeletedAt != nil {
+ continue
+ }
+ oldAlbum := p.AlbumID
+ if oldAlbum == targetAlbumID {
+ continue
+ }
+ _, srcRole, err := s.albums.Access(ctx, oldAlbum, userID)
+ if err != nil {
+ continue
+ }
+ if !canDeletePhoto(srcRole, p.UploaderID == userID) {
+ continue
+ }
+ if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
+ continue
+ }
+ p.AlbumID = targetAlbumID
+ s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
+ "id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID,
+ })
+ if err := s.attachURLs(ctx, p); err == nil {
+ s.hub.Publish(targetAlbumID, "photo.created", p)
+ }
+ moved++
+ }
+ return moved, nil
+}
+
+// CopyBatch clones photos into another album (metadata only; stored objects shared).
+func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) {
+ _, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if !roles.CanUpload(targetRole) {
+ return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
+ }
+ out := []models.Photo{}
+ for _, id := range ids {
+ src, err := s.store.GetByID(ctx, id, userID)
+ if err != nil || src.DeletedAt != nil {
+ continue
+ }
+ if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil {
+ continue
+ }
+ newID := auth.NewID()
+ if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil {
+ continue
+ }
+ clone, err := s.store.GetByID(ctx, newID, userID)
+ if err != nil {
+ continue
+ }
+ if err := s.attachURLs(ctx, clone); err != nil {
+ continue
+ }
+ s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID)
+ s.hub.Publish(targetAlbumID, "photo.created", clone)
+ out = append(out, *clone)
+ }
+ return out, nil
+}
+
+// Trash lists soft-deleted photos from all albums the user belongs to.
+func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) {
+ out, err := s.store.ListTrash(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ for i := range out {
+ _ = s.attachURLs(ctx, &out[i])
+ }
+ return out, nil
+}
+
+// TrashCount returns the number of photos in the user's trash.
+func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
+ return s.store.TrashCount(ctx, userID)
+}
+
+// Tags lists the user's tags, optionally filtered by a search query.
+func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
+ return s.store.ListTags(ctx, userID, q)
+}
+
+func (s *Service) taggablePhoto(ctx context.Context, photoID, userID string) (*models.Photo, error) {
+ p, err := s.store.GetByID(ctx, photoID, userID)
+ if err != nil {
+ if errors.Is(err, ErrNotFound) {
+ return nil, httpx.NotFound("photo_not_found", "photo not found")
+ }
+ return nil, err
+ }
+ if p.DeletedAt != nil {
+ return nil, httpx.NotFound("photo_not_found", "photo not found")
+ }
+ if p.AlbumID == "" {
+ return nil, httpx.NotFound("photo_not_found", "photo not found")
+ }
+ if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
+ return nil, err
+ }
+ return p, nil
+}
+
+// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
+func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
+ if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
+ return nil, err
+ }
+ tag, err := s.store.EnsureTag(ctx, userID, name)
+ if err != nil {
+ return nil, httpx.BadRequest("invalid_tag", "tag name is required")
+ }
+ if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
+ return nil, err
+ }
+ return s.Get(ctx, photoID, userID)
+}
+
+// RemoveTag detaches one of the user's tags from a photo.
+func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
+ if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
+ return nil, err
+ }
+ owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if !owned {
+ return nil, httpx.NotFound("tag_not_found", "tag not found")
+ }
+ if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
+ return nil, err
+ }
+ return s.Get(ctx, photoID, userID)
+}
diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go
index 8937da9..446c4a6 100644
--- a/backend/internal/photos/store.go
+++ b/backend/internal/photos/store.go
@@ -2,12 +2,16 @@ package photos
import (
"context"
+ "encoding/json"
"errors"
+ "strings"
"time"
"github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
+ "shared-album/internal/auth"
"shared-album/internal/models"
)
@@ -19,40 +23,71 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
-const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
+const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
p.original_filename, p.size, p.width, p.height, p.mime_type,
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
u.id, u.name, COALESCE(u.avatar_url, ''),
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
- EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2)`
+ EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
+ COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at,
+ COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
+ FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
+ WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
+ var exifText, tagsText string
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
- &p.LikesCount, &p.CommentsCount, &p.LikedByMe)
+ &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
+ setExif(&p, exifText)
+ setTags(&p, tagsText)
return &p, nil
}
+func setTags(p *models.Photo, raw string) {
+ if raw == "" || raw == "null" || raw == "[]" {
+ return
+ }
+ var tags []models.Tag
+ if err := json.Unmarshal([]byte(raw), &tags); err == nil && len(tags) > 0 {
+ p.Tags = tags
+ }
+}
+
+func setExif(p *models.Photo, raw string) {
+ if raw == "" || raw == "null" || raw == "{}" {
+ return
+ }
+ var m map[string]any
+ if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 {
+ p.EXIF = m
+ }
+}
+
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
+ exifJSON, err := json.Marshal(p.EXIF)
+ if err != nil || len(exifJSON) == 0 {
+ exifJSON = []byte("{}")
+ }
return s.pool.QueryRow(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
- size, width, height, mime_type, taken_at, latitude, longitude, caption)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
+ size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
RETURNING created_at`,
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
- p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption,
+ p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
).Scan(&p.CreatedAt)
}
@@ -85,6 +120,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.album_id = $1
+ AND p.deleted_at IS NULL
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
AND ($4::timestamptz IS NULL OR p.created_at > $4)
ORDER BY p.created_at DESC, p.id DESC
@@ -98,20 +134,78 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
out := []models.Photo{}
for rows.Next() {
var p models.Photo
+ var exifText, tagsText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
- &p.LikesCount, &p.CommentsCount, &p.LikedByMe); err != nil {
+ &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
return nil, err
}
+ setExif(&p, exifText)
+ setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
}
-func (s *Store) Delete(ctx context.Context, id string) error {
+func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error {
+ var tag pgconn.CommandTag
+ var err error
+ if deleted {
+ tag, err = s.pool.Exec(ctx,
+ `UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
+ } else {
+ tag, err = s.pool.Exec(ctx,
+ `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
+ }
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
+ tag, err := s.pool.Exec(ctx,
+ `UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// Clone creates a new photo row that references the same stored objects as src.
+func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
+ exifJSON, err := json.Marshal(src.EXIF)
+ if err != nil || len(exifJSON) == 0 {
+ exifJSON = []byte("{}")
+ }
+ _, err = s.pool.Exec(ctx,
+ `INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
+ original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`,
+ id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
+ src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
+ src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
+ return err
+}
+
+// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
+func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
+ var n int
+ err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
+ return n, err
+}
+
+// HardDelete permanently removes the photo row.
+func (s *Store) HardDelete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
return err
@@ -122,6 +216,228 @@ func (s *Store) Delete(ctx context.Context, id string) error {
return nil
}
-func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
- _, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
+// ListTrash returns soft-deleted photos from albums the user belongs to, plus
+// their own photos whose album no longer exists.
+func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
+ rows, err := s.pool.Query(ctx,
+ `SELECT `+photoColumns+`, COALESCE(a.title, '')
+ FROM photos p
+ JOIN users u ON u.id = p.uploader_id
+ LEFT JOIN albums a ON a.id = p.album_id
+ WHERE p.deleted_at IS NOT NULL
+ AND (
+ (a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
+ SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
+ OR (p.album_id IS NULL AND p.uploader_id = $1)
+ )
+ ORDER BY p.deleted_at DESC
+ LIMIT 200`,
+ userID, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []models.Photo{}
+ for rows.Next() {
+ var p models.Photo
+ var exifText, tagsText string
+ if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
+ &p.ThumbKey, &p.PreviewKey,
+ &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
+ &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
+ &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
+ &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
+ return nil, err
+ }
+ setExif(&p, exifText)
+ setTags(&p, tagsText)
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// TrashCount counts soft-deleted photos accessible to the user.
+func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
+ var n int
+ err := s.pool.QueryRow(ctx,
+ `SELECT count(*) FROM photos p
+ LEFT JOIN albums a ON a.id = p.album_id
+ WHERE p.deleted_at IS NOT NULL
+ AND (
+ (a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
+ SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
+ OR (p.album_id IS NULL AND p.uploader_id = $1)
+ )`, userID).Scan(&n)
+ return n, err
+}
+
+// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
+func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
+ tag, err := s.pool.Exec(ctx,
+ `UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// AlbumExists reports whether an album row exists.
+func (s *Store) AlbumExists(ctx context.Context, albumID string) (bool, error) {
+ var ok bool
+ err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
+ return ok, err
+}
+
+func (s *Store) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
+ rows, err := s.pool.Query(ctx,
+ `SELECT id, name FROM tags
+ WHERE user_id = $1 AND ($2 = '' OR name ILIKE '%' || $2 || '%')
+ ORDER BY lower(name) LIMIT 50`, userID, q)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []models.Tag{}
+ for rows.Next() {
+ var t models.Tag
+ if err := rows.Scan(&t.ID, &t.Name); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// EnsureTag returns the user's tag with the given name, creating it if needed.
+func (s *Store) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
+ name = strings.TrimSpace(name)
+ if name == "" || len(name) > 64 {
+ return nil, ErrNotFound
+ }
+ var t models.Tag
+ err := s.pool.QueryRow(ctx,
+ `SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
+ Scan(&t.ID, &t.Name)
+ if err == nil {
+ return &t, nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return nil, err
+ }
+ id := auth.NewID()
+ err = s.pool.QueryRow(ctx,
+ `INSERT INTO tags (id, user_id, name) VALUES ($1,$2,$3) RETURNING id, name`, id, userID, name).
+ Scan(&t.ID, &t.Name)
+ if err != nil {
+ if e2 := s.pool.QueryRow(ctx,
+ `SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
+ Scan(&t.ID, &t.Name); e2 == nil {
+ return &t, nil
+ }
+ return nil, err
+ }
+ return &t, nil
+}
+
+func (s *Store) AttachTag(ctx context.Context, photoID, tagID string) error {
+ _, err := s.pool.Exec(ctx,
+ `INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, photoID, tagID)
+ return err
+}
+
+func (s *Store) DetachTag(ctx context.Context, photoID, tagID string) error {
+ _, err := s.pool.Exec(ctx,
+ `DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
+ return err
+}
+
+func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
+ var ok bool
+ err := s.pool.QueryRow(ctx,
+ `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
+ return ok, err
+}
+
+func nullIfEmpty(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
+ _, _ = s.pool.Exec(ctx,
+ `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
+ albumID, photoID)
+}
+
+func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
+ tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+type backfillRow struct {
+ ID string
+ StorageKey string
+}
+
+func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
+ rows, err := s.pool.Query(ctx,
+ `SELECT id, storage_key FROM photos
+ WHERE exif IS NULL OR exif = '{}'::jsonb
+ ORDER BY created_at`)
+ if err != nil {
+ return 0, err
+ }
+ var pending []backfillRow
+ for rows.Next() {
+ var r backfillRow
+ if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ pending = append(pending, r)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+
+ updated := 0
+ for _, r := range pending {
+ data, err := fetch(ctx, r.StorageKey)
+ if err != nil {
+ continue
+ }
+ meta := extractEXIF(data)
+ if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
+ continue
+ }
+ attrs, err := json.Marshal(meta.Attributes)
+ if err != nil || len(attrs) == 0 {
+ attrs = []byte("{}")
+ }
+ _, err = s.pool.Exec(ctx,
+ `UPDATE photos SET exif = $2::jsonb,
+ taken_at = COALESCE(taken_at, $3),
+ latitude = COALESCE(latitude, $4),
+ longitude = COALESCE(longitude, $5)
+ WHERE id = $1`,
+ r.ID, string(attrs), meta.TakenAt, meta.Latitude, meta.Longitude)
+ if err != nil {
+ continue
+ }
+ updated++
+ }
+ return updated, nil
}
diff --git a/backend/internal/repo/repo_test.go b/backend/internal/repo/repo_test.go
index 14d23df..9449131 100644
--- a/backend/internal/repo/repo_test.go
+++ b/backend/internal/repo/repo_test.go
@@ -12,6 +12,7 @@ import (
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/models"
+ "shared-album/internal/photos"
"shared-album/internal/sharelinks"
)
@@ -123,6 +124,71 @@ func TestAlbumRepository(t *testing.T) {
}
}
+func TestAlbumCoverRepository(t *testing.T) {
+ pool := testPool(t)
+ defer pool.Close()
+ ctx := context.Background()
+ store := albums.NewStore(pool)
+
+ owner := newUser(t, ctx, pool, "CoverOwner")
+ t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
+
+ a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Cover"}
+ if err := store.Create(ctx, a); err != nil {
+ t.Fatalf("create album: %v", err)
+ }
+
+ insertPhoto := func(id, thumbKey string) {
+ t.Helper()
+ if _, err := pool.Exec(ctx,
+ `INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key)
+ VALUES ($1, $2, $3, $4, $5)`,
+ id, a.ID, owner.ID, "albums/"+a.ID+"/original/"+id+".jpg", thumbKey); err != nil {
+ t.Fatalf("insert photo: %v", err)
+ }
+ }
+
+ photo1 := auth.NewID()
+ insertPhoto(photo1, "albums/"+a.ID+"/thumb/"+photo1+".webp")
+
+ got, err := store.GetByID(ctx, a.ID)
+ if err != nil || got.CoverThumbKey != "" {
+ t.Fatalf("expected empty cover key before set: %v %q", err, got.CoverThumbKey)
+ }
+
+ ps := photos.NewStore(pool)
+ ps.SetCoverIfEmpty(ctx, a.ID, photo1)
+ got, _ = store.GetByID(ctx, a.ID)
+ if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
+ t.Fatalf("cover not set: %+v", got.CoverPhotoID)
+ }
+ if want := "albums/" + a.ID + "/thumb/" + photo1 + ".webp"; got.CoverThumbKey != want {
+ t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, want)
+ }
+
+ photo2 := auth.NewID()
+ insertPhoto(photo2, "thumb-2")
+ ps.SetCoverIfEmpty(ctx, a.ID, photo2)
+ got, _ = store.GetByID(ctx, a.ID)
+ if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
+ t.Fatalf("existing cover was overridden: %+v", got.CoverPhotoID)
+ }
+
+ if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo1); err != nil {
+ t.Fatalf("delete cover photo: %v", err)
+ }
+ got, _ = store.GetByID(ctx, a.ID)
+ if got.CoverPhotoID != nil {
+ t.Fatalf("cover not cleared after delete: %+v", got.CoverPhotoID)
+ }
+
+ ps.SetCoverIfEmpty(ctx, a.ID, photo2)
+ list, err := store.ListForUser(ctx, owner.ID)
+ if err != nil || len(list) != 1 || list[0].CoverThumbKey != "thumb-2" {
+ t.Fatalf("list cover key: %v %+v", err, list)
+ }
+}
+
func TestMemberRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go
index 6f700f5..19b5b93 100644
--- a/backend/internal/storage/storage.go
+++ b/backend/internal/storage/storage.go
@@ -122,8 +122,23 @@ func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
return c.Delete(ctx, keys...)
}
-func (c *Client) PresignGet(ctx context.Context, key string) (string, error) {
- out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
+func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
+ out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String(c.bucket),
+ Key: aws.String(key),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("get object %s: %w", key, err)
+ }
+ defer out.Body.Close()
+ data, err := io.ReadAll(out.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read object %s: %w", key, err)
+ }
+ return data, nil
+}
+
+func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(c.presignTTL))
diff --git a/backend/migrations/000002_album_cover_fk.down.sql b/backend/migrations/000002_album_cover_fk.down.sql
new file mode 100644
index 0000000..c3c858f
--- /dev/null
+++ b/backend/migrations/000002_album_cover_fk.down.sql
@@ -0,0 +1 @@
+ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk;
diff --git a/backend/migrations/000002_album_cover_fk.up.sql b/backend/migrations/000002_album_cover_fk.up.sql
new file mode 100644
index 0000000..130250f
--- /dev/null
+++ b/backend/migrations/000002_album_cover_fk.up.sql
@@ -0,0 +1,9 @@
+-- Clear any dangling cover references before adding the constraint.
+UPDATE albums
+SET cover_photo_id = NULL
+WHERE cover_photo_id IS NOT NULL
+ AND cover_photo_id NOT IN (SELECT id FROM photos);
+
+ALTER TABLE albums
+ ADD CONSTRAINT albums_cover_photo_fk
+ FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL;
diff --git a/backend/migrations/000003_photo_exif.down.sql b/backend/migrations/000003_photo_exif.down.sql
new file mode 100644
index 0000000..73fb840
--- /dev/null
+++ b/backend/migrations/000003_photo_exif.down.sql
@@ -0,0 +1 @@
+ALTER TABLE photos DROP COLUMN IF EXISTS exif;
diff --git a/backend/migrations/000003_photo_exif.up.sql b/backend/migrations/000003_photo_exif.up.sql
new file mode 100644
index 0000000..4b9ba65
--- /dev/null
+++ b/backend/migrations/000003_photo_exif.up.sql
@@ -0,0 +1 @@
+ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT NULL DEFAULT '{}'::jsonb;
diff --git a/backend/migrations/000004_user_preferences.down.sql b/backend/migrations/000004_user_preferences.down.sql
new file mode 100644
index 0000000..109d772
--- /dev/null
+++ b/backend/migrations/000004_user_preferences.down.sql
@@ -0,0 +1 @@
+ALTER TABLE users DROP COLUMN IF EXISTS preferences;
diff --git a/backend/migrations/000004_user_preferences.up.sql b/backend/migrations/000004_user_preferences.up.sql
new file mode 100644
index 0000000..d62d6ab
--- /dev/null
+++ b/backend/migrations/000004_user_preferences.up.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb;
diff --git a/backend/migrations/000005_photo_trash.down.sql b/backend/migrations/000005_photo_trash.down.sql
new file mode 100644
index 0000000..efa7285
--- /dev/null
+++ b/backend/migrations/000005_photo_trash.down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS photos_deleted_idx;
+ALTER TABLE photos DROP COLUMN IF EXISTS deleted_at;
diff --git a/backend/migrations/000005_photo_trash.up.sql b/backend/migrations/000005_photo_trash.up.sql
new file mode 100644
index 0000000..0e2a655
--- /dev/null
+++ b/backend/migrations/000005_photo_trash.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE photos ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
+CREATE INDEX IF NOT EXISTS photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL;
diff --git a/backend/migrations/000006_photo_album_nullable.down.sql b/backend/migrations/000006_photo_album_nullable.down.sql
new file mode 100644
index 0000000..9fdcff6
--- /dev/null
+++ b/backend/migrations/000006_photo_album_nullable.down.sql
@@ -0,0 +1,5 @@
+ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
+DELETE FROM photos WHERE album_id IS NULL;
+ALTER TABLE photos ALTER COLUMN album_id SET NOT NULL;
+ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
+ FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE;
diff --git a/backend/migrations/000006_photo_album_nullable.up.sql b/backend/migrations/000006_photo_album_nullable.up.sql
new file mode 100644
index 0000000..371002b
--- /dev/null
+++ b/backend/migrations/000006_photo_album_nullable.up.sql
@@ -0,0 +1,4 @@
+ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
+ALTER TABLE photos ALTER COLUMN album_id DROP NOT NULL;
+ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
+ FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL;
diff --git a/backend/migrations/000007_photo_tags.down.sql b/backend/migrations/000007_photo_tags.down.sql
new file mode 100644
index 0000000..e86d7d8
--- /dev/null
+++ b/backend/migrations/000007_photo_tags.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS photo_tags;
+DROP TABLE IF EXISTS tags;
diff --git a/backend/migrations/000007_photo_tags.up.sql b/backend/migrations/000007_photo_tags.up.sql
new file mode 100644
index 0000000..0b0737f
--- /dev/null
+++ b/backend/migrations/000007_photo_tags.up.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS tags (
+ id text PRIMARY KEY,
+ user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS tags_user_name_idx ON tags (user_id, lower(name));
+
+CREATE TABLE IF NOT EXISTS photo_tags (
+ photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
+ tag_id text NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (photo_id, tag_id)
+);
+
+CREATE INDEX IF NOT EXISTS photo_tags_tag_idx ON photo_tags (tag_id);
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 024fe6c..93e09fa 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -15,39 +15,45 @@
--primary-fg: 0 0% 100%;
--danger: 0 84% 60%;
--success: 142 71% 45%;
+ --chrome: 0 0% 98%;
+ --canvas: 0 0% 100%;
--shadow: 0 0% 0% / 0.08;
}
-/* Тёмная */
+/* Тёмная (графит Lightroom) */
[data-theme='dark'] {
- --bg: 0 0% 7%;
- --bg-elevated: 0 0% 11%;
- --bg-muted: 0 0% 16%;
+ --bg: 0 0% 9%;
+ --bg-elevated: 0 0% 15%;
+ --bg-muted: 0 0% 18%;
--border: 0 0% 22%;
- --text: 0 0% 96%;
+ --text: 0 0% 92%;
--text-muted: 0 0% 65%;
--text-subtle: 0 0% 45%;
--primary: 217 91% 60%;
--primary-fg: 0 0% 100%;
--danger: 0 84% 65%;
--success: 142 65% 50%;
+ --chrome: 0 0% 13%;
+ --canvas: 0 0% 9%;
--shadow: 0 0% 0% / 0.4;
}
-/* Нейтральная (тёплая серо-бежевая) */
+/* Нейтральная (тёплый графит) */
[data-theme='neutral'] {
- --bg: 40 20% 96%;
- --bg-elevated: 40 22% 92%;
- --bg-muted: 40 18% 87%;
- --border: 40 14% 78%;
- --text: 30 10% 15%;
- --text-muted: 30 8% 40%;
- --text-subtle: 30 6% 55%;
- --primary: 25 90% 50%;
+ --bg: 30 12% 18%;
+ --bg-elevated: 30 12% 23%;
+ --bg-muted: 30 11% 28%;
+ --border: 30 9% 34%;
+ --text: 35 18% 93%;
+ --text-muted: 32 10% 68%;
+ --text-subtle: 30 7% 50%;
+ --primary: 25 90% 55%;
--primary-fg: 0 0% 100%;
- --danger: 0 72% 55%;
- --success: 142 55% 42%;
- --shadow: 30 20% 20% / 0.12;
+ --danger: 0 72% 62%;
+ --success: 142 45% 52%;
+ --chrome: 30 12% 15%;
+ --canvas: 30 12% 18%;
+ --shadow: 30 30% 5% / 0.45;
}
html,
@@ -66,3 +72,34 @@ body {
transition: none;
}
}
+
+/* Скроллбары в тон темы */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--border)) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: hsl(var(--border));
+ border: 2px solid transparent;
+ border-radius: 9999px;
+ background-clip: padding-box;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: hsl(var(--text-subtle));
+ background-clip: padding-box;
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
diff --git a/frontend/src/app.html b/frontend/src/app.html
index d32d6e1..e616e5c 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -1,5 +1,5 @@
-
+
@@ -7,7 +7,7 @@
+
+{#if albumActions.renaming}
+ {
+ if (e.target === e.currentTarget && !busy) albumActions.renaming = null;
+ }}
+ onkeydown={(e) => {
+ if (e.key === 'Escape' && !busy) albumActions.renaming = null;
+ }}
+ >
+
+
+{/if}
+
+{#if albumActions.deleting}
+ (albumActions.deleting = null)}
+ />
+{/if}
diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte
new file mode 100644
index 0000000..71e2e16
--- /dev/null
+++ b/frontend/src/lib/components/AlbumPanel.svelte
@@ -0,0 +1,231 @@
+
+
+{#snippet selectionActions()}
+
+
Действия
+
+
+{/snippet}
+
+{#if panelPhoto}
+
+{:else if !photoId && selectedItems.length > 1}
+ {@render selectionActions()}
+{:else if album && !photoId}
+
+
+
+ {#if album.cover_thumb_url}
+

+ {:else}
+
+
+
+ {/if}
+
+ {#if canEdit}
+
+
+ {#if album.cover_photo_id}
+
+ {/if}
+
+ {/if}
+
+
+
+ {album.title}
+ {#if album.description}{album.description}
{/if}
+
+ {album.photos_count ?? 0} фото · {album.members_count ?? 0} участников
+
+ {#if error}{error}
{/if}
+
+
+
+
+
+
+
+ {#if picking}
+ (picking = false)}
+ onSelected={(updated) => (album = updated)}
+ />
+ {/if}
+{/if}
+
+{#if pendingDeleteSelected}
+ (pendingDeleteSelected = false)}
+ />
+{/if}
diff --git a/frontend/src/lib/components/AlbumRail.svelte b/frontend/src/lib/components/AlbumRail.svelte
new file mode 100644
index 0000000..3e0dcd1
--- /dev/null
+++ b/frontend/src/lib/components/AlbumRail.svelte
@@ -0,0 +1,151 @@
+
+
+
+
+ Альбомы
+ {#if auth.isAuthenticated}
+
+ {/if}
+
+
+ {#if !auth.isAuthenticated}
+
Войдите, чтобы видеть альбомы.
+ {:else if albums.loading && albums.list.length === 0}
+
Загрузка…
+ {:else if albums.list.length === 0}
+
Альбомов пока нет.
+ {:else}
+
+ {/if}
+
+ {#if auth.isAuthenticated}
+
+ {/if}
+
+ {#if creating}
+
(creating = false)}
+ onCreated={(album) => goto(`/albums/${album.id}`)}
+ />
+ {/if}
+
diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte
new file mode 100644
index 0000000..1938748
--- /dev/null
+++ b/frontend/src/lib/components/AppShell.svelte
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+ {#if railOpen}
+
+ {/if}
+
+
+
+
+
+ {@render children()}
+
+
+ {#if panel && panelVisible}
+ {#if panelOpen}
+
+ {/if}
+
+
+ {/if}
+
+
diff --git a/frontend/src/lib/components/Comments.svelte b/frontend/src/lib/components/Comments.svelte
index 93a0acd..41a51bc 100644
--- a/frontend/src/lib/components/Comments.svelte
+++ b/frontend/src/lib/components/Comments.svelte
@@ -75,18 +75,20 @@
{/each}
{#if auth.isAuthenticated}
-
{/if}
diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte
new file mode 100644
index 0000000..5903918
--- /dev/null
+++ b/frontend/src/lib/components/ConfirmDialog.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ onkeydown={onKey}
+>
+
+
{title}
+ {#if message}
{message}
{/if}
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/ContextMenu.svelte b/frontend/src/lib/components/ContextMenu.svelte
new file mode 100644
index 0000000..7faec7f
--- /dev/null
+++ b/frontend/src/lib/components/ContextMenu.svelte
@@ -0,0 +1,63 @@
+
+
+ contextMenu.close()}
+ onscroll={() => contextMenu.close()}
+ onclick={() => contextMenu.close()}
+ oncontextmenu={() => contextMenu.close()}
+/>
+
+{#if contextMenu.open}
+ e.preventDefault()}
+ >
+ {#each contextMenu.items as item (item.label)}
+
+ {/each}
+
+{/if}
diff --git a/frontend/src/lib/components/CoverPicker.svelte b/frontend/src/lib/components/CoverPicker.svelte
new file mode 100644
index 0000000..5941fc6
--- /dev/null
+++ b/frontend/src/lib/components/CoverPicker.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ onkeydown={onKey}
+>
+
+
+
Выбрать обложку
+
+
+
+
+ {#if error}
{error}
{/if}
+ {#if loading}
+
Загрузка…
+ {:else if photos.length === 0}
+
В альбоме нет фотографий.
+ {:else}
+
+ {#each photos as photo (photo.id)}
+ -
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/components/CreateAlbumDialog.svelte b/frontend/src/lib/components/CreateAlbumDialog.svelte
new file mode 100644
index 0000000..cf3bb40
--- /dev/null
+++ b/frontend/src/lib/components/CreateAlbumDialog.svelte
@@ -0,0 +1,90 @@
+
+
+
+
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ onkeydown={onKey}
+>
+
+
diff --git a/frontend/src/lib/components/DensityControl.svelte b/frontend/src/lib/components/DensityControl.svelte
new file mode 100644
index 0000000..d00ba10
--- /dev/null
+++ b/frontend/src/lib/components/DensityControl.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+ density.set(Number(e.currentTarget.value))}
+ class="accent-primary h-1 w-24 cursor-pointer"
+ aria-label="Размер миниатюр"
+ title="Размер миниатюр"
+ />
+
diff --git a/frontend/src/lib/components/Lightbox.svelte b/frontend/src/lib/components/Lightbox.svelte
deleted file mode 100644
index 5af7be3..0000000
--- a/frontend/src/lib/components/Lightbox.svelte
+++ /dev/null
@@ -1,131 +0,0 @@
-
-
-
-
- {
- if (e.target === e.currentTarget) onClose();
- }}
- onkeydown={onKey}
- ontouchstart={onTouchStart}
- ontouchend={onTouchEnd}
->
-
-
{index + 1} / {photos.length}
-
-
-
-
-
- {#if current}
-

- {/if}
-
-
-
- {#if current}
-
-
- {current.uploader.name} · {current.original_filename}
-
-
-
- {/if}
-
diff --git a/frontend/src/lib/components/PanelSection.svelte b/frontend/src/lib/components/PanelSection.svelte
new file mode 100644
index 0000000..828496b
--- /dev/null
+++ b/frontend/src/lib/components/PanelSection.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+
+ {#if actions}
+
{@render actions()}
+ {/if}
+
+ {#if isOpen}
+ {@render children()}
+ {/if}
+
diff --git a/frontend/src/lib/components/PhotoGrid.svelte b/frontend/src/lib/components/PhotoGrid.svelte
index b7bbcb6..09ad607 100644
--- a/frontend/src/lib/components/PhotoGrid.svelte
+++ b/frontend/src/lib/components/PhotoGrid.svelte
@@ -1,25 +1,45 @@
{#if photos.length === 0}
Пока нет фотографий.
{:else}
-
+
{#each photos as photo, i (photo.id)}
-