feat: implement shared-album multi-user photo album

Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client
(path-style, region garage), embedded golang-migrate migrations, JWT auth with
refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix,
photo upload + async imaging/goexif WebP variants, cursor pagination, presigned
URLs, likes/comments, SSE hub (album + personal), notifications, share links.

Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh,
auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with
reconnect + REST catch-up, three themes + system via data-theme.

Deploy without Docker: Makefile builds the frontend into backend/static and
embeds it (go:embed) into a single statically linked binary; systemd unit and
nginx SSE example included.

Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep
CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
iu
2026-10-10 12:50:33 +03:00
commit 2b2b342492
105 changed files with 12494 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"io/fs"
"log/slog"
"os"
assets "shared-album"
"shared-album/internal/config"
"shared-album/internal/db"
)
func main() {
loadDotEnv(".env")
loadDotEnv("../.env")
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "error", err)
os.Exit(1)
}
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
slog.Error("migrations fs", "error", err)
os.Exit(1)
}
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
slog.Info("migrations applied")
}
func loadDotEnv(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
}
for _, line := range splitLines(string(data)) {
if line == "" || line[0] == '#' {
continue
}
for i := 0; i < len(line); i++ {
if line[i] == '=' {
key := line[:i]
val := line[i+1:]
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val)
}
break
}
}
}
}
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, trim(s[start:i]))
start = i + 1
}
}
out = append(out, trim(s[start:]))
return out
}
func trim(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
end--
}
return s[start:end]
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"bufio"
"context"
"errors"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
assets "shared-album"
"shared-album/internal/app"
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/sse"
"shared-album/internal/storage"
)
func main() {
loadDotEnv(".env")
loadDotEnv("../.env")
logger := slog.New(slog.NewJSONHandler(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, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
slog.Error("migrations fs", "error", err)
os.Exit(1)
}
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
slog.Info("migrations applied")
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)
}
if err := store.EnsureBucket(ctx); err != nil {
slog.Warn("could not ensure bucket", "error", err)
} else {
slog.Info("storage bucket ready", "bucket", store.Bucket())
}
hub := sse.NewHub(cfg.SSE.ClientBuffer)
server := app.New(cfg, pool, store, hub)
httpServer := &http.Server{
Addr: cfg.Server.Addr,
Handler: server.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
slog.Info("listening", "addr", cfg.Server.Addr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "error", err)
stop()
}
}()
<-ctx.Done()
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
}
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)
}
}
}
+13
View File
@@ -0,0 +1,13 @@
package backend
import "embed"
// Static holds the built SvelteKit frontend, copied into backend/static by the Makefile.
//
//go:embed all:static
var Static embed.FS
// Migrations holds the SQL migration files.
//
//go:embed migrations/*.sql
var Migrations embed.FS
+71
View File
@@ -0,0 +1,71 @@
module shared-album
go 1.26.1
require (
github.com/aws/aws-sdk-go-v2 v1.47.3
github.com/aws/aws-sdk-go-v2/config v1.33.9
github.com/aws/aws-sdk-go-v2/credentials v1.20.9
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/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
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
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/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-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/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
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+211
View File
@@ -0,0 +1,211 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk=
github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 h1:oiEmPz/YMMeh/JvAE8xSxNCIsvWE6lpFe4ZPlyFykQI=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ=
github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0=
github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 h1:NyeuyXkZL476uoAowNActm4XJO1QPKf7aassgXHpiqA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3/go.mod h1:GfoBeKMDJecgRUsGf/0RGdx0NJAh81ObTd922h8pQys=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM=
github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k=
github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
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/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=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
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-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=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
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/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=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
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/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/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=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
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.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.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.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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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=
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/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=
+87
View File
@@ -0,0 +1,87 @@
package albums
import (
"net/http"
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/httpx"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
func (h *Handler) List(c *gin.Context) {
list, err := h.svc.List(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"albums": list})
}
type albumRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
}
func (h *Handler) Create(c *gin.Context) {
var req albumRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "title is required"))
return
}
a, err := h.svc.Create(c.Request.Context(), auth.UserID(c), CreateInput(req))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"album": a})
}
func (h *Handler) Get(c *gin.Context) {
a, role, err := h.svc.Get(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"album": a, "role": role})
}
type updateRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
CoverPhotoID *string `json:"cover_photo_id"`
ClearCover bool `json:"clear_cover"`
}
func (h *Handler) Update(c *gin.Context) {
var req updateRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid body"))
return
}
a, err := h.svc.Update(c.Request.Context(), c.Param("id"), auth.UserID(c), UpdateInput{
Title: req.Title,
Description: req.Description,
CoverPhotoID: req.CoverPhotoID,
ClearCover: req.ClearCover,
})
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"album": a})
}
func (h *Handler) Delete(c *gin.Context) {
if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+135
View File
@@ -0,0 +1,135 @@
package albums
import (
"context"
"errors"
"strings"
"shared-album/internal/auth"
"shared-album/internal/httpx"
"shared-album/internal/models"
"shared-album/internal/roles"
"shared-album/internal/sse"
"shared-album/internal/storage"
)
type MemberLookup interface {
Role(ctx context.Context, albumID, userID string) (string, error)
}
type Service struct {
store *Store
members MemberLookup
hub *sse.Hub
storage *storage.Client
}
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client) *Service {
return &Service{store: store, members: members, hub: hub, storage: storage}
}
type CreateInput struct {
Title string
Description string
}
func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*models.Album, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, httpx.BadRequest("invalid_title", "title is required")
}
a := &models.Album{
ID: auth.NewID(),
OwnerID: userID,
Title: title,
Description: strings.TrimSpace(in.Description),
}
if err := s.store.Create(ctx, a); err != nil {
return nil, err
}
return a, nil
}
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
return s.store.ListForUser(ctx, userID)
}
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
a, err := s.store.GetByID(ctx, albumID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, "", httpx.NotFound("album_not_found", "album not found")
}
return nil, "", err
}
if a.OwnerID == userID {
return a, roles.Owner, nil
}
role, err := s.members.Role(ctx, albumID, userID)
if err != nil {
return nil, "", err
}
if role == "" {
return nil, "", httpx.NotFound("album_not_found", "album not found")
}
return a, role, nil
}
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
return s.Access(ctx, albumID, userID)
}
type UpdateInput struct {
Title *string
Description *string
CoverPhotoID *string
ClearCover bool
}
func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateInput) (*models.Album, error) {
a, role, err := s.Access(ctx, albumID, userID)
if err != nil {
return nil, err
}
if !roles.CanEditAlbum(role) {
return nil, httpx.Forbidden("forbidden", "only owner and admin can edit the album")
}
if in.Title != nil {
title := strings.TrimSpace(*in.Title)
if title == "" {
return nil, httpx.BadRequest("invalid_title", "title cannot be empty")
}
a.Title = title
}
if in.Description != nil {
a.Description = strings.TrimSpace(*in.Description)
}
if in.ClearCover {
a.CoverPhotoID = nil
} else if in.CoverPhotoID != nil {
a.CoverPhotoID = in.CoverPhotoID
}
if err := s.store.Update(ctx, a); err != nil {
return nil, err
}
s.hub.Publish(albumID, "album.updated", a)
return a, nil
}
func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
a, role, err := s.Access(ctx, albumID, userID)
if err != nil {
return err
}
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 {
return err
}
if s.storage != nil {
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
}
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package albums
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/httpx"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("album not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Create(ctx context.Context, a *models.Album) error {
return s.pool.QueryRow(ctx,
`INSERT INTO albums (id, owner_id, title, description, cover_photo_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at, updated_at`,
a.ID, a.OwnerID, a.Title, a.Description, a.CoverPhotoID,
).Scan(&a.CreatedAt, &a.UpdatedAt)
}
func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
var ownerID string
err := s.pool.QueryRow(ctx, `SELECT owner_id FROM albums WHERE id = $1`, id).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", httpx.NotFound("album_not_found", "album not found")
}
return "", err
}
return ownerID, nil
}
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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &a, nil
}
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
FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
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 {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) Update(ctx context.Context, a *models.Album) error {
return s.pool.QueryRow(ctx,
`UPDATE albums
SET title = $2, description = $3, cover_photo_id = $4, updated_at = now()
WHERE id = $1
RETURNING updated_at`,
a.ID, a.Title, a.Description, a.CoverPhotoID,
).Scan(&a.UpdatedAt)
}
func (s *Store) Delete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
+286
View File
@@ -0,0 +1,286 @@
package app
import (
"io/fs"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/httpx"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
"shared-album/internal/sse"
"shared-album/internal/storage"
)
type Server struct {
cfg *config.Config
pool *pgxpool.Pool
storage *storage.Client
hub *sse.Hub
albumsStore *albums.Store
albums *albums.Service
auth *auth.Service
membersSvc *members.Service
photos *photos.Service
social *social.Service
notifications *notifications.Service
share *sharelinks.Service
}
func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hub) *Server {
authStore := auth.NewStore(pool)
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
authSvc := auth.NewService(authStore, tm)
albumsStore := albums.NewStore(pool)
membersStore := members.NewStore(pool)
notifStore := notifications.NewStore(pool)
notifSvc := notifications.NewService(notifStore, hub)
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st)
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
photosStore := photos.NewStore(pool)
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
socialStore := social.NewStore(pool)
socialSvc := social.NewService(socialStore, albumsSvc, photosStore, hub, notifSvc)
shareStore := sharelinks.NewStore(pool)
shareSvc := sharelinks.NewService(shareStore, albumsSvc)
return &Server{
cfg: cfg,
pool: pool,
storage: st,
hub: hub,
albumsStore: albumsStore,
albums: albumsSvc,
auth: authSvc,
membersSvc: membersSvc,
photos: photosSvc,
social: socialSvc,
notifications: notifSvc,
share: shareSvc,
}
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
authHandler := auth.NewHandler(s.auth)
albumsHandler := albums.NewHandler(s.albums)
membersHandler := members.NewHandler(s.membersSvc)
photosHandler := photos.NewHandler(s.photos)
socialHandler := social.NewHandler(s.social)
notifHandler := notifications.NewHandler(s.notifications)
shareHandler := sharelinks.NewHandler(s.share)
api := r.Group("/api")
api.GET("/health", s.health)
api.POST("/auth/register", authHandler.Register)
api.POST("/auth/login", authHandler.Login)
api.POST("/auth/refresh", authHandler.Refresh)
api.POST("/auth/logout", authHandler.Logout)
api.GET("/invites/:token", membersHandler.InviteInfo)
api.GET("/share/:token", s.shareAlbum)
api.GET("/share/:token/photos", s.sharePhotos)
api.GET("/share/:token/photos/:photo_id", s.sharePhoto)
api.GET("/share/:token/photos/:photo_id/download", s.sharePhotoDownload)
authed := api.Group("")
authed.Use(s.auth.Middleware())
authed.GET("/auth/me", authHandler.Me)
authed.GET("/sse/albums/:id", s.albumSSE)
authed.GET("/sse/me", s.meSSE)
authed.GET("/albums", albumsHandler.List)
authed.POST("/albums", albumsHandler.Create)
authed.GET("/albums/:id", albumsHandler.Get)
authed.PATCH("/albums/:id", albumsHandler.Update)
authed.DELETE("/albums/:id", albumsHandler.Delete)
authed.GET("/albums/:id/members", membersHandler.List)
authed.POST("/albums/:id/invites", membersHandler.CreateInvite)
authed.POST("/invites/:token/accept", membersHandler.AcceptInvite)
authed.PATCH("/albums/:id/members/:user_id", membersHandler.ChangeRole)
authed.DELETE("/albums/:id/members/:user_id", membersHandler.Remove)
authed.GET("/albums/:id/photos", photosHandler.List)
authed.POST("/albums/:id/photos", photosHandler.Upload)
authed.GET("/photos/:id", photosHandler.Get)
authed.DELETE("/photos/:id", photosHandler.Delete)
authed.GET("/photos/:id/download", photosHandler.Download)
authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
authed.GET("/photos/:id/comments", socialHandler.ListComments)
authed.POST("/photos/:id/comments", socialHandler.AddComment)
authed.DELETE("/comments/:id", socialHandler.DeleteComment)
authed.GET("/albums/:id/share", shareHandler.List)
authed.POST("/albums/:id/share", shareHandler.Create)
authed.DELETE("/albums/:id/share/:link_id", shareHandler.Revoke)
authed.GET("/notifications", notifHandler.List)
authed.POST("/notifications/:id/read", notifHandler.MarkRead)
s.registerStatic(r)
return r
}
func (s *Server) health(c *gin.Context) {
ctx := c.Request.Context()
dbStatus := "ok"
if err := s.pool.Ping(ctx); err != nil {
dbStatus = "error"
}
s3Status := "ok"
if err := s.storage.Ping(ctx); err != nil {
s3Status = "error"
}
status := http.StatusOK
overall := "ok"
if dbStatus != "ok" || s3Status != "ok" {
status = http.StatusServiceUnavailable
overall = "degraded"
}
c.JSON(status, gin.H{"status": overall, "db": dbStatus, "s3": s3Status})
}
func (s *Server) albumSSE(c *gin.Context) {
userID := auth.UserID(c)
albumID := c.Param("id")
if _, _, err := s.albums.Access(c.Request.Context(), albumID, userID); err != nil {
httpx.RespondError(c, err)
return
}
sub := s.hub.SubscribeAlbum(albumID)
defer sub.Close()
sse.Serve(c, sub, s.cfg.SSE.HeartbeatInterval)
}
func (s *Server) meSSE(c *gin.Context) {
sub := s.hub.SubscribeUser(auth.UserID(c))
defer sub.Close()
sse.Serve(c, sub, s.cfg.SSE.HeartbeatInterval)
}
func (s *Server) shareAlbum(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
album, err := s.albumsStore.GetByID(c.Request.Context(), link.AlbumID)
if err != nil {
httpx.RespondError(c, httpx.NotFound("album_not_found", "album not found"))
return
}
c.JSON(http.StatusOK, gin.H{"album": album})
}
func (s *Server) sharePhotos(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
limit := 50
var since *time.Time
list, next, err := s.photos.ListPublic(c.Request.Context(), link.AlbumID, c.Query("cursor"), since, limit)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": list, "next_cursor": next})
}
func (s *Server) sharePhoto(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
p, err := s.photos.GetPublic(c.Request.Context(), c.Param("photo_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
if p.AlbumID != link.AlbumID {
httpx.RespondError(c, httpx.NotFound("photo_not_found", "photo not found"))
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (s *Server) sharePhotoDownload(c *gin.Context) {
link, err := s.share.Resolve(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
p, err := s.photos.GetPublic(c.Request.Context(), c.Param("photo_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
if p.AlbumID != link.AlbumID {
httpx.RespondError(c, httpx.NotFound("photo_not_found", "photo not found"))
return
}
c.JSON(http.StatusOK, gin.H{"url": p.DownloadURL})
}
func (s *Server) registerStatic(r *gin.Engine) {
sub, err := fs.Sub(assets.Static, "static")
if err != nil {
return
}
fileServer := http.FileServer(http.FS(sub))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") {
c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: httpx.ErrorBody{Code: "not_found", Message: "not found"}})
return
}
clean := strings.TrimPrefix(path, "/")
if clean != "" {
if f, err := sub.Open(clean); err == nil {
f.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
c.String(http.StatusNotFound, "frontend not built")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
}
+93
View File
@@ -0,0 +1,93 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
"shared-album/internal/httpx"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
type registerRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
Name string `json:"name" binding:"required"`
}
func (h *Handler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "email, password and name are required"))
return
}
u, pair, err := h.svc.Register(c.Request.Context(), req.Email, req.Password, req.Name)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"user": u.Public(), "tokens": pair})
}
type loginRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "email and password are required"))
return
}
u, pair, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"user": u.Public(), "tokens": pair})
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (h *Handler) Refresh(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "refresh_token is required"))
return
}
pair, err := h.svc.Refresh(c.Request.Context(), req.RefreshToken)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"tokens": pair})
}
func (h *Handler) Logout(c *gin.Context) {
var req refreshRequest
_ = c.ShouldBindJSON(&req)
if req.RefreshToken != "" {
if err := h.svc.Logout(c.Request.Context(), req.RefreshToken); err != nil {
httpx.RespondError(c, err)
return
}
}
c.Status(http.StatusNoContent)
}
func (h *Handler) Me(c *gin.Context) {
u, err := h.svc.store.GetUserByID(c.Request.Context(), UserID(c))
if err != nil {
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
return
}
c.JSON(http.StatusOK, gin.H{"user": u.Public()})
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type Claims struct {
UserID string `json:"uid"`
Email string `json:"email"`
jwt.RegisteredClaims
}
type TokenManager struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager {
return &TokenManager{secret: []byte(secret), accessTTL: accessTTL, refreshTTL: refreshTTL}
}
func (m *TokenManager) AccessTTL() time.Duration { return m.accessTTL }
func (m *TokenManager) RefreshTTL() time.Duration { return m.refreshTTL }
func (m *TokenManager) GenerateAccess(userID, email string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *TokenManager) ParseAccess(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func NewRefreshToken() (raw string, hash string, err error) {
buf := make([]byte, 32)
if _, err = rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
return raw, HashRefreshToken(raw), nil
}
func HashRefreshToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func NewID() string {
return uuid.NewString()
}
+61
View File
@@ -0,0 +1,61 @@
package auth
import (
"strings"
"github.com/gin-gonic/gin"
"shared-album/internal/httpx"
)
const (
ctxUserID = "auth.user_id"
ctxEmail = "auth.email"
)
func (s *Service) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)
if token == "" {
httpx.Abort(c, httpx.Unauthorized("unauthorized", "missing access token"))
return
}
claims, err := s.tm.ParseAccess(token)
if err != nil {
httpx.Abort(c, httpx.Unauthorized("unauthorized", "invalid or expired access token"))
return
}
c.Set(ctxUserID, claims.UserID)
c.Set(ctxEmail, claims.Email)
c.Next()
}
}
func extractToken(c *gin.Context) string {
h := c.GetHeader("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
if t := c.Query("token"); t != "" {
return t
}
return ""
}
func UserID(c *gin.Context) string {
if v, ok := c.Get(ctxUserID); ok {
return v.(string)
}
return ""
}
func Email(c *gin.Context) string {
if v, ok := c.Get(ctxEmail); ok {
return v.(string)
}
return ""
}
func (s *Service) ParseToken(token string) (*Claims, error) {
return s.tm.ParseAccess(token)
}
+15
View File
@@ -0,0 +1,15 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+147
View File
@@ -0,0 +1,147 @@
package auth
import (
"context"
"errors"
"strings"
"time"
"shared-album/internal/httpx"
"shared-album/internal/models"
)
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
type Service struct {
store *Store
tm *TokenManager
}
func NewService(store *Store, tm *TokenManager) *Service {
return &Service{store: store, tm: tm}
}
func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, *TokenPair, error) {
email = strings.TrimSpace(strings.ToLower(email))
name = strings.TrimSpace(name)
if email == "" || !strings.Contains(email, "@") {
return nil, nil, httpx.BadRequest("invalid_email", "valid email is required")
}
if len(password) < 8 {
return nil, nil, httpx.BadRequest("weak_password", "password must be at least 8 characters")
}
if name == "" {
return nil, nil, httpx.BadRequest("invalid_name", "name is required")
}
if _, err := s.store.GetUserByEmail(ctx, email); err == nil {
return nil, nil, httpx.Conflict("email_taken", "email is already registered")
} else if !errors.Is(err, ErrNotFound) {
return nil, nil, err
}
hash, err := HashPassword(password)
if err != nil {
return nil, nil, err
}
u := &models.User{
ID: NewID(),
Email: email,
PasswordHash: hash,
Name: name,
CreatedAt: time.Now(),
}
if err := s.store.CreateUser(ctx, u); err != nil {
return nil, nil, err
}
pair, err := s.issuePair(ctx, u)
if err != nil {
return nil, nil, err
}
return u, pair, nil
}
func (s *Service) Login(ctx context.Context, email, password string) (*models.User, *TokenPair, error) {
email = strings.TrimSpace(strings.ToLower(email))
u, err := s.store.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
}
return nil, nil, err
}
if !CheckPassword(u.PasswordHash, password) {
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
}
pair, err := s.issuePair(ctx, u)
if err != nil {
return nil, nil, err
}
return u, pair, nil
}
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
hash := HashRefreshToken(refreshToken)
t, err := s.store.GetRefreshToken(ctx, hash)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
}
return nil, err
}
if t.RevokedAt != nil || time.Now().After(t.ExpiresAt) {
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
}
u, err := s.store.GetUserByID(ctx, t.UserID)
if err != nil {
return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired")
}
if err := s.store.RevokeRefreshToken(ctx, t.ID); err != nil {
return nil, err
}
return s.issuePair(ctx, u)
}
func (s *Service) Logout(ctx context.Context, refreshToken string) error {
hash := HashRefreshToken(refreshToken)
t, err := s.store.GetRefreshToken(ctx, hash)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}
return s.store.RevokeRefreshToken(ctx, t.ID)
}
func (s *Service) issuePair(ctx context.Context, u *models.User) (*TokenPair, error) {
access, err := s.tm.GenerateAccess(u.ID, u.Email)
if err != nil {
return nil, err
}
raw, hash, err := NewRefreshToken()
if err != nil {
return nil, err
}
rt := &models.RefreshToken{
ID: NewID(),
UserID: u.ID,
TokenHash: hash,
ExpiresAt: time.Now().Add(s.tm.RefreshTTL()),
}
if err := s.store.CreateRefreshToken(ctx, rt); err != nil {
return nil, err
}
return &TokenPair{
AccessToken: access,
RefreshToken: raw,
TokenType: "Bearer",
ExpiresIn: int64(s.tm.AccessTTL().Seconds()),
}, nil
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO users (id, email, password_hash, name, avatar_url)
VALUES ($1, $2, $3, $4, $5)`,
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL),
)
return err
}
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
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
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 {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &u, 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)
VALUES ($1, $2, $3, $4)`,
t.ID, t.UserID, t.TokenHash, t.ExpiresAt,
)
return err
}
func (s *Store) GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) {
var t models.RefreshToken
err := s.pool.QueryRow(ctx,
`SELECT id, user_id, token_hash, expires_at, revoked_at, created_at
FROM refresh_tokens WHERE token_hash = $1`, hash).
Scan(&t.ID, &t.UserID, &t.TokenHash, &t.ExpiresAt, &t.RevokedAt, &t.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &t, nil
}
func (s *Store) RevokeRefreshToken(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx,
`UPDATE refresh_tokens SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id)
return err
}
func (s *Store) RevokeAllUserTokens(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx,
`UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
return err
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
+144
View File
@@ -0,0 +1,144 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Postgres struct {
Host string
Port int
User string
Password string
Database string
SSLMode string
}
func (p Postgres) DSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
p.User, p.Password, p.Host, p.Port, p.Database, p.SSLMode)
}
type S3 struct {
Endpoint string
Region string
AccessKey string
SecretKey string
Bucket string
UsePathStyle bool
PublicURL string
PresignTTL time.Duration
}
type Auth struct {
JWTSecret string
AccessTTL time.Duration
RefreshTTL time.Duration
}
type Server struct {
Addr string
BaseURL string
}
type SSE struct {
HeartbeatInterval time.Duration
ClientBuffer int
}
type Config struct {
Postgres Postgres
S3 S3
Auth Auth
Server Server
SSE SSE
}
func Load() (*Config, error) {
pgPort, err := strconv.Atoi(getEnv("PG_PORT", "5432"))
if err != nil {
return nil, fmt.Errorf("invalid PG_PORT: %w", err)
}
accessTTL, err := time.ParseDuration(getEnv("JWT_ACCESS_TTL", "15m"))
if err != nil {
return nil, fmt.Errorf("invalid JWT_ACCESS_TTL: %w", err)
}
refreshTTL, err := time.ParseDuration(getEnv("JWT_REFRESH_TTL", "720h"))
if err != nil {
return nil, fmt.Errorf("invalid JWT_REFRESH_TTL: %w", err)
}
presignTTL, err := time.ParseDuration(getEnv("S3_PRESIGN_TTL", "1h"))
if err != nil {
return nil, fmt.Errorf("invalid S3_PRESIGN_TTL: %w", err)
}
heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s"))
if err != nil {
return nil, fmt.Errorf("invalid SSE_HEARTBEAT_INTERVAL: %w", err)
}
bufSize, err := strconv.Atoi(getEnv("SSE_CLIENT_BUFFER", "64"))
if err != nil {
return nil, fmt.Errorf("invalid SSE_CLIENT_BUFFER: %w", err)
}
pathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "true"))
if err != nil {
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
}
cfg := &Config{
Postgres: Postgres{
Host: getEnv("PG_HOST", "pg.localnet"),
Port: pgPort,
User: getEnv("PG_USER", "album"),
Password: getEnv("PG_PASSWORD", ""),
Database: getEnv("PG_DATABASE", "shared_album"),
SSLMode: getEnv("PG_SSLMODE", "disable"),
},
S3: S3{
Endpoint: getEnv("S3_ENDPOINT", "http://s3.localnet:3900"),
Region: getEnv("S3_REGION", "garage"),
AccessKey: getEnv("S3_ACCESS_KEY", ""),
SecretKey: getEnv("S3_SECRET_KEY", ""),
Bucket: getEnv("S3_BUCKET", "albums"),
UsePathStyle: pathStyle,
PublicURL: getEnv("S3_PUBLIC_URL", ""),
PresignTTL: presignTTL,
},
Auth: Auth{
JWTSecret: getEnv("JWT_SECRET", ""),
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
},
Server: Server{
Addr: getEnv("HTTP_ADDR", ":8080"),
BaseURL: getEnv("APP_BASE_URL", "http://localhost:8080"),
},
SSE: SSE{
HeartbeatInterval: heartbeat,
ClientBuffer: bufSize,
},
}
if cfg.Auth.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
if cfg.Postgres.Password == "" {
return nil, fmt.Errorf("PG_PASSWORD is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
+48
View File
@@ -0,0 +1,48 @@
package db
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 1
cfg.MaxConnLifetime = time.Hour
cfg.HealthCheckPeriod = time.Minute
var pool *pgxpool.Pool
var lastErr error
backoff := time.Second
for attempt := 1; attempt <= 10; attempt++ {
pool, err = pgxpool.NewWithConfig(ctx, cfg)
if err == nil {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err = pool.Ping(pingCtx)
cancel()
if err == nil {
return pool, nil
}
pool.Close()
}
lastErr = err
slog.Warn("db connect attempt failed", "attempt", attempt, "error", err)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
if backoff < 15*time.Second {
backoff *= 2
}
}
return nil, fmt.Errorf("connect to database after retries: %w", lastErr)
}
+41
View File
@@ -0,0 +1,41 @@
package db
import (
"errors"
"fmt"
"io/fs"
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
)
// Migrate applies all up migrations from the given fs (root contains *.sql files).
func Migrate(dsn string, migrations fs.FS) error {
source, err := iofs.New(migrations, ".")
if err != nil {
return fmt.Errorf("migrations source: %w", err)
}
sqlDB, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open sql db: %w", err)
}
defer sqlDB.Close()
driver, err := pgx.WithInstance(sqlDB, &pgx.Config{})
if err != nil {
return fmt.Errorf("migrate driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pgx", driver)
if err != nil {
return fmt.Errorf("migrate instance: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package httpx
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
type AppError struct {
Status int
Code string
Message string
}
func (e *AppError) Error() string { return e.Message }
func NewError(status int, code, message string) *AppError {
return &AppError{Status: status, Code: code, Message: message}
}
func BadRequest(code, message string) *AppError {
return NewError(http.StatusBadRequest, code, message)
}
func Unauthorized(code, message string) *AppError {
return NewError(http.StatusUnauthorized, code, message)
}
func Forbidden(code, message string) *AppError {
return NewError(http.StatusForbidden, code, message)
}
func NotFound(code, message string) *AppError {
return NewError(http.StatusNotFound, code, message)
}
func Conflict(code, message string) *AppError {
return NewError(http.StatusConflict, code, message)
}
func Internal(message string) *AppError {
return NewError(http.StatusInternalServerError, "internal_error", message)
}
func Abort(c *gin.Context, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
c.AbortWithStatusJSON(appErr.Status, ErrorResponse{ErrorBody{appErr.Code, appErr.Message}})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{ErrorBody{"internal_error", "internal server error"}})
}
func RespondError(c *gin.Context, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
c.JSON(appErr.Status, ErrorResponse{ErrorBody{appErr.Code, appErr.Message}})
return
}
c.JSON(http.StatusInternalServerError, ErrorResponse{ErrorBody{"internal_error", "internal server error"}})
}
+106
View File
@@ -0,0 +1,106 @@
package members
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/httpx"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
func (h *Handler) List(c *gin.Context) {
list, err := h.svc.List(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"members": list})
}
type inviteRequest struct {
Role string `json:"role" binding:"required"`
ExpiresIn string `json:"expires_in"`
MaxUses int `json:"max_uses"`
}
func (h *Handler) CreateInvite(c *gin.Context) {
var req inviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "role is required"))
return
}
var ttl time.Duration
if req.ExpiresIn != "" {
d, err := time.ParseDuration(req.ExpiresIn)
if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_expires_in", "invalid expires_in duration"))
return
}
ttl = d
}
inv, err := h.svc.CreateInvite(c.Request.Context(), c.Param("id"), auth.UserID(c), CreateInviteInput{
Role: req.Role,
TTL: ttl,
MaxUses: req.MaxUses,
})
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"invite": inv})
}
func (h *Handler) AcceptInvite(c *gin.Context) {
inv, err := h.svc.Accept(c.Request.Context(), c.Param("token"), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"album_id": inv.AlbumID, "role": inv.Role})
}
func (h *Handler) InviteInfo(c *gin.Context) {
inv, err := h.svc.InviteInfo(c.Request.Context(), c.Param("token"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"invite": gin.H{
"album_id": inv.AlbumID,
"role": inv.Role,
"expires_at": inv.ExpiresAt,
}})
}
type roleRequest struct {
Role string `json:"role" binding:"required"`
}
func (h *Handler) ChangeRole(c *gin.Context) {
var req roleRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "role is required"))
return
}
if err := h.svc.ChangeRole(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("user_id"), req.Role); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) Remove(c *gin.Context) {
if err := h.svc.Remove(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("user_id")); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+205
View File
@@ -0,0 +1,205 @@
package members
import (
"context"
"errors"
"time"
"shared-album/internal/auth"
"shared-album/internal/httpx"
"shared-album/internal/models"
"shared-album/internal/roles"
"shared-album/internal/sse"
)
type AlbumReader interface {
OwnerID(ctx context.Context, albumID string) (string, error)
}
type Notifier interface {
Create(ctx context.Context, userID, typ string, payload map[string]any) error
}
type Service struct {
store *Store
albums AlbumReader
hub *sse.Hub
notify Notifier
}
func NewService(store *Store, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, hub: hub, notify: notify}
}
func (s *Service) Role(ctx context.Context, albumID, userID string) (string, error) {
return s.store.Role(ctx, albumID, userID)
}
func (s *Service) actorRole(ctx context.Context, albumID, actorID string) (string, error) {
ownerID, err := s.albums.OwnerID(ctx, albumID)
if err != nil {
return "", err
}
if ownerID == actorID {
return roles.Owner, nil
}
role, err := s.store.Role(ctx, albumID, actorID)
if err != nil {
return "", err
}
if role == "" {
return "", httpx.NotFound("album_not_found", "album not found")
}
return role, nil
}
type CreateInviteInput struct {
Role string
TTL time.Duration
MaxUses int
}
func (s *Service) CreateInvite(ctx context.Context, albumID, actorID string, in CreateInviteInput) (*models.Invite, error) {
role, err := s.actorRole(ctx, albumID, actorID)
if err != nil {
return nil, err
}
if !roles.CanInvite(role, in.Role) {
return nil, httpx.Forbidden("forbidden", "you cannot invite with this role")
}
if in.TTL <= 0 {
in.TTL = 7 * 24 * time.Hour
}
if in.MaxUses <= 0 {
in.MaxUses = 1
}
token, err := randomToken()
if err != nil {
return nil, err
}
inv := &models.Invite{
ID: auth.NewID(),
AlbumID: albumID,
Token: token,
Role: in.Role,
CreatedBy: actorID,
ExpiresAt: time.Now().Add(in.TTL),
MaxUses: in.MaxUses,
}
if err := s.store.CreateInvite(ctx, inv); err != nil {
return nil, err
}
return inv, nil
}
func (s *Service) Accept(ctx context.Context, token, userID string) (*models.Invite, error) {
inv, err := s.store.GetInviteByToken(ctx, token)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("invite_not_found", "invite not found")
}
return nil, err
}
if time.Now().After(inv.ExpiresAt) {
return nil, httpx.BadRequest("invite_expired", "invite has expired")
}
if inv.Uses >= inv.MaxUses {
return nil, httpx.BadRequest("invite_exhausted", "invite has no remaining uses")
}
if err := s.store.Add(ctx, inv.AlbumID, userID, inv.Role, &inv.CreatedBy); err != nil {
return nil, err
}
if err := s.store.IncrementUses(ctx, inv.ID); err != nil {
return nil, err
}
s.hub.Publish(inv.AlbumID, "member.joined", map[string]string{"album_id": inv.AlbumID, "user_id": userID, "role": inv.Role})
if s.notify != nil {
ownerID, err := s.albums.OwnerID(ctx, inv.AlbumID)
if err == nil && ownerID != userID {
_ = s.notify.Create(ctx, ownerID, "member.joined", map[string]any{"album_id": inv.AlbumID, "user_id": userID, "role": inv.Role})
}
}
return inv, nil
}
func (s *Service) List(ctx context.Context, albumID, actorID string) ([]models.AlbumMember, error) {
if _, err := s.actorRole(ctx, albumID, actorID); err != nil {
return nil, err
}
return s.store.List(ctx, albumID)
}
func (s *Service) InviteInfo(ctx context.Context, token string) (*models.Invite, error) {
inv, err := s.store.GetInviteByToken(ctx, token)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("invite_not_found", "invite not found")
}
return nil, err
}
if time.Now().After(inv.ExpiresAt) || inv.Uses >= inv.MaxUses {
return nil, httpx.BadRequest("invite_inactive", "invite is expired or exhausted")
}
return inv, nil
}
func (s *Service) ChangeRole(ctx context.Context, albumID, actorID, targetID, newRole string) error {
actor, err := s.actorRole(ctx, albumID, actorID)
if err != nil {
return err
}
current, err := s.store.Role(ctx, albumID, targetID)
if err != nil {
return err
}
if current == "" {
if ownerID, oerr := s.albums.OwnerID(ctx, albumID); oerr == nil && ownerID == targetID {
return httpx.Forbidden("forbidden", "cannot change the owner's role")
}
return httpx.NotFound("member_not_found", "member not found")
}
if !roles.CanChangeRole(actor, current, newRole) {
return httpx.Forbidden("forbidden", "you cannot assign this role")
}
if err := s.store.UpdateRole(ctx, albumID, targetID, newRole); err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("member_not_found", "member not found")
}
return err
}
s.hub.Publish(albumID, "member.updated", map[string]string{"album_id": albumID, "user_id": targetID, "role": newRole})
return nil
}
func (s *Service) Remove(ctx context.Context, albumID, actorID, targetID string) error {
actor, err := s.actorRole(ctx, albumID, actorID)
if err != nil {
return err
}
targetRole, err := s.store.Role(ctx, albumID, targetID)
if err != nil {
return err
}
if targetRole == "" {
if ownerID, oerr := s.albums.OwnerID(ctx, albumID); oerr == nil && ownerID == targetID {
return httpx.Forbidden("forbidden", "cannot remove the album owner")
}
return httpx.NotFound("member_not_found", "member not found")
}
if !roles.CanRemoveMember(actor, targetRole) {
return httpx.Forbidden("forbidden", "you cannot remove this member")
}
if err := s.store.Remove(ctx, albumID, targetID); err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("member_not_found", "member not found")
}
return err
}
s.hub.Publish(albumID, "member.left", map[string]string{"album_id": albumID, "user_id": targetID})
return nil
}
func randomToken() (string, error) {
raw, _, err := auth.NewRefreshToken()
return raw, err
}
+121
View File
@@ -0,0 +1,121 @@
package members
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Role(ctx context.Context, albumID, userID string) (string, error) {
var role string
err := s.pool.QueryRow(ctx,
`SELECT role FROM album_members WHERE album_id = $1 AND user_id = $2`,
albumID, userID).Scan(&role)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
func (s *Store) List(ctx context.Context, albumID string) ([]models.AlbumMember, error) {
rows, err := s.pool.Query(ctx,
`SELECT m.album_id, m.user_id, m.role, m.invited_by, m.joined_at,
u.id, u.email, u.name, COALESCE(u.avatar_url, '')
FROM album_members m
JOIN users u ON u.id = m.user_id
WHERE m.album_id = $1
ORDER BY m.joined_at`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.AlbumMember{}
for rows.Next() {
var m models.AlbumMember
if err := rows.Scan(&m.AlbumID, &m.UserID, &m.Role, &m.InvitedBy, &m.JoinedAt,
&m.User.ID, &m.User.Email, &m.User.Name, &m.User.AvatarURL); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO album_members (album_id, user_id, role, invited_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (album_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
albumID, userID, role, invitedBy)
return err
}
func (s *Store) UpdateRole(ctx context.Context, albumID, userID, role string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE album_members SET role = $3 WHERE album_id = $1 AND user_id = $2`,
albumID, userID, role)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) Remove(ctx context.Context, albumID, userID string) error {
tag, err := s.pool.Exec(ctx,
`DELETE FROM album_members WHERE album_id = $1 AND user_id = $2`, albumID, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) CreateInvite(ctx context.Context, inv *models.Invite) error {
return s.pool.QueryRow(ctx,
`INSERT INTO invites (id, album_id, token, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at`,
inv.ID, inv.AlbumID, inv.Token, inv.Role, inv.CreatedBy, inv.ExpiresAt, inv.MaxUses,
).Scan(&inv.CreatedAt)
}
func (s *Store) GetInviteByToken(ctx context.Context, token string) (*models.Invite, error) {
var inv models.Invite
err := s.pool.QueryRow(ctx,
`SELECT id, album_id, token, role, created_by, expires_at, max_uses, uses, created_at
FROM invites WHERE token = $1`, token).
Scan(&inv.ID, &inv.AlbumID, &inv.Token, &inv.Role, &inv.CreatedBy, &inv.ExpiresAt, &inv.MaxUses, &inv.Uses, &inv.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &inv, nil
}
func (s *Store) IncrementUses(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `UPDATE invites SET uses = uses + 1 WHERE id = $1`, id)
return err
}
+126
View File
@@ -0,0 +1,126 @@
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"`
}
type PublicUser struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"`
}
func (u *User) Public() PublicUser {
if u == nil {
return PublicUser{}
}
return PublicUser{ID: u.ID, Email: u.Email, Name: u.Name, AvatarURL: u.AvatarURL}
}
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"`
}
type AlbumMember struct {
AlbumID string `json:"album_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
InvitedBy *string `json:"invited_by,omitempty"`
JoinedAt time.Time `json:"joined_at"`
User PublicUser `json:"user"`
}
type Invite struct {
ID string `json:"id"`
AlbumID string `json:"album_id"`
Token string `json:"token"`
Role string `json:"role"`
CreatedBy string `json:"created_by"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int `json:"max_uses"`
Uses int `json:"uses"`
CreatedAt time.Time `json:"created_at"`
}
type Photo struct {
ID string `json:"id"`
AlbumID string `json:"album_id"`
UploaderID string `json:"uploader_id"`
StorageKey string `json:"-"`
ThumbKey string `json:"-"`
PreviewKey string `json:"-"`
OriginalFilename string `json:"original_filename"`
Size int64 `json:"size"`
Width int `json:"width"`
Height int `json:"height"`
MimeType string `json:"mime_type"`
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"`
Uploader PublicUser `json:"uploader"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
LikedByMe bool `json:"liked_by_me"`
ThumbURL string `json:"thumb_url,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
OriginalURL string `json:"original_url,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
}
type Comment struct {
ID string `json:"id"`
PhotoID string `json:"photo_id"`
UserID string `json:"user_id"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User PublicUser `json:"user"`
}
type Notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Type string `json:"type"`
PayloadJSON map[string]any `json:"payload"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ShareLink struct {
ID string `json:"id"`
AlbumID string `json:"album_id"`
Token string `json:"token"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
func (s *ShareLink) Active(now time.Time) bool {
return s.RevokedAt == nil && now.Before(s.ExpiresAt)
}
type RefreshToken struct {
ID string
UserID string
TokenHash string
ExpiresAt time.Time
RevokedAt *time.Time
CreatedAt time.Time
}
@@ -0,0 +1,35 @@
package notifications
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/httpx"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
func (h *Handler) List(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
list, err := h.svc.List(c.Request.Context(), auth.UserID(c), limit)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"notifications": list})
}
func (h *Handler) MarkRead(c *gin.Context) {
if err := h.svc.MarkRead(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+44
View File
@@ -0,0 +1,44 @@
package notifications
import (
"context"
"shared-album/internal/auth"
"shared-album/internal/httpx"
"shared-album/internal/models"
"shared-album/internal/sse"
)
type Service struct {
store *Store
hub *sse.Hub
}
func NewService(store *Store, hub *sse.Hub) *Service {
return &Service{store: store, hub: hub}
}
func (s *Service) Create(ctx context.Context, userID, typ string, payload map[string]any) error {
n := &models.Notification{
ID: auth.NewID(),
UserID: userID,
Type: typ,
PayloadJSON: payload,
}
if err := s.store.Create(ctx, n); err != nil {
return err
}
s.hub.PublishUser(userID, "notification", n)
return nil
}
func (s *Service) List(ctx context.Context, userID string, limit int) ([]models.Notification, error) {
return s.store.List(ctx, userID, limit)
}
func (s *Service) MarkRead(ctx context.Context, id, userID string) error {
if err := s.store.MarkRead(ctx, id, userID); err != nil {
return httpx.NotFound("notification_not_found", "notification not found")
}
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package notifications
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Create(ctx context.Context, n *models.Notification) error {
payload, err := json.Marshal(n.PayloadJSON)
if err != nil {
return err
}
return s.pool.QueryRow(ctx,
`INSERT INTO notifications (id, user_id, type, payload_json)
VALUES ($1, $2, $3, $4)
RETURNING created_at`,
n.ID, n.UserID, n.Type, payload,
).Scan(&n.CreatedAt)
}
func (s *Store) List(ctx context.Context, userID string, limit int) ([]models.Notification, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
rows, err := s.pool.Query(ctx,
`SELECT id, user_id, type, payload_json, read_at, created_at
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Notification{}
for rows.Next() {
var n models.Notification
var payload []byte
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &payload, &n.ReadAt, &n.CreatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal(payload, &n.PayloadJSON)
out = append(out, n)
}
return out, rows.Err()
}
func (s *Store) MarkRead(ctx context.Context, id, userID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE notifications SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL`,
id, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
var exists bool
if err := s.pool.QueryRow(ctx, `SELECT true FROM notifications WHERE id = $1 AND user_id = $2`, id, userID).Scan(&exists); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package photos
import (
"io"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/httpx"
)
const maxUploadSize = 50 << 20
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
func (h *Handler) List(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
var since *time.Time
if raw := c.Query("since"); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
since = &t
} else if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
t := time.UnixMilli(ms)
since = &t
}
}
list, next, err := h.svc.List(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Query("cursor"), since, limit)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": list, "next_cursor": next})
}
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"))
return
}
files := form.File["files"]
if len(files) == 0 {
files = form.File["file"]
}
if len(files) == 0 {
httpx.RespondError(c, httpx.BadRequest("no_files", "no files provided"))
return
}
created := make([]any, 0, len(files))
for _, fh := range files {
f, err := fh.Open()
if err != nil {
httpx.RespondError(c, err)
return
}
data, err := io.ReadAll(io.LimitReader(f, maxUploadSize))
f.Close()
if err != nil {
httpx.RespondError(c, err)
return
}
p, err := h.svc.Upload(c.Request.Context(), UploadInput{
AlbumID: c.Param("id"),
UserID: auth.UserID(c),
Filename: fh.Filename,
MimeType: fh.Header.Get("Content-Type"),
Data: data,
})
if err != nil {
httpx.RespondError(c, err)
return
}
created = append(created, p)
}
c.JSON(http.StatusCreated, gin.H{"photos": created})
}
func (h *Handler) Get(c *gin.Context) {
p, err := h.svc.Get(c.Request.Context(), c.Param("id"), auth.UserID(c))
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 {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
func (h *Handler) Delete(c *gin.Context) {
if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+69
View File
@@ -0,0 +1,69 @@
package photos
import (
"bytes"
"image"
"time"
"github.com/disintegration/imaging"
"github.com/gen2brain/webp"
"github.com/rwcarlsen/goexif/exif"
)
type ImageMeta struct {
Width int
Height int
MimeType string
TakenAt *time.Time
Latitude *float64
Longitude *float64
Thumb []byte
Preview []byte
}
func inspect(data []byte) (width, height int, ok bool) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return 0, 0, false
}
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
}
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
}
}
}
if la, lo, err := x.LatLong(); err == nil {
lat, lon = &la, &lo
}
return taken, lat, lon
}
func buildVariants(data []byte) (thumb, preview []byte, err error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, nil, err
}
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
opts := webp.Options{Quality: 82}
var thumbBuf bytes.Buffer
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
return nil, nil, err
}
var previewBuf bytes.Buffer
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
return nil, nil, err
}
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
}
+284
View File
@@ -0,0 +1,284 @@
package photos
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"shared-album/internal/auth"
"shared-album/internal/httpx"
"shared-album/internal/models"
"shared-album/internal/roles"
"shared-album/internal/sse"
"shared-album/internal/storage"
)
type AlbumAccess interface {
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
}
type Service struct {
store *Store
albums AlbumAccess
storage *storage.Client
hub *sse.Hub
}
func NewService(store *Store, albums AlbumAccess, storage *storage.Client, hub *sse.Hub) *Service {
return &Service{store: store, albums: albums, storage: storage, hub: hub}
}
type UploadInput struct {
AlbumID string
UserID string
Filename string
MimeType string
Data []byte
}
func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, error) {
_, role, err := s.albums.Access(ctx, in.AlbumID, in.UserID)
if err != nil {
return nil, err
}
if !roles.CanUpload(role) {
return nil, httpx.Forbidden("forbidden", "you do not have permission to upload photos")
}
mime := in.MimeType
if mime == "" {
mime = http.DetectContentType(in.Data)
}
if !strings.HasPrefix(mime, "image/") {
return nil, httpx.BadRequest("invalid_file", "file is not an image")
}
width, height, ok := inspect(in.Data)
if !ok {
return nil, httpx.BadRequest("invalid_image", "could not decode image")
}
taken, lat, lon := extractEXIF(in.Data)
id := auth.NewID()
ext := strings.ToLower(filepath.Ext(in.Filename))
if ext == "" {
ext = ".bin"
}
storageKey := fmt.Sprintf("albums/%s/original/%s%s", in.AlbumID, id, ext)
if err := s.storage.Put(ctx, storageKey, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
return nil, err
}
p := &models.Photo{
ID: id,
AlbumID: in.AlbumID,
UploaderID: in.UserID,
StorageKey: storageKey,
OriginalFilename: in.Filename,
Size: int64(len(in.Data)),
Width: width,
Height: height,
MimeType: mime,
TakenAt: taken,
Latitude: lat,
Longitude: lon,
}
if err := s.store.Create(ctx, p); err != nil {
return nil, err
}
data := in.Data
go s.processVariants(p.ID, in.AlbumID, data)
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) processVariants(photoID, albumID string, data []byte) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
thumb, preview, err := buildVariants(data)
if err != nil {
return
}
thumbKey := fmt.Sprintf("albums/%s/thumb/%s.webp", albumID, photoID)
previewKey := fmt.Sprintf("albums/%s/preview/%s.webp", albumID, photoID)
if err := s.storage.Put(ctx, thumbKey, bytes.NewReader(thumb), int64(len(thumb)), "image/webp"); err != nil {
return
}
if err := s.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/webp"); err != nil {
return
}
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
return
}
s.store.SetCover(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)
}
}
}
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
if p.StorageKey != "" {
url, err := s.storage.PresignGet(ctx, p.StorageKey)
if err != nil {
return err
}
p.OriginalURL = url
p.DownloadURL = url
}
if p.ThumbKey != "" {
if url, err := s.storage.PresignGet(ctx, p.ThumbKey); err == nil {
p.ThumbURL = url
}
}
if p.PreviewKey != "" {
if url, err := s.storage.PresignGet(ctx, p.PreviewKey); err == nil {
p.PreviewURL = url
}
}
return nil
}
func (s *Service) List(ctx context.Context, albumID, userID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
if _, _, err := s.albums.Access(ctx, albumID, userID); err != nil {
return nil, "", err
}
out, err := s.store.List(ctx, ListParams{
AlbumID: albumID,
Viewer: userID,
Cursor: cursor,
Since: since,
Limit: limit,
})
if err != nil {
return nil, "", err
}
for i := range out {
_ = s.attachURLs(ctx, &out[i])
}
next := ""
if since == nil && len(out) == limit && limit > 0 {
next = out[len(out)-1].ID
}
return out, next, nil
}
// ListPublic lists photos for a share link (access already validated by token).
func (s *Service) ListPublic(ctx context.Context, albumID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
out, err := s.store.List(ctx, ListParams{
AlbumID: albumID,
Viewer: "",
Cursor: cursor,
Since: since,
Limit: limit,
})
if err != nil {
return nil, "", err
}
for i := range out {
_ = s.attachURLs(ctx, &out[i])
}
next := ""
if since == nil && len(out) == limit && limit > 0 {
next = out[len(out)-1].ID
}
return out, next, nil
}
// GetPublic returns a single photo for a share link.
func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, "")
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// DownloadPublic returns a presigned original URL for a share link.
func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) {
p, err := s.GetPublic(ctx, photoID)
if err != nil {
return "", err
}
return s.storage.PresignGet(ctx, p.StorageKey)
}
func (s *Service) Get(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 _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *Service) Download(ctx context.Context, photoID, userID string) (string, error) {
p, err := s.Get(ctx, photoID, userID)
if err != nil {
return "", err
}
url, err := s.storage.PresignGet(ctx, p.StorageKey)
if err != nil {
return "", err
}
return url, nil
}
func (s *Service) Delete(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
}
own := p.UploaderID == userID
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
return httpx.Forbidden("forbidden", "you cannot delete this photo")
}
if err := s.store.Delete(ctx, photoID); 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.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
})
return nil
}
+127
View File
@@ -0,0 +1,127 @@
package photos
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("photo not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
const photoColumns = `p.id, 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)`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &p, nil
}
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
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)
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,
).Scan(&p.CreatedAt)
}
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
_, err := s.pool.Exec(ctx,
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
return err
}
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
return scanPhoto(s.pool.QueryRow(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.id = $1`, id, viewerID))
}
type ListParams struct {
AlbumID string
Viewer string
Cursor string
Since *time.Time
Limit int
}
func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
if in.Limit <= 0 || in.Limit > 100 {
in.Limit = 50
}
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.album_id = $1
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
LIMIT $5`,
in.AlbumID, in.Viewer, in.Cursor, in.Since, in.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
var p models.Photo
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 {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) Delete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
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)
}
+224
View File
@@ -0,0 +1,224 @@
package repo_test
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/models"
"shared-album/internal/sharelinks"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
return pool
}
func newUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) *models.User {
t.Helper()
u := &models.User{
ID: auth.NewID(),
Email: auth.NewID()[:8] + "@test.local",
PasswordHash: "x",
Name: name,
}
if err := auth.NewStore(pool).CreateUser(ctx, u); err != nil {
t.Fatalf("create user: %v", err)
}
return u
}
func TestUserRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
store := auth.NewStore(pool)
u := newUser(t, ctx, pool, "Tester")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID) })
got, err := store.GetUserByEmail(ctx, u.Email)
if err != nil || got.ID != u.ID {
t.Fatalf("get by email: %v id=%v", err, got)
}
if _, err := store.GetUserByID(ctx, u.ID); err != nil {
t.Fatalf("get by id: %v", err)
}
tok := &models.RefreshToken{ID: auth.NewID(), UserID: u.ID, TokenHash: auth.HashRefreshToken("abc"), ExpiresAt: time.Now().Add(time.Hour)}
if err := store.CreateRefreshToken(ctx, tok); err != nil {
t.Fatalf("create refresh: %v", err)
}
fetched, err := store.GetRefreshToken(ctx, auth.HashRefreshToken("abc"))
if err != nil || fetched.ID != tok.ID {
t.Fatalf("get refresh: %v", err)
}
if err := store.RevokeRefreshToken(ctx, tok.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
fetched, _ = store.GetRefreshToken(ctx, auth.HashRefreshToken("abc"))
if fetched.RevokedAt == nil {
t.Fatalf("refresh not revoked")
}
}
func TestAlbumRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
store := albums.NewStore(pool)
owner := newUser(t, ctx, pool, "Owner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Trip", Description: "d"}
if err := store.Create(ctx, a); err != nil {
t.Fatalf("create: %v", err)
}
got, err := store.GetByID(ctx, a.ID)
if err != nil || got.Title != "Trip" {
t.Fatalf("get: %v", err)
}
if ownerID, err := store.OwnerID(ctx, a.ID); err != nil || ownerID != owner.ID {
t.Fatalf("owner id: %v %v", ownerID, err)
}
list, err := store.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
a.Title = "Renamed"
if err := store.Update(ctx, a); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = store.GetByID(ctx, a.ID)
if got.Title != "Renamed" {
t.Fatalf("update not applied")
}
if err := store.Delete(ctx, a.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := store.GetByID(ctx, a.ID); err != albums.ErrNotFound {
t.Fatalf("expected not found after delete, got %v", err)
}
}
func TestMemberRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
member := newUser(t, ctx, pool, "Member")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id IN ($1, $2)`, owner.ID, member.ID)
})
albumStore := albums.NewStore(pool)
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
if err := albumStore.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
store := members.NewStore(pool)
if err := store.Add(ctx, a.ID, member.ID, "member", &owner.ID); err != nil {
t.Fatalf("add: %v", err)
}
role, err := store.Role(ctx, a.ID, member.ID)
if err != nil || role != "member" {
t.Fatalf("role: %v %q", err, role)
}
list, err := store.List(ctx, a.ID)
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
t.Fatalf("list members: %v %+v", err, list)
}
if err := store.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
t.Fatalf("update role: %v", err)
}
role, _ = store.Role(ctx, a.ID, member.ID)
if role != "editor" {
t.Fatalf("role not updated: %q", role)
}
inv := &models.Invite{ID: auth.NewID(), AlbumID: a.ID, Token: auth.NewID(), Role: "viewer", CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour), MaxUses: 2}
if err := store.CreateInvite(ctx, inv); err != nil {
t.Fatalf("create invite: %v", err)
}
got, err := store.GetInviteByToken(ctx, inv.Token)
if err != nil || got.Role != "viewer" || got.MaxUses != 2 {
t.Fatalf("get invite: %v %+v", err, got)
}
if err := store.IncrementUses(ctx, inv.ID); err != nil {
t.Fatalf("increment: %v", err)
}
got, _ = store.GetInviteByToken(ctx, inv.Token)
if got.Uses != 1 {
t.Fatalf("uses not incremented: %d", got.Uses)
}
if err := store.Remove(ctx, a.ID, member.ID); err != nil {
t.Fatalf("remove: %v", err)
}
role, _ = store.Role(ctx, a.ID, member.ID)
if role != "" {
t.Fatalf("member not removed")
}
}
func TestShareLinkRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
albumsStore := albums.NewStore(pool)
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
if err := albumsStore.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
store := sharelinks.NewStore(pool)
l := &models.ShareLink{ID: auth.NewID(), AlbumID: a.ID, Token: auth.NewID(), CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour)}
if err := store.Create(ctx, l); err != nil {
t.Fatalf("create: %v", err)
}
got, err := store.GetByToken(ctx, l.Token)
if err != nil || !got.Active(time.Now()) {
t.Fatalf("get token: %v", err)
}
links, err := store.ListByAlbum(ctx, a.ID)
if err != nil || len(links) != 1 {
t.Fatalf("list: %v len=%d", err, len(links))
}
if err := store.Revoke(ctx, l.ID, a.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
got, _ = store.GetByToken(ctx, l.Token)
if got.Active(time.Now()) {
t.Fatalf("link still active after revoke")
}
}
+98
View File
@@ -0,0 +1,98 @@
package roles
const (
Owner = "owner"
Admin = "admin"
Editor = "editor"
Member = "member"
Viewer = "viewer"
)
var rank = map[string]int{
Viewer: 0,
Member: 1,
Editor: 2,
Admin: 3,
Owner: 4,
}
func Rank(r string) int { return rank[r] }
func ValidMemberRole(r string) bool {
switch r {
case Admin, Editor, Member, Viewer:
return true
}
return false
}
func CanUpload(r string) bool {
switch r {
case Owner, Admin, Editor, Member:
return true
}
return false
}
func CanComment(r string) bool {
return CanUpload(r)
}
func CanDeleteAnyPhoto(r string) bool {
return r == Owner || r == Admin
}
func CanDeleteOwnPhoto(r string) bool {
return CanUpload(r)
}
func CanEditAlbum(r string) bool {
return r == Owner || r == Admin
}
func CanManageShares(r string) bool {
return r == Owner || r == Admin
}
func CanInvite(actorRole, targetRole string) bool {
if actorRole == Editor {
return targetRole == Member || targetRole == Viewer
}
if actorRole != Owner && actorRole != Admin {
return false
}
if targetRole == Owner {
return false
}
return Rank(targetRole) <= Rank(actorRole)
}
func CanChangeRole(actorRole, targetCurrent, newRole string) bool {
if targetCurrent == Owner || newRole == Owner {
return false
}
if !ValidMemberRole(newRole) {
return false
}
switch actorRole {
case Owner:
return true
case Admin:
if targetCurrent == Admin {
return false
}
return newRole == Editor || newRole == Member || newRole == Viewer
default:
return false
}
}
func CanRemoveMember(actorRole, targetRole string) bool {
if actorRole != Owner && actorRole != Admin {
return false
}
if targetRole == Owner || targetRole == Admin {
return false
}
return true
}
+58
View File
@@ -0,0 +1,58 @@
package sharelinks
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/httpx"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
type createRequest struct {
ExpiresIn string `json:"expires_in"`
}
func (h *Handler) Create(c *gin.Context) {
var req createRequest
_ = c.ShouldBindJSON(&req)
var ttl time.Duration
if req.ExpiresIn != "" {
d, err := time.ParseDuration(req.ExpiresIn)
if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_expires_in", "invalid expires_in duration"))
return
}
ttl = d
}
link, err := h.svc.Create(c.Request.Context(), c.Param("id"), auth.UserID(c), ttl)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"share_link": link})
}
func (h *Handler) List(c *gin.Context) {
list, err := h.svc.List(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"share_links": list})
}
func (h *Handler) Revoke(c *gin.Context) {
if err := h.svc.Revoke(c.Request.Context(), c.Param("id"), c.Param("link_id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+95
View File
@@ -0,0 +1,95 @@
package sharelinks
import (
"context"
"errors"
"time"
"shared-album/internal/auth"
"shared-album/internal/httpx"
"shared-album/internal/models"
"shared-album/internal/roles"
)
type AlbumAccess interface {
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
}
type Service struct {
store *Store
albums AlbumAccess
}
func NewService(store *Store, albums AlbumAccess) *Service {
return &Service{store: store, albums: albums}
}
func (s *Service) Create(ctx context.Context, albumID, actorID string, ttl time.Duration) (*models.ShareLink, error) {
_, role, err := s.albums.Access(ctx, albumID, actorID)
if err != nil {
return nil, err
}
if !roles.CanManageShares(role) {
return nil, httpx.Forbidden("forbidden", "only owner and admin can manage share links")
}
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
token, _, err := auth.NewRefreshToken()
if err != nil {
return nil, err
}
l := &models.ShareLink{
ID: auth.NewID(),
AlbumID: albumID,
Token: token,
CreatedBy: actorID,
ExpiresAt: time.Now().Add(ttl),
}
if err := s.store.Create(ctx, l); err != nil {
return nil, err
}
return l, nil
}
func (s *Service) List(ctx context.Context, albumID, actorID string) ([]models.ShareLink, error) {
_, role, err := s.albums.Access(ctx, albumID, actorID)
if err != nil {
return nil, err
}
if !roles.CanManageShares(role) {
return nil, httpx.Forbidden("forbidden", "only owner and admin can manage share links")
}
return s.store.ListByAlbum(ctx, albumID)
}
func (s *Service) Revoke(ctx context.Context, albumID, linkID, actorID string) error {
_, role, err := s.albums.Access(ctx, albumID, actorID)
if err != nil {
return err
}
if !roles.CanManageShares(role) {
return httpx.Forbidden("forbidden", "only owner and admin can manage share links")
}
if err := s.store.Revoke(ctx, linkID, albumID); err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("share_link_not_found", "share link not found")
}
return err
}
return nil
}
func (s *Service) Resolve(ctx context.Context, token string) (*models.ShareLink, error) {
l, err := s.store.GetByToken(ctx, token)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("share_link_not_found", "share link not found")
}
return nil, err
}
if !l.Active(time.Now()) {
return nil, httpx.Forbidden("share_link_inactive", "share link is expired or revoked")
}
return l, nil
}
+76
View File
@@ -0,0 +1,76 @@
package sharelinks
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Create(ctx context.Context, l *models.ShareLink) error {
return s.pool.QueryRow(ctx,
`INSERT INTO share_links (id, album_id, token, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at`,
l.ID, l.AlbumID, l.Token, l.CreatedBy, l.ExpiresAt,
).Scan(&l.CreatedAt)
}
func (s *Store) ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE album_id = $1 ORDER BY created_at DESC`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.ShareLink{}
for rows.Next() {
var l models.ShareLink
if err := rows.Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &l.CreatedAt, &l.ExpiresAt, &l.RevokedAt); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (s *Store) GetByToken(ctx context.Context, token string) (*models.ShareLink, error) {
var l models.ShareLink
err := s.pool.QueryRow(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE token = $1`, token).
Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &l.CreatedAt, &l.ExpiresAt, &l.RevokedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &l, nil
}
func (s *Store) Revoke(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE share_links SET revoked_at = now() WHERE id = $1 AND album_id = $2 AND revoked_at IS NULL`,
id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
package social
import (
"net/http"
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/httpx"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
func (h *Handler) AddLike(c *gin.Context) {
if err := h.svc.AddLike(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) RemoveLike(c *gin.Context) {
if err := h.svc.RemoveLike(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) ListComments(c *gin.Context) {
list, err := h.svc.ListComments(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"comments": list})
}
type commentRequest struct {
Text string `json:"text" binding:"required"`
}
func (h *Handler) AddComment(c *gin.Context) {
var req commentRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "text is required"))
return
}
comment, err := h.svc.AddComment(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Text)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"comment": comment})
}
func (h *Handler) DeleteComment(c *gin.Context) {
if err := h.svc.DeleteComment(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+171
View File
@@ -0,0 +1,171 @@
package social
import (
"context"
"errors"
"strings"
"shared-album/internal/auth"
"shared-album/internal/httpx"
"shared-album/internal/models"
"shared-album/internal/roles"
"shared-album/internal/sse"
)
type AlbumAccess interface {
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
}
type PhotoLookup interface {
GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error)
}
type Notifier interface {
Create(ctx context.Context, userID, typ string, payload map[string]any) error
}
type Service struct {
store *Store
albums AlbumAccess
photos PhotoLookup
hub *sse.Hub
notify Notifier
}
func NewService(store *Store, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, photos: photos, hub: hub, notify: notify}
}
func (s *Service) requireComment(ctx context.Context, photo *models.Photo, userID string) (string, error) {
_, role, err := s.albums.Access(ctx, photo.AlbumID, userID)
if err != nil {
return "", err
}
if !roles.CanComment(role) {
return "", httpx.Forbidden("forbidden", "you do not have permission to interact with photos")
}
return role, nil
}
func (s *Service) AddLike(ctx context.Context, photoID, userID string) error {
photo, err := s.photos.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("photo_not_found", "photo not found")
}
return err
}
if _, err := s.requireComment(ctx, photo, userID); err != nil {
return err
}
if err := s.store.Like(ctx, photoID, userID); err != nil {
return err
}
s.hub.Publish(photo.AlbumID, "like.added", map[string]string{
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID,
})
if s.notify != nil && photo.UploaderID != userID {
_ = s.notify.Create(ctx, photo.UploaderID, "like.added", map[string]any{
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID,
})
}
return nil
}
func (s *Service) RemoveLike(ctx context.Context, photoID, userID string) error {
photo, err := s.photos.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("photo_not_found", "photo not found")
}
return err
}
if _, err := s.requireComment(ctx, photo, userID); err != nil {
return err
}
if err := s.store.Unlike(ctx, photoID, userID); err != nil {
return err
}
s.hub.Publish(photo.AlbumID, "like.removed", map[string]string{
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID,
})
return nil
}
func (s *Service) AddComment(ctx context.Context, photoID, userID, text string) (*models.Comment, error) {
text = strings.TrimSpace(text)
if text == "" {
return nil, httpx.BadRequest("invalid_text", "comment text is required")
}
photo, err := s.photos.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 _, err := s.requireComment(ctx, photo, userID); err != nil {
return nil, err
}
c := &models.Comment{
ID: auth.NewID(),
PhotoID: photoID,
UserID: userID,
Text: text,
}
if err := s.store.CreateComment(ctx, c); err != nil {
return nil, err
}
list, err := s.store.ListComments(ctx, photoID)
if err == nil {
for _, item := range list {
if item.ID == c.ID {
c.User = item.User
break
}
}
}
s.hub.Publish(photo.AlbumID, "comment.created", c)
if s.notify != nil && photo.UploaderID != userID {
_ = s.notify.Create(ctx, photo.UploaderID, "comment.created", map[string]any{
"photo_id": photoID, "album_id": photo.AlbumID, "user_id": userID, "comment_id": c.ID,
})
}
return c, nil
}
func (s *Service) ListComments(ctx context.Context, photoID, userID string) ([]models.Comment, error) {
photo, err := s.photos.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 _, _, err := s.albums.Access(ctx, photo.AlbumID, userID); err != nil {
return nil, err
}
return s.store.ListComments(ctx, photoID)
}
func (s *Service) DeleteComment(ctx context.Context, commentID, userID string) error {
c, err := s.store.GetComment(ctx, commentID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("comment_not_found", "comment not found")
}
return err
}
photo, err := s.photos.GetByID(ctx, c.PhotoID, userID)
if err != nil {
return err
}
_, role, err := s.albums.Access(ctx, photo.AlbumID, userID)
if err != nil {
return err
}
if c.UserID != userID && !roles.CanDeleteAnyPhoto(role) {
return httpx.Forbidden("forbidden", "you cannot delete this comment")
}
return s.store.DeleteComment(ctx, commentID)
}
+91
View File
@@ -0,0 +1,91 @@
package social
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Like(ctx context.Context, photoID, userID string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO likes (photo_id, user_id) VALUES ($1, $2)
ON CONFLICT (photo_id, user_id) DO NOTHING`, photoID, userID)
return err
}
func (s *Store) Unlike(ctx context.Context, photoID, userID string) error {
_, err := s.pool.Exec(ctx,
`DELETE FROM likes WHERE photo_id = $1 AND user_id = $2`, photoID, userID)
return err
}
func (s *Store) CreateComment(ctx context.Context, c *models.Comment) error {
return s.pool.QueryRow(ctx,
`INSERT INTO comments (id, photo_id, user_id, text)
VALUES ($1, $2, $3, $4)
RETURNING created_at, updated_at`,
c.ID, c.PhotoID, c.UserID, c.Text,
).Scan(&c.CreatedAt, &c.UpdatedAt)
}
func (s *Store) ListComments(ctx context.Context, photoID string) ([]models.Comment, error) {
rows, err := s.pool.Query(ctx,
`SELECT c.id, c.photo_id, c.user_id, c.text, c.created_at, c.updated_at,
u.id, u.name, COALESCE(u.avatar_url, '')
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.photo_id = $1
ORDER BY c.created_at`, photoID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Comment{}
for rows.Next() {
var c models.Comment
if err := rows.Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &c.CreatedAt, &c.UpdatedAt,
&c.User.ID, &c.User.Name, &c.User.AvatarURL); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) GetComment(ctx context.Context, id string) (*models.Comment, error) {
var c models.Comment
err := s.pool.QueryRow(ctx,
`SELECT id, photo_id, user_id, text, created_at, updated_at FROM comments WHERE id = $1`, id).
Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &c, nil
}
func (s *Store) DeleteComment(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM comments WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
+139
View File
@@ -0,0 +1,139 @@
package sse
import (
"sync"
)
type Event struct {
Type string
Data any
}
type subscriber struct {
ch chan Event
closed bool
}
type Subscription struct {
hub *Hub
key string
personal bool
sub *subscriber
once sync.Once
}
func (s *Subscription) Events() <-chan Event { return s.sub.ch }
func (s *Subscription) Close() {
s.once.Do(func() {
if s.personal {
s.hub.unsubscribeUser(s.key, s.sub)
} else {
s.hub.unsubscribeAlbum(s.key, s.sub)
}
})
}
type Hub struct {
mu sync.RWMutex
albums map[string]map[*subscriber]struct{}
users map[string]map[*subscriber]struct{}
buffer int
}
func NewHub(buffer int) *Hub {
if buffer <= 0 {
buffer = 64
}
return &Hub{
albums: make(map[string]map[*subscriber]struct{}),
users: make(map[string]map[*subscriber]struct{}),
buffer: buffer,
}
}
func (h *Hub) SubscribeAlbum(albumID string) *Subscription {
sub := &subscriber{ch: make(chan Event, h.buffer)}
h.mu.Lock()
if h.albums[albumID] == nil {
h.albums[albumID] = make(map[*subscriber]struct{})
}
h.albums[albumID][sub] = struct{}{}
h.mu.Unlock()
return &Subscription{hub: h, key: albumID, sub: sub}
}
func (h *Hub) SubscribeUser(userID string) *Subscription {
sub := &subscriber{ch: make(chan Event, h.buffer)}
h.mu.Lock()
if h.users[userID] == nil {
h.users[userID] = make(map[*subscriber]struct{})
}
h.users[userID][sub] = struct{}{}
h.mu.Unlock()
return &Subscription{hub: h, key: userID, personal: true, sub: sub}
}
func (h *Hub) Publish(albumID, eventType string, data any) {
if albumID == "" {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
h.broadcast(h.albums[albumID], Event{Type: eventType, Data: data})
}
func (h *Hub) PublishUser(userID, eventType string, data any) {
if userID == "" {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
h.broadcast(h.users[userID], Event{Type: eventType, Data: data})
}
func (h *Hub) broadcast(subs map[*subscriber]struct{}, ev Event) {
for sub := range subs {
if sub.closed {
continue
}
select {
case sub.ch <- ev:
default:
// slow client: drop it (backpressure)
sub.closed = true
close(sub.ch)
}
}
}
func (h *Hub) unsubscribeAlbum(albumID string, sub *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
if subs, ok := h.albums[albumID]; ok {
delete(subs, sub)
if len(subs) == 0 {
delete(h.albums, albumID)
}
}
closeIfOpen(sub)
}
func (h *Hub) unsubscribeUser(userID string, sub *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
if subs, ok := h.users[userID]; ok {
delete(subs, sub)
if len(subs) == 0 {
delete(h.users, userID)
}
}
closeIfOpen(sub)
}
func closeIfOpen(sub *subscriber) {
if !sub.closed {
sub.closed = true
close(sub.ch)
}
}
+53
View File
@@ -0,0 +1,53 @@
package sse
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func Serve(c *gin.Context, sub *Subscription, heartbeat time.Duration) {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.WriteHeader(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
fmt.Fprint(c.Writer, ": connected\n\n")
flusher.Flush()
if heartbeat <= 0 {
heartbeat = 25 * time.Second
}
ticker := time.NewTicker(heartbeat)
defer ticker.Stop()
ctx := c.Request.Context()
for {
select {
case <-ctx.Done():
return
case ev, ok := <-sub.Events():
if !ok {
return
}
payload, err := json.Marshal(ev.Data)
if err != nil {
continue
}
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", ev.Type, payload)
flusher.Flush()
case <-ticker.C:
fmt.Fprint(c.Writer, ": heartbeat\n\n")
flusher.Flush()
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package storage
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"shared-album/internal/config"
)
type Client struct {
s3 *s3.Client
presign *s3.PresignClient
bucket string
presignTTL time.Duration
}
func New(ctx context.Context, cfg config.S3) (*Client, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKey, cfg.SecretKey, "",
)),
)
if err != nil {
return nil, fmt.Errorf("load aws config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = cfg.UsePathStyle
})
return &Client{
s3: client,
presign: s3.NewPresignClient(client),
bucket: cfg.Bucket,
presignTTL: cfg.PresignTTL,
}, nil
}
func (c *Client) Bucket() string { return c.bucket }
func (c *Client) EnsureBucket(ctx context.Context) error {
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
if err == nil {
return nil
}
var nf *types.NotFound
if !errors.As(err, &nf) && !isNoSuchBucket(err) {
return fmt.Errorf("head bucket: %w", err)
}
if _, err := c.s3.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(c.bucket)}); err != nil {
return fmt.Errorf("create bucket: %w", err)
}
return nil
}
func (c *Client) Ping(ctx context.Context) error {
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
return err
}
func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
in := &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
}
if size >= 0 {
in.ContentLength = aws.Int64(size)
}
if _, err := c.s3.PutObject(ctx, in); err != nil {
return fmt.Errorf("put object %s: %w", key, err)
}
return nil
}
func (c *Client) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
objs := make([]types.ObjectIdentifier, 0, len(keys))
for _, k := range keys {
objs = append(objs, types.ObjectIdentifier{Key: aws.String(k)})
}
if _, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(c.bucket),
Delete: &types.Delete{Objects: objs, Quiet: aws.Bool(true)},
}); err != nil {
return fmt.Errorf("delete objects: %w", err)
}
return nil
}
func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
var keys []string
paginator := s3.NewListObjectsV2Paginator(c.s3, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return fmt.Errorf("list objects: %w", err)
}
for _, o := range page.Contents {
keys = append(keys, aws.ToString(o.Key))
}
}
return c.Delete(ctx, keys...)
}
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))
if err != nil {
return "", fmt.Errorf("presign %s: %w", key, err)
}
return out.URL, nil
}
func isNoSuchBucket(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
code := apiErr.ErrorCode()
return strings.EqualFold(code, "NoSuchBucket") || strings.EqualFold(code, "NotFound")
}
return false
}
+10
View File
@@ -0,0 +1,10 @@
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS share_links;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS likes;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS photos;
DROP TABLE IF EXISTS invites;
DROP TABLE IF EXISTS album_members;
DROP TABLE IF EXISTS albums;
DROP TABLE IF EXISTS users;
+110
View File
@@ -0,0 +1,110 @@
CREATE TABLE IF NOT EXISTS users (
id text PRIMARY KEY,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
name text NOT NULL,
avatar_url text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS albums (
id text PRIMARY KEY,
owner_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
cover_photo_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS album_members (
album_id text NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL,
invited_by text REFERENCES users(id) ON DELETE SET NULL,
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (album_id, user_id)
);
CREATE TABLE IF NOT EXISTS invites (
id text PRIMARY KEY,
album_id text NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token text NOT NULL UNIQUE,
role text NOT NULL,
created_by text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
max_uses integer NOT NULL DEFAULT 1,
uses integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS photos (
id text PRIMARY KEY,
album_id text NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
uploader_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
storage_key text NOT NULL,
thumb_key text,
preview_key text,
original_filename text NOT NULL DEFAULT '',
size bigint NOT NULL DEFAULT 0,
width integer NOT NULL DEFAULT 0,
height integer NOT NULL DEFAULT 0,
mime_type text NOT NULL DEFAULT '',
taken_at timestamptz,
latitude double precision,
longitude double precision,
caption text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS photos_album_created_idx ON photos (album_id, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS comments (
id text PRIMARY KEY,
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS comments_photo_idx ON comments (photo_id, created_at);
CREATE TABLE IF NOT EXISTS likes (
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (photo_id, user_id)
);
CREATE TABLE IF NOT EXISTS notifications (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type text NOT NULL,
payload_json jsonb NOT NULL DEFAULT '{}'::jsonb,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS notifications_user_idx ON notifications (user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS share_links (
id text PRIMARY KEY,
album_id text NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token text NOT NULL UNIQUE,
created_by text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens (user_id);
View File