This commit is contained in:
2026-08-22 02:59:16 +02:00
commit 6a5bb1d699
100 changed files with 17409 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/example/sndit/backend/internal/config"
"github.com/example/sndit/backend/internal/db"
)
func main() {
directory := flag.String("dir", "migrations", "directory containing SQL migrations")
flag.Parse()
cfg := config.Load()
ctx := context.Background()
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
if err != nil {
log.Fatalf("database unavailable: %v", err)
}
defer database.Close()
if err := run(ctx, database, *directory, cfg.DBDriver); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context, database *sql.DB, directory, driver string) error {
if _, err := database.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return fmt.Errorf("create migration table: %w", err)
}
directory = migrationDirectory(directory, driver)
files, err := migrationFiles(directory)
if err != nil {
return err
}
for _, path := range files {
version := filepath.Base(path)
var applied bool
if err := database.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", version, err)
}
if applied {
continue
}
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", version, err)
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", version, err)
}
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", version, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %s: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", version, err)
}
log.Printf("applied migration %s", version)
}
return nil
}
func migrationDirectory(directory, driver string) string {
if strings.EqualFold(driver, "sqlite") || strings.EqualFold(driver, "sqlite3") {
return filepath.Join(directory, "sqlite")
}
return directory
}
func migrationFiles(directory string) ([]string, error) {
entries, err := os.ReadDir(directory)
if err != nil {
return nil, fmt.Errorf("read migration directory: %w", err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
files = append(files, filepath.Join(directory, entry.Name()))
}
}
sort.Strings(files)
return files, nil
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"context"
"database/sql"
"fmt"
"log"
"github.com/example/sndit/backend/internal/config"
"github.com/example/sndit/backend/internal/db"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
demoEmail = "demo@example.com"
demoPassword = "DemoPassword123!"
)
func main() {
cfg := config.Load()
ctx := context.Background()
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
if err != nil {
log.Fatalf("database unavailable: %v", err)
}
defer database.Close()
if err := seed(ctx, database); err != nil {
log.Fatal(err)
}
log.Printf("seeded %s with the Northline Studio demo gallery", demoEmail)
}
func seed(ctx context.Context, database *sql.DB) error {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(demoPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash demo password: %w", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
`, demoUserID, demoEmail, string(passwordHash), "Northline Studio"); err != nil {
return fmt.Errorf("seed demo user: %w", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO galleries (
id, user_id, slug, title, client_name, description, status,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
cover_media_id, theme_config, branding_config, published_at
)
VALUES ($1, $2, $3, $4, $5, $6, 'published', TRUE, TRUE, TRUE, FALSE, $7, $8, $9, CURRENT_TIMESTAMP)
ON CONFLICT (slug) DO UPDATE SET
user_id = EXCLUDED.user_id, title = EXCLUDED.title, client_name = EXCLUDED.client_name,
description = EXCLUDED.description, status = EXCLUDED.status,
downloads_enabled = EXCLUDED.downloads_enabled, favorites_enabled = EXCLUDED.favorites_enabled,
download_all_enabled = EXCLUDED.download_all_enabled, watermark_enabled = EXCLUDED.watermark_enabled,
cover_media_id = EXCLUDED.cover_media_id, theme_config = EXCLUDED.theme_config,
branding_config = EXCLUDED.branding_config, published_at = EXCLUDED.published_at,
updated_at = CURRENT_TIMESTAMP
`, demoGalleryID, demoUserID, "emma-james-wedding", "Emma & James", "Emma & James", "An early summer wedding, held close to the water and the people who make it home.", demoCoverID,
`{"mode":"light","layout":"editorial","accent":"#ad695b","font":"serif"}`,
`{"studioName":"Northline Studio","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil {
return fmt.Errorf("seed demo gallery: %w", err)
}
mediaRows := []struct {
id, filename, mime, externalURL string
sortOrder int
duration float64
}{
{demoCoverID.String(), "emma-james-01.jpg", "image/jpeg", "https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1800&q=88", 1, 0},
{demoPhotoID.String(), "emma-james-02.jpg", "image/jpeg", "https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=1800&q=88", 2, 0},
{demoVideoID.String(), "emma-james-film.mp4", "video/mp4", "https://storage.googleapis.com/coverr-main/mp4/Mt_Baker.mp4", 3, 31.0},
}
for _, item := range mediaRows {
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, external_url, processing_status, sort_order, duration_seconds)
VALUES ($1, $2, $3, $4, 0, $5, $6, 'READY', $7, $8)
ON CONFLICT (id) DO UPDATE SET
gallery_id = EXCLUDED.gallery_id, original_filename = EXCLUDED.original_filename,
mime_type = EXCLUDED.mime_type, storage_key = EXCLUDED.storage_key,
external_url = EXCLUDED.external_url, processing_status = EXCLUDED.processing_status,
sort_order = EXCLUDED.sort_order, duration_seconds = EXCLUDED.duration_seconds, updated_at = CURRENT_TIMESTAMP
`, uuid.MustParse(item.id), demoGalleryID, item.filename, item.mime, "demo/"+item.filename, item.externalURL, item.sortOrder, item.duration); err != nil {
return fmt.Errorf("seed demo media %s: %w", item.filename, err)
}
}
return nil
}
var (
demoUserID = uuid.MustParse("55555555-5555-4555-8555-555555555555")
demoGalleryID = uuid.MustParse("66666666-6666-4666-8666-666666666666")
demoCoverID = uuid.MustParse("77777777-7777-4777-8777-777777777777")
demoPhotoID = uuid.MustParse("88888888-8888-4888-8888-888888888888")
demoVideoID = uuid.MustParse("99999999-9999-4999-8999-999999999999")
)
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/config"
"github.com/example/sndit/backend/internal/db"
devtools "github.com/example/sndit/backend/internal/dev"
"github.com/example/sndit/backend/internal/downloads"
"github.com/example/sndit/backend/internal/galleries"
"github.com/example/sndit/backend/internal/gifts"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
)
func main() {
cfg := config.Load()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
if err != nil {
log.Fatalf("database unavailable: %v", err)
}
defer database.Close()
objectStorage, err := storage.NewMinIO(storage.Config{
Endpoint: cfg.StorageEndpoint,
AccessKey: cfg.StorageAccessKey,
SecretKey: cfg.StorageSecretKey,
Bucket: cfg.StorageBucket,
UseSSL: cfg.StorageUseSSL,
CORSOrigins: cfg.CORSOrigin,
})
if err != nil {
log.Fatalf("storage unavailable: %v", err)
}
if err := objectStorage.EnsureBucket(ctx); err != nil {
log.Fatalf("storage unavailable: %v", err)
}
authRepository := auth.NewRepository(database)
authService, err := auth.NewService(authRepository, cfg.SessionSecret, cfg.CookieSecure)
if err != nil {
log.Fatalf("authentication unavailable: %v", err)
}
galleryRepository := galleries.NewRepository(database)
mediaRepository := media.NewRepository(database)
mediaProcessor := media.NewProcessor(mediaRepository, objectStorage, 2)
defer mediaProcessor.Close()
downloadRepository := downloads.NewRepository(database)
downloadService := downloads.NewService(downloadRepository, mediaRepository, objectStorage, 1)
defer downloadService.Close()
authHandler := auth.NewHandler(authService)
galleryHandler := galleries.NewHandler(galleryRepository, mediaRepository, objectStorage, authService)
mediaHandler := media.NewHandler(mediaRepository, objectStorage, mediaProcessor, authService)
downloadHandler := downloads.NewHandler(galleryRepository, mediaRepository, objectStorage, authService, downloadService)
devHandler := devtools.NewHandler(database, objectStorage, authService, devtools.Config{
DBDriver: cfg.DBDriver,
StorageEndpoint: cfg.StorageEndpoint,
StorageBucket: cfg.StorageBucket,
StorageUseSSL: cfg.StorageUseSSL,
CORSOrigins: cfg.CORSOrigin,
CookieSecure: cfg.CookieSecure,
})
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
authHandler.RegisterRoutes(mux)
galleryHandler.RegisterProtectedRoutes(mux, authService.Require)
mediaHandler.RegisterRoutes(mux, authService.Require)
galleryHandler.RegisterPublicRoutes(mux)
downloadHandler.RegisterRoutes(mux)
devHandler.RegisterRoutes(mux, authService.Require)
// Keep the original public gift endpoint available while the gallery product
// uses /api/public/galleries/:slug.
legacyGifts := gifts.NewHandler(gifts.NewService(gifts.NewRepository(database)))
mux.Handle("/api/gifts/", legacyGifts.Routes())
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: withCORS(withLogging(mux), cfg.CORSOrigin),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("API listening on http://localhost%s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("server shutdown failed: %v", err)
}
}
func health(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}` + "\n"))
}
func withCORS(next http.Handler, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, allowedOrigin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func originAllowed(origin, configured string) bool {
for _, allowed := range strings.Split(configured, ",") {
if strings.TrimSpace(allowed) == "*" || strings.TrimSpace(allowed) == origin {
return true
}
}
return false
}
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started).Round(time.Millisecond))
})
}
+39
View File
@@ -0,0 +1,39 @@
module github.com/example/sndit/backend
go 1.24.0
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
github.com/minio/minio-go/v7 v7.0.95
golang.org/x/crypto v0.39.0
modernc.org/sqlite v1.39.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // 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/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.26.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+100
View File
@@ -0,0 +1,100 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
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=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+93
View File
@@ -0,0 +1,93 @@
package auth
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/auth/register", h.Register)
mux.HandleFunc("POST /api/auth/login", h.Login)
mux.HandleFunc("POST /api/auth/logout", h.Logout)
mux.HandleFunc("GET /api/auth/me", h.Me)
}
type credentialsRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var request credentialsRequest
if !decodeJSON(w, r, &request) {
return
}
user, err := h.service.Register(r.Context(), request.Email, request.Password, request.Name)
if err != nil {
if errors.Is(err, ErrEmailTaken) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "email is already registered"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.service.SetSession(w, user)
writeJSON(w, http.StatusCreated, map[string]User{"user": user})
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var request credentialsRequest
if !decodeJSON(w, r, &request) {
return
}
user, err := h.service.Login(r.Context(), request.Email, request.Password)
if err != nil {
if errors.Is(err, ErrInvalidCredentials) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not sign in"})
return
}
h.service.SetSession(w, user)
writeJSON(w, http.StatusOK, map[string]User{"user": user})
}
func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) {
h.service.ClearSession(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
user, err := h.service.UserFromRequest(r.Context(), r)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
writeJSON(w, http.StatusOK, map[string]User{"user": user})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"})
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return false
}
return true
}
+14
View File
@@ -0,0 +1,14 @@
package auth
import "github.com/google/uuid"
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
type storedUser struct {
User
PasswordHash string
}
+68
View File
@@ -0,0 +1,68 @@
package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrEmailTaken = errors.New("email already registered")
func (r *Repository) CreateUser(ctx context.Context, email, passwordHash, name string) (User, error) {
user := User{ID: uuid.New(), Email: strings.ToLower(strings.TrimSpace(email)), Name: strings.TrimSpace(name)}
_, err := r.db.ExecContext(ctx, `
INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4)
`, user.ID, user.Email, passwordHash, user.Name)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return User{}, ErrEmailTaken
}
return User{}, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (r *Repository) FindByEmail(ctx context.Context, email string) (storedUser, error) {
var user storedUser
err := r.db.QueryRowContext(ctx, `
SELECT id, email, name, password_hash
FROM users
WHERE lower(email) = lower($1)
`, strings.TrimSpace(email)).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return storedUser{}, sql.ErrNoRows
}
if err != nil {
return storedUser{}, fmt.Errorf("find user by email: %w", err)
}
return user, nil
}
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) {
var user User
err := r.db.QueryRowContext(ctx, `
SELECT id, email, name
FROM users
WHERE id = $1
`, id).Scan(&user.ID, &user.Email, &user.Name)
if errors.Is(err, sql.ErrNoRows) {
return User{}, sql.ErrNoRows
}
if err != nil {
return User{}, fmt.Errorf("find user by id: %w", err)
}
return user, nil
}
+243
View File
@@ -0,0 +1,243 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookieName = "studio_session"
visitorCookieName = "studio_visitor"
accessCookieName = "studio_gallery_access"
sessionDuration = 7 * 24 * time.Hour
accessDuration = 12 * time.Hour
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidSession = errors.New("invalid session")
)
type Service struct {
repository *Repository
secret []byte
secure bool
}
func NewService(repository *Repository, secret string, secureCookie bool) (*Service, error) {
if len(secret) < 32 {
return nil, fmt.Errorf("session secret must be at least 32 characters")
}
return &Service{repository: repository, secret: []byte(secret), secure: secureCookie}, nil
}
func (s *Service) Register(ctx context.Context, email, password, name string) (User, error) {
email = strings.ToLower(strings.TrimSpace(email))
name = strings.TrimSpace(name)
if !strings.Contains(email, "@") || len(email) > 254 {
return User{}, fmt.Errorf("enter a valid email address")
}
if len(password) < 8 {
return User{}, fmt.Errorf("password must be at least 8 characters")
}
if name == "" || len(name) > 120 {
return User{}, fmt.Errorf("name is required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return User{}, fmt.Errorf("hash password: %w", err)
}
return s.repository.CreateUser(ctx, email, string(hash), name)
}
func (s *Service) Login(ctx context.Context, email, password string) (User, error) {
user, err := s.repository.FindByEmail(ctx, email)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidCredentials
}
if err != nil {
return User{}, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return User{}, ErrInvalidCredentials
}
return user.User, nil
}
func (s *Service) SetSession(w http.ResponseWriter, user User) {
payload := sessionPayload{UserID: user.ID.String(), ExpiresAt: time.Now().Add(sessionDuration).Unix()}
token, err := s.signJSON(payload)
if err != nil {
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: int(sessionDuration.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Service) ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return User{}, ErrInvalidSession
}
var payload sessionPayload
if err := s.verifyJSON(cookie.Value, &payload); err != nil {
return User{}, ErrInvalidSession
}
userID, err := uuid.Parse(payload.UserID)
if err != nil || payload.ExpiresAt <= time.Now().Unix() {
return User{}, ErrInvalidSession
}
user, err := s.repository.FindByID(ctx, userID)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidSession
}
return user, err
}
type contextKey string
const userContextKey contextKey = "authenticated-user"
func (s *Service) Require(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := s.UserFromRequest(r.Context(), r)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
})
}
func UserFromContext(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userContextKey).(User)
return user, ok
}
func (s *Service) EnsureVisitor(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(visitorCookieName); err == nil {
if _, err := uuid.Parse(cookie.Value); err == nil {
return cookie.Value
}
}
visitorID := uuid.NewString()
http.SetCookie(w, &http.Cookie{
Name: visitorCookieName,
Value: visitorID,
Path: "/",
MaxAge: int(365 * 24 * time.Hour / time.Second),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
return visitorID
}
func (s *Service) GrantGalleryAccess(w http.ResponseWriter, slug string) {
payload := accessPayload{Slug: slug, ExpiresAt: time.Now().Add(accessDuration).Unix()}
token, err := s.signJSON(payload)
if err != nil {
return
}
http.SetCookie(w, &http.Cookie{
Name: accessCookieName,
Value: token,
Path: "/",
MaxAge: int(accessDuration.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool {
cookie, err := r.Cookie(accessCookieName)
if err != nil {
return false
}
var payload accessPayload
if err := s.verifyJSON(cookie.Value, &payload); err != nil {
return false
}
return payload.Slug == slug && payload.ExpiresAt > time.Now().Unix()
}
type sessionPayload struct {
UserID string `json:"userId"`
ExpiresAt int64 `json:"expiresAt"`
}
type accessPayload struct {
Slug string `json:"slug"`
ExpiresAt int64 `json:"expiresAt"`
}
func (s *Service) signJSON(value any) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(data)
return encoded + "." + s.signature(encoded), nil
}
func (s *Service) verifyJSON(token string, target any) error {
encoded, signature, ok := strings.Cut(token, ".")
if !ok || subtle.ConstantTimeCompare([]byte(signature), []byte(s.signature(encoded))) != 1 {
return ErrInvalidSession
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return ErrInvalidSession
}
if err := json.Unmarshal(data, target); err != nil {
return ErrInvalidSession
}
return nil
}
func (s *Service) signature(value string) string {
hash := hmac.New(sha256.New, s.secret)
_, _ = hash.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
)
func TestRegisterLoginAndSession(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
if _, err := database.ExecContext(ctx, `
CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
t.Fatalf("create users table: %v", err)
}
service, err := NewService(NewRepository(database), "test-session-secret-that-is-long-enough", false)
if err != nil {
t.Fatalf("create auth service: %v", err)
}
user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Northline Studio")
if err != nil {
t.Fatalf("register user: %v", err)
}
if user.Email != "photographer@example.com" {
t.Fatalf("email was not normalized: %q", user.Email)
}
loggedIn, err := service.Login(ctx, "PHOTOGRAPHER@example.com", "DemoPassword123!")
if err != nil || loggedIn.ID != user.ID {
t.Fatalf("login failed: user=%+v err=%v", loggedIn, err)
}
recorder := httptest.NewRecorder()
service.SetSession(recorder, user)
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
request.AddCookie(recorder.Result().Cookies()[0])
fromSession, err := service.UserFromRequest(ctx, request)
if err != nil || fromSession.ID != user.ID {
t.Fatalf("session lookup failed: user=%+v err=%v", fromSession, err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package config
import (
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
// Config contains the small set of process-level settings needed by the API.
type Config struct {
DBDriver string
Port string
DatabaseURL string
SQLitePath string
CORSOrigin string
SessionSecret string
CookieSecure bool
StorageEndpoint string
StorageAccessKey string
StorageSecretKey string
StorageBucket string
StorageUseSSL bool
}
func Load() Config {
// Loading is intentionally best-effort: real environment variables still win,
// while local commands can be run from either the repository or backend folder.
for _, path := range []string{".env", "../.env", "../../.env"} {
_ = godotenv.Load(path)
}
return Config{
DBDriver: envOrDefault("DB_DRIVER", "postgres"),
Port: envOrDefault("PORT", "8080"),
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://surprise:surprise_dev_password@localhost:5432/surprise?sslmode=disable"),
SQLitePath: envOrDefault("SQLITE_PATH", "./data/surprise.db"),
CORSOrigin: envOrDefault("CORS_ORIGIN", "http://localhost:5173,http://127.0.0.1:5173"),
SessionSecret: envOrDefault("SESSION_SECRET", "local-development-session-secret-change-me"),
CookieSecure: parseBoolEnv("COOKIE_SECURE", false),
StorageEndpoint: envOrDefault("STORAGE_ENDPOINT", "localhost:9000"),
StorageAccessKey: envOrDefault("STORAGE_ACCESS_KEY", "minioadmin"),
StorageSecretKey: envOrDefault("STORAGE_SECRET_KEY", "minioadmin"),
StorageBucket: envOrDefault("STORAGE_BUCKET", "gallery-media"),
StorageUseSSL: parseBoolEnv("STORAGE_USE_SSL", false),
}
}
func (c Config) DatabaseDSN() string {
if strings.EqualFold(c.DBDriver, "sqlite") || strings.EqualFold(c.DBDriver, "sqlite3") {
return c.SQLitePath
}
return c.DatabaseURL
}
func envOrDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func parseBoolEnv(key string, fallback bool) bool {
value, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(key)))
if err != nil {
return fallback
}
return value
}
+80
View File
@@ -0,0 +1,80 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
func New(ctx context.Context, driver, dsn string) (*sql.DB, error) {
driverName, err := normalizeDriver(driver)
if err != nil {
return nil, err
}
if driverName == "sqlite" {
if err := ensureSQLiteDirectory(dsn); err != nil {
return nil, fmt.Errorf("prepare sqlite path: %w", err)
}
}
database, err := sql.Open(driverName, dsn)
if err != nil {
return nil, fmt.Errorf("open %s database: %w", driverName, err)
}
if driverName == "sqlite" {
// In-memory databases are connection-local, so keep SQLite single-connection
// and enable foreign keys for every operation through this handle.
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
if _, err := database.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
database.Close()
return nil, fmt.Errorf("configure sqlite: %w", err)
}
} else {
database.SetMaxOpenConns(10)
database.SetMaxIdleConns(1)
}
if err := database.PingContext(ctx); err != nil {
database.Close()
return nil, fmt.Errorf("ping %s database: %w", driverName, err)
}
return database, nil
}
func normalizeDriver(driver string) (string, error) {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "postgres", "postgresql", "pgx":
return "pgx", nil
case "sqlite", "sqlite3":
return "sqlite", nil
default:
return "", fmt.Errorf("unsupported database driver %q (use postgres or sqlite)", driver)
}
}
func ensureSQLiteDirectory(dsn string) error {
if dsn == ":memory:" || strings.HasPrefix(dsn, "file::memory:") {
return nil
}
path := strings.SplitN(dsn, "?", 2)[0]
path = strings.TrimPrefix(path, "file:")
if path == "" || path == ":memory:" {
return nil
}
directory := filepath.Dir(path)
if directory == "." || directory == "" {
return nil
}
return os.MkdirAll(directory, 0o755)
}
+130
View File
@@ -0,0 +1,130 @@
package dev
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/storage"
)
type Config struct {
DBDriver string
StorageEndpoint string
StorageBucket string
StorageUseSSL bool
CORSOrigins string
CookieSecure bool
}
type Handler struct {
db *sql.DB
storage storage.Storage
auth *auth.Service
config Config
}
func NewHandler(db *sql.DB, objectStorage storage.Storage, authService *auth.Service, config Config) *Handler {
return &Handler{db: db, storage: objectStorage, auth: authService, config: config}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
mux.Handle("GET /api/dev/diagnostics", require(http.HandlerFunc(h.Diagnostics)))
mux.Handle("POST /api/dev/storage-check", require(http.HandlerFunc(h.StorageCheck)))
}
func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
databaseError := ""
databaseContext, cancel := context.WithTimeout(r.Context(), 2*time.Second)
if err := h.db.PingContext(databaseContext); err != nil {
databaseError = err.Error()
}
cancel()
storageError := ""
storageContext, storageCancel := context.WithTimeout(r.Context(), 3*time.Second)
if err := h.storage.EnsureBucket(storageContext); err != nil {
storageError = err.Error()
}
storageCancel()
writeJSON(w, http.StatusOK, map[string]any{
"environment": "development",
"now": time.Now().UTC().Format(time.RFC3339),
"user": map[string]string{
"id": user.ID.String(),
"email": user.Email,
"name": user.Name,
},
"database": map[string]any{
"driver": h.config.DBDriver,
"connected": databaseError == "",
"error": databaseError,
},
"storage": map[string]any{
"provider": "MinIO / S3-compatible",
"endpoint": h.config.StorageEndpoint,
"bucket": h.config.StorageBucket,
"secure": h.config.StorageUseSSL,
"reachable": storageError == "",
"error": storageError,
},
"http": map[string]any{
"corsOrigins": splitOrigins(h.config.CORSOrigins),
"cookieSecure": h.config.CookieSecure,
},
})
}
func (h *Handler) StorageCheck(w http.ResponseWriter, r *http.Request) {
if err := h.storage.EnsureBucket(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()})
return
}
var randomBytes [12]byte
if _, err := rand.Read(randomBytes[:]); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()})
return
}
key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:]))
contents := "northline storage check " + time.Now().UTC().Format(time.RFC3339Nano)
if err := h.storage.Put(r.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()})
return
}
info, statErr := h.storage.Stat(r.Context(), key)
deleteErr := h.storage.Delete(r.Context(), key)
if statErr != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()})
return
}
if deleteErr != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType})
}
func splitOrigins(value string) []string {
result := make([]string, 0)
for _, origin := range strings.Split(value, ",") {
if trimmed := strings.TrimSpace(origin); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+145
View File
@@ -0,0 +1,145 @@
package downloads
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/galleries"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Handler struct {
galleries *galleries.Repository
media *media.Repository
storage storage.Storage
auth *auth.Service
service *Service
}
func NewHandler(galleryRepository *galleries.Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service, service *Service) *Handler {
return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download)
mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll)
mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "downloads are disabled")
return
}
mediaID, err := uuid.Parse(r.PathValue("mediaId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.media.GetByID(r.Context(), mediaID)
if err != nil || item.GalleryID != record.ID || item.ProcessingStatus != media.StatusReady {
writeError(w, http.StatusNotFound, "media not found")
return
}
url, err := h.downloadURL(r, item)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
_ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID)
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
job, err := h.service.Create(r.Context(), record.ID, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start gallery download")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status})
}
func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
return
}
jobID, err := uuid.Parse(r.PathValue("jobId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid download job id")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID)
if err != nil {
writeError(w, http.StatusNotFound, "download job not found")
return
}
response := map[string]any{"jobId": job.ID.String(), "status": job.Status}
if job.Error != "" {
response["error"] = job.Error
}
if job.Status == StatusReady {
url, err := h.storage.CreateDownloadURL(r.Context(), job.StorageKey, time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download URL")
return
}
response["url"] = url
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) {
record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
if err != nil || record.IsExpired() {
return galleries.GalleryRecord{}, galleries.ErrNotFound
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
return galleries.GalleryRecord{}, galleries.ErrNotFound
}
return record, nil
}
func (h *Handler) downloadURL(r *http.Request, item media.Record) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
return h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+22
View File
@@ -0,0 +1,22 @@
package downloads
import "github.com/google/uuid"
const (
StatusQueued = "QUEUED"
StatusProcessing = "PROCESSING"
StatusReady = "READY"
StatusFailed = "FAILED"
)
type Job struct {
ID uuid.UUID
GalleryID uuid.UUID
VisitorID string
Status string
StorageKey string
Error string
CreatedAt string
UpdatedAt string
CompletedAt string
}
+94
View File
@@ -0,0 +1,94 @@
package downloads
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("download job not found")
func (r *Repository) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) {
job := Job{ID: uuid.New(), GalleryID: galleryID, VisitorID: visitorID, Status: StatusQueued}
_, err := r.db.ExecContext(ctx, `
INSERT INTO download_jobs (id, gallery_id, visitor_id, status)
VALUES ($1, $2, $3, $4)
`, job.ID, job.GalleryID, job.VisitorID, job.Status)
if err != nil {
return Job{}, fmt.Errorf("create download job: %w", err)
}
return r.GetForVisitor(ctx, job.ID, galleryID, visitorID)
}
func (r *Repository) GetForVisitor(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) {
return r.get(ctx, `
WHERE id = $1 AND gallery_id = $2 AND visitor_id = $3
`, jobID, galleryID, visitorID)
}
func (r *Repository) Get(ctx context.Context, jobID uuid.UUID) (Job, error) {
return r.get(ctx, `WHERE id = $1`, jobID)
}
func (r *Repository) get(ctx context.Context, predicate string, args ...any) (Job, error) {
var (
job Job
storageKey, jobError, createdAt, updatedAt sql.NullString
completedAt sql.NullString
)
err := r.db.QueryRowContext(ctx, `
SELECT id, gallery_id, visitor_id, status, storage_key, error, created_at, updated_at, completed_at
FROM download_jobs
`+predicate+`
`, args...).Scan(
&job.ID, &job.GalleryID, &job.VisitorID, &job.Status, &storageKey, &jobError,
&createdAt, &updatedAt, &completedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return Job{}, ErrNotFound
}
if err != nil {
return Job{}, fmt.Errorf("find download job: %w", err)
}
job.StorageKey = storageKey.String
job.Error = jobError.String
job.CreatedAt = createdAt.String
job.UpdatedAt = updatedAt.String
job.CompletedAt = completedAt.String
return job, nil
}
func (r *Repository) MarkProcessing(ctx context.Context, jobID uuid.UUID) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2
`, StatusProcessing, jobID)
return err
}
func (r *Repository) MarkReady(ctx context.Context, jobID uuid.UUID, storageKey string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, storage_key = $2, error = NULL,
updated_at = CURRENT_TIMESTAMP, completed_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusReady, storageKey, jobID)
return err
}
func (r *Repository) MarkFailed(ctx context.Context, jobID uuid.UUID, message string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusFailed, message, jobID)
return err
}
+185
View File
@@ -0,0 +1,185 @@
package downloads
import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Service struct {
repository *Repository
media *media.Repository
storage storage.Storage
jobs chan uuid.UUID
stop chan struct{}
waitGroup sync.WaitGroup
}
var placeholderClient = &http.Client{Timeout: time.Minute}
func NewService(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, workers int) *Service {
if workers < 1 {
workers = 1
}
service := &Service{
repository: repository,
media: mediaRepository,
storage: objectStorage,
jobs: make(chan uuid.UUID, 32),
stop: make(chan struct{}),
}
for index := 0; index < workers; index++ {
service.waitGroup.Add(1)
go service.worker()
}
return service
}
func (s *Service) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) {
job, err := s.repository.Create(ctx, galleryID, visitorID)
if err != nil {
return Job{}, err
}
select {
case s.jobs <- job.ID:
case <-s.stop:
return Job{}, fmt.Errorf("download service is stopping")
}
return job, nil
}
func (s *Service) Get(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) {
return s.repository.GetForVisitor(ctx, jobID, galleryID, visitorID)
}
func (s *Service) Close() {
close(s.stop)
s.waitGroup.Wait()
}
func (s *Service) worker() {
defer s.waitGroup.Done()
for {
select {
case jobID := <-s.jobs:
s.process(jobID)
case <-s.stop:
return
}
}
}
func (s *Service) process(jobID uuid.UUID) {
ctx := context.Background()
if err := s.repository.MarkProcessing(ctx, jobID); err != nil {
return
}
var job Job
// The worker needs the gallery ID and visitor only for status storage. The
// job lookup below is intentionally not visitor-scoped because the ID is
// generated internally and never exposed before creation succeeds.
job, err := s.repository.Get(ctx, jobID)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
items, err := s.media.ListByGallery(ctx, job.GalleryID)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
temporary, err := os.CreateTemp("", "gallery-download-*.zip")
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
archive := zip.NewWriter(temporary)
for _, item := range items {
if item.ProcessingStatus != media.StatusReady {
continue
}
object, err := s.openItem(ctx, item)
if err != nil {
_ = archive.Close()
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
entry, err := archive.Create(filepath.Base(item.OriginalFilename))
if err == nil {
_, err = io.Copy(entry, object)
}
_ = object.Close()
if err != nil {
_ = archive.Close()
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
}
if err := archive.Close(); err != nil {
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
if err := temporary.Close(); err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
fileInfo, err := os.Stat(temporaryPath)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
key := fmt.Sprintf("galleries/%s/downloads/%s.zip", job.GalleryID, job.ID)
file, err := os.Open(temporaryPath)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
err = s.storage.Put(ctx, key, file, fileInfo.Size(), "application/zip")
_ = file.Close()
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
_ = s.repository.MarkReady(ctx, jobID, key)
}
func (s *Service) openItem(ctx context.Context, item media.Record) (io.ReadCloser, error) {
if item.ExternalURL != "" {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, item.ExternalURL, nil)
if err != nil {
return nil, err
}
response, err := placeholderClient.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode >= http.StatusBadRequest {
_ = response.Body.Close()
return nil, fmt.Errorf("download placeholder returned %s", response.Status)
}
return response.Body, nil
}
if strings.TrimSpace(item.StorageKey) == "" {
return nil, fmt.Errorf("media has no storage object")
}
return s.storage.Get(ctx, item.StorageKey)
}
+677
View File
@@ -0,0 +1,677 @@
package galleries
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const signedURLDuration = time.Hour
type Handler struct {
repository *Repository
media *media.Repository
storage storage.Storage
auth *auth.Service
}
func NewHandler(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service) *Handler {
return &Handler{
repository: repository,
media: mediaRepository,
storage: objectStorage,
auth: authService,
}
}
func (h *Handler) RegisterProtectedRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List)))
mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create)))
mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get)))
mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update)))
mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete)))
mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish)))
mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish)))
mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview)))
}
func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public)
mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic)
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite)
mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite)
}
type createRequest struct {
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
}
type updateRequest struct {
Title *string `json:"title"`
ClientName *string `json:"clientName"`
Description *string `json:"description"`
Password *string `json:"password"`
ClearPassword bool `json:"clearPassword"`
DownloadsEnabled *bool `json:"downloadsEnabled"`
FavoritesEnabled *bool `json:"favoritesEnabled"`
DownloadAllEnabled *bool `json:"downloadAllEnabled"`
WatermarkEnabled *bool `json:"watermarkEnabled"`
ExpiresAt *string `json:"expiresAt"`
CoverMediaID *string `json:"coverMediaId"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
}
type publicPasswordRequest struct {
Password string `json:"password"`
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
summaries, err := h.repository.ListForUser(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load galleries")
return
}
for index := range summaries {
if summaries[index].CoverMediaID == "" {
continue
}
coverID, err := uuid.Parse(summaries[index].CoverMediaID)
if err != nil {
continue
}
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
if err != nil {
continue
}
summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false)
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
var request createRequest
if !decodeJSON(w, r, &request) {
return
}
request.Title = strings.TrimSpace(request.Title)
request.ClientName = strings.TrimSpace(request.ClientName)
if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 {
writeError(w, http.StatusBadRequest, "title and client name are required")
return
}
record, err := h.repository.Create(r.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create gallery")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
items, err := h.media.ListByGallery(r.Context(), record.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media")
return
}
views, err := h.mediaViews(r.Context(), items, "", true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
}
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
gallery, err := h.publicPayload(r.Context(), r, record, true, true, "")
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build gallery preview")
return
}
gallery.Preview = true
writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
current, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
var request updateRequest
if !decodeJSON(w, r, &request) {
return
}
input := UpdateInput{
Title: current.Title,
ClientName: current.ClientName,
Description: current.Description,
DownloadsEnabled: current.DownloadsEnabled,
FavoritesEnabled: current.FavoritesEnabled,
DownloadAllEnabled: current.DownloadAllEnabled,
WatermarkEnabled: current.WatermarkEnabled,
ExpiresAt: stringPointer(current.ExpiresAt),
CoverMediaID: stringPointer(current.CoverMediaID),
ThemeConfig: current.ThemeConfig,
BrandingConfig: current.BrandingConfig,
}
if current.PasswordHash != "" {
input.PasswordHash = &current.PasswordHash
}
if request.Title != nil {
input.Title = strings.TrimSpace(*request.Title)
}
if request.ClientName != nil {
input.ClientName = strings.TrimSpace(*request.ClientName)
}
if request.Description != nil {
input.Description = strings.TrimSpace(*request.Description)
}
if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 {
writeError(w, http.StatusBadRequest, "title and client name are required")
return
}
if request.Password != nil {
if strings.TrimSpace(*request.Password) == "" {
input.ClearPassword = true
} else if len(*request.Password) < 4 {
writeError(w, http.StatusBadRequest, "gallery password must be at least 4 characters")
return
} else {
hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not secure gallery password")
return
}
hashed := string(hash)
input.PasswordHash = &hashed
input.ClearPassword = false
}
}
if request.ClearPassword {
input.ClearPassword = true
input.PasswordHash = nil
}
if request.DownloadsEnabled != nil {
input.DownloadsEnabled = *request.DownloadsEnabled
}
if request.FavoritesEnabled != nil {
input.FavoritesEnabled = *request.FavoritesEnabled
}
if request.DownloadAllEnabled != nil {
input.DownloadAllEnabled = *request.DownloadAllEnabled
}
if request.WatermarkEnabled != nil {
input.WatermarkEnabled = *request.WatermarkEnabled
}
if request.ExpiresAt != nil {
value := strings.TrimSpace(*request.ExpiresAt)
if value != "" {
if _, err := time.Parse(time.RFC3339, value); err != nil {
writeError(w, http.StatusBadRequest, "expiry must be an ISO timestamp")
return
}
}
input.ExpiresAt = &value
}
if request.CoverMediaID != nil {
value := strings.TrimSpace(*request.CoverMediaID)
if value != "" {
coverID, err := uuid.Parse(value)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cover media id")
return
}
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
if err != nil || cover.GalleryID != current.ID {
writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery")
return
}
}
input.CoverMediaID = &value
}
if len(request.ThemeConfig) > 0 {
if !json.Valid(request.ThemeConfig) {
writeError(w, http.StatusBadRequest, "theme config must be valid JSON")
return
}
input.ThemeConfig = request.ThemeConfig
}
if len(request.BrandingConfig) > 0 {
if !json.Valid(request.BrandingConfig) {
writeError(w, http.StatusBadRequest, "branding config must be valid JSON")
return
}
input.BrandingConfig = request.BrandingConfig
}
record, err := h.repository.Update(r.Context(), user.ID, current.ID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery")
return
}
items, err := h.media.ListByGallery(r.Context(), record.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media")
return
}
views, err := h.mediaViews(r.Context(), items, "", true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
}
func (h *Handler) Publish(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, StatusPublished)
}
func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, StatusDraft)
}
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery status")
return
}
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
id, err := pathUUID(r, "id")
if err != nil {
writeGalleryError(w, err)
return
}
if err := h.repository.Delete(r.Context(), user.ID, id); err != nil {
writeGalleryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Public(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
gallery := h.lockedPayload(record)
writeJSON(w, http.StatusOK, gallery)
return
}
visitorID := ""
if record.FavoritesEnabled {
visitorID = h.auth.EnsureVisitor(w, r)
}
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery")
return
}
writeJSON(w, http.StatusOK, gallery)
}
func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
var request publicPasswordRequest
if !decodeJSON(w, r, &request) {
return
}
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil {
writeError(w, http.StatusUnauthorized, "incorrect gallery password")
return
}
h.auth.GrantGalleryAccess(w, record.Slug)
visitorID := ""
if record.FavoritesEnabled {
visitorID = h.auth.EnsureVisitor(w, r)
}
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery")
return
}
writeJSON(w, http.StatusOK, gallery)
}
func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) {
h.setFavorite(w, r, true)
}
func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) {
h.setFavorite(w, r, false)
}
func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) {
record, err := h.publicRecordForRequest(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.FavoritesEnabled {
writeError(w, http.StatusForbidden, "favorites are disabled")
return
}
mediaID, err := pathUUID(r, "mediaId")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.media.GetByID(r.Context(), mediaID)
if err != nil || item.GalleryID != record.ID {
writeError(w, http.StatusNotFound, "media not found")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
writeError(w, http.StatusInternalServerError, "could not update favorite")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"favorited": favorited})
}
func (h *Handler) publicRecordForRequest(r *http.Request) (GalleryRecord, error) {
record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
if err != nil || record.IsExpired() {
return GalleryRecord{}, ErrNotFound
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
return GalleryRecord{}, ErrNotFound
}
return record, nil
}
func (h *Handler) publicPayload(ctx context.Context, _ *http.Request, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
items, err := h.media.ListByGallery(ctx, record.ID)
if err != nil {
return Public{}, err
}
views, err := h.mediaViews(ctx, items, visitorID, includeOriginal)
if err != nil {
return Public{}, err
}
gallery := Public{
Slug: record.Slug,
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
ThemeConfig: record.ThemeConfig,
BrandingConfig: record.BrandingConfig,
DownloadsEnabled: record.DownloadsEnabled,
FavoritesEnabled: record.FavoritesEnabled,
DownloadAllEnabled: record.DownloadAllEnabled,
WatermarkEnabled: record.WatermarkEnabled,
ExpiresAt: record.ExpiresAt,
Media: views,
}
for index := range items {
if record.CoverMediaID != "" && items[index].ID.String() == record.CoverMediaID {
gallery.Cover = &views[index]
break
}
}
if gallery.Cover == nil {
for index := range items {
if media.IsImage(items[index]) && views[index].PreviewURL != "" {
gallery.Cover = &views[index]
break
}
}
}
return gallery, nil
}
func (h *Handler) lockedPayload(record GalleryRecord) map[string]any {
return map[string]any{
"slug": record.Slug,
"title": record.Title,
"clientName": record.ClientName,
"description": record.Description,
"themeConfig": record.ThemeConfig,
"brandingConfig": record.BrandingConfig,
"requiresPassword": true,
"media": []media.Public{},
}
}
func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) {
id, err := pathUUID(r, "id")
if err != nil {
return GalleryRecord{}, err
}
return h.repository.GetForUser(r.Context(), userID, id)
}
func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail {
if items == nil {
items = []media.Public{}
}
return Detail{
ID: record.ID.String(),
Slug: record.Slug,
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
Status: record.Status,
DownloadsEnabled: record.DownloadsEnabled,
FavoritesEnabled: record.FavoritesEnabled,
DownloadAllEnabled: record.DownloadAllEnabled,
WatermarkEnabled: record.WatermarkEnabled,
ExpiresAt: record.ExpiresAt,
CoverMediaID: record.CoverMediaID,
ThemeConfig: record.ThemeConfig,
BrandingConfig: record.BrandingConfig,
CreatedAt: record.CreatedAt,
PublishedAt: record.PublishedAt,
Media: items,
}
}
func (h *Handler) mediaViews(ctx context.Context, items []media.Record, visitorID string, includeOriginal bool) ([]media.Public, error) {
views := make([]media.Public, 0, len(items))
for _, item := range items {
view := media.Public{
ID: item.ID,
OriginalFilename: item.OriginalFilename,
MimeType: item.MimeType,
FileSize: item.FileSize,
ProcessingStatus: item.ProcessingStatus,
Width: item.Width,
Height: item.Height,
DurationSeconds: item.DurationSeconds,
SortOrder: item.SortOrder,
}
if item.ProcessingStatus == media.StatusReady {
var err error
view.ThumbnailURL, err = h.mediaVariantURL(ctx, item, item.ThumbnailKey)
if err != nil {
return nil, err
}
view.PreviewURL, err = h.mediaURL(ctx, item, false)
if err != nil {
return nil, err
}
if includeOriginal {
view.OriginalURL, err = h.mediaURL(ctx, item, true)
if err != nil {
return nil, err
}
}
}
if visitorID != "" {
var err error
view.Favorited, err = h.media.IsFavorited(ctx, item.GalleryID, item.ID, visitorID)
if err != nil {
return nil, err
}
}
views = append(views, view)
}
return views, nil
}
func (h *Handler) mediaURL(ctx context.Context, item media.Record, original bool) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
key := item.PreviewKey
if original {
key = item.StorageKey
}
return h.mediaVariantURL(ctx, item, key)
}
func (h *Handler) mediaVariantURL(ctx context.Context, item media.Record, key string) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
if key == "" {
return "", fmt.Errorf("media object is not ready")
}
return h.storage.CreateDownloadURL(ctx, key, signedURLDuration)
}
func newSlug(title string) string {
var builder strings.Builder
lastWasSeparator := true
for _, character := range strings.ToLower(title) {
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') {
builder.WriteRune(character)
lastWasSeparator = false
continue
}
if builder.Len() > 0 && !lastWasSeparator {
builder.WriteByte('-')
lastWasSeparator = true
}
}
slug := strings.Trim(builder.String(), "-")
if slug == "" {
slug = "gallery"
}
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
return slug + "-" + suffix
}
func pathUUID(r *http.Request, name string) (uuid.UUID, error) {
id, err := uuid.Parse(r.PathValue(name))
if err != nil {
return uuid.Nil, fmt.Errorf("invalid %s", name)
}
return id, nil
}
func stringPointer(value string) *string {
if value == "" {
return nil
}
copy := value
return &copy
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
return true
}
func writeGalleryError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
writeError(w, http.StatusInternalServerError, "could not load gallery")
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
@@ -0,0 +1,90 @@
package galleries
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/example/sndit/backend/internal/auth"
appdb "github.com/example/sndit/backend/internal/db"
"github.com/example/sndit/backend/internal/media"
"github.com/google/uuid"
)
func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql")
migration, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("read gallery migration: %v", err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply gallery migration: %v", err)
}
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666")
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
t.Fatalf("insert user: %v", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config)
VALUES ($1, $2, $3, $4, $5, $6, 'published', $7, $8)
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Northline Studio"}`); err != nil {
t.Fatalf("insert gallery: %v", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, storage_key, external_url, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0)
`, mediaID, galleryID, "one.jpg", "image/jpeg", "demo/one.jpg", "https://example.com/one.jpg"); err != nil {
t.Fatalf("insert media: %v", err)
}
authService, err := auth.NewService(auth.NewRepository(database), "test-gallery-secret-that-is-long-enough", false)
if err != nil {
t.Fatalf("create auth service: %v", err)
}
handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService)
mux := http.NewServeMux()
handler.RegisterPublicRoutes(mux)
getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil)
getRecorder := httptest.NewRecorder()
mux.ServeHTTP(getRecorder, getRequest)
if getRecorder.Code != http.StatusOK {
t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String())
}
var gallery Public
if err := json.NewDecoder(getRecorder.Body).Decode(&gallery); err != nil {
t.Fatalf("decode public gallery: %v", err)
}
if gallery.Title != "Emma & James" || len(gallery.Media) != 1 || gallery.Media[0].PreviewURL != "https://example.com/one.jpg" {
t.Fatalf("unexpected public gallery: %+v", gallery)
}
favoriteRequest := httptest.NewRequest(http.MethodPost, "/api/public/galleries/demo-gallery/media/77777777-7777-4777-8777-777777777777/favorite", nil)
for _, cookie := range getRecorder.Result().Cookies() {
favoriteRequest.AddCookie(cookie)
}
favoriteRecorder := httptest.NewRecorder()
mux.ServeHTTP(favoriteRecorder, favoriteRequest)
if favoriteRecorder.Code != http.StatusOK {
t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String())
}
}
+125
View File
@@ -0,0 +1,125 @@
package galleries
import (
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
)
const (
StatusDraft = "draft"
StatusPublished = "published"
StatusArchived = "archived"
)
type GalleryRecord struct {
ID uuid.UUID
UserID uuid.UUID
Slug string
Title string
ClientName string
Description string
Status string
PasswordHash string
DownloadsEnabled bool
FavoritesEnabled bool
DownloadAllEnabled bool
WatermarkEnabled bool
ExpiresAt string
CoverMediaID string
ThemeConfig json.RawMessage
BrandingConfig json.RawMessage
CreatedAt string
UpdatedAt string
PublishedAt string
}
type Summary struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
Status string `json:"status"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
CoverMediaID string `json:"coverMediaId,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
CreatedAt string `json:"createdAt"`
PublishedAt string `json:"publishedAt,omitempty"`
PhotoCount int `json:"photoCount"`
VideoCount int `json:"videoCount"`
TotalBytes int64 `json:"totalBytes"`
}
type UpdateInput struct {
Title string
ClientName string
Description string
PasswordHash *string
ClearPassword bool
DownloadsEnabled bool
FavoritesEnabled bool
DownloadAllEnabled bool
WatermarkEnabled bool
ExpiresAt *string
CoverMediaID *string
ThemeConfig json.RawMessage
BrandingConfig json.RawMessage
}
func (g GalleryRecord) IsExpired() bool {
if strings.TrimSpace(g.ExpiresAt) == "" {
return false
}
value := strings.TrimSpace(g.ExpiresAt)
layouts := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05-07",
"2006-01-02 15:04:05",
}
var parsed time.Time
var err error
for _, layout := range layouts {
parsed, err = time.Parse(layout, value)
if err == nil {
break
}
}
if err != nil {
return false
}
return !time.Now().Before(parsed)
}
func (g GalleryRecord) ToSummary(photoCount, videoCount int, totalBytes int64) Summary {
return Summary{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
ClientName: g.ClientName,
Description: g.Description,
Status: g.Status,
DownloadsEnabled: g.DownloadsEnabled,
FavoritesEnabled: g.FavoritesEnabled,
DownloadAllEnabled: g.DownloadAllEnabled,
WatermarkEnabled: g.WatermarkEnabled,
ExpiresAt: g.ExpiresAt,
CoverMediaID: g.CoverMediaID,
ThemeConfig: g.ThemeConfig,
BrandingConfig: g.BrandingConfig,
CreatedAt: g.CreatedAt,
PublishedAt: g.PublishedAt,
PhotoCount: photoCount,
VideoCount: videoCount,
TotalBytes: totalBytes,
}
}
+230
View File
@@ -0,0 +1,230 @@
package galleries
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("gallery not found")
func (r *Repository) Create(ctx context.Context, userID uuid.UUID, slug, title, clientName, description string) (GalleryRecord, error) {
id := uuid.New()
_, err := r.db.ExecContext(ctx, `
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, theme_config, branding_config)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', '{}', '{}')
`, id, userID, slug, title, clientName, description)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return GalleryRecord{}, fmt.Errorf("gallery slug already exists: %w", err)
}
return GalleryRecord{}, fmt.Errorf("create gallery: %w", err)
}
return r.GetForUser(ctx, userID, id)
}
func (r *Repository) ListForUser(ctx context.Context, userID uuid.UUID) ([]Summary, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT
g.id, g.slug, g.title, g.client_name, g.description, g.status,
g.downloads_enabled, g.favorites_enabled, g.download_all_enabled,
g.watermark_enabled, g.expires_at, g.cover_media_id,
g.theme_config, g.branding_config, g.created_at, g.published_at,
COUNT(CASE WHEN m.mime_type LIKE 'image/%' THEN 1 END),
COUNT(CASE WHEN m.mime_type LIKE 'video/%' THEN 1 END),
COALESCE(SUM(m.file_size), 0)
FROM galleries g
LEFT JOIN media m ON m.gallery_id = g.id
WHERE g.user_id = $1 AND g.status <> 'archived'
GROUP BY g.id
ORDER BY g.created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list galleries: %w", err)
}
defer rows.Close()
result := make([]Summary, 0)
for rows.Next() {
var (
item Summary
expiresAt, coverMediaID, createdAt sql.NullString
publishedAt sql.NullString
themeConfig, brandingConfig []byte
)
if err := rows.Scan(
&item.ID, &item.Slug, &item.Title, &item.ClientName, &item.Description, &item.Status,
&item.DownloadsEnabled, &item.FavoritesEnabled, &item.DownloadAllEnabled,
&item.WatermarkEnabled, &expiresAt, &coverMediaID, &themeConfig, &brandingConfig,
&createdAt, &publishedAt, &item.PhotoCount, &item.VideoCount, &item.TotalBytes,
); err != nil {
return nil, fmt.Errorf("scan gallery summary: %w", err)
}
item.ExpiresAt = expiresAt.String
item.CoverMediaID = coverMediaID.String
item.ThemeConfig = nonEmptyJSON(themeConfig)
item.BrandingConfig = nonEmptyJSON(brandingConfig)
item.CreatedAt = createdAt.String
item.PublishedAt = publishedAt.String
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate galleries: %w", err)
}
return result, nil
}
func (r *Repository) GetForUser(ctx context.Context, userID, galleryID uuid.UUID) (GalleryRecord, error) {
return r.get(ctx, `
SELECT id, user_id, slug, title, client_name, description, status, password_hash,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at
FROM galleries
WHERE id = $1 AND user_id = $2
`, galleryID, userID)
}
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (GalleryRecord, error) {
return r.get(ctx, `
SELECT id, user_id, slug, title, client_name, description, status, password_hash,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at
FROM galleries
WHERE slug = $1 AND status = 'published'
`, slug)
}
func (r *Repository) Update(ctx context.Context, userID, galleryID uuid.UUID, input UpdateInput) (GalleryRecord, error) {
var passwordValue any
if input.ClearPassword {
passwordValue = nil
} else if input.PasswordHash != nil {
passwordValue = *input.PasswordHash
} else {
current, err := r.GetForUser(ctx, userID, galleryID)
if err != nil {
return GalleryRecord{}, err
}
passwordValue = current.PasswordHash
}
var expiresValue any
if input.ExpiresAt != nil && strings.TrimSpace(*input.ExpiresAt) != "" {
expiresValue = *input.ExpiresAt
}
var coverValue any
if input.CoverMediaID != nil && strings.TrimSpace(*input.CoverMediaID) != "" {
coverID, err := uuid.Parse(strings.TrimSpace(*input.CoverMediaID))
if err != nil {
return GalleryRecord{}, fmt.Errorf("parse cover media id: %w", err)
}
coverValue = coverID
}
_, err := r.db.ExecContext(ctx, `
UPDATE galleries
SET title = $1, client_name = $2, description = $3,
password_hash = $4, downloads_enabled = $5, favorites_enabled = $6,
download_all_enabled = $7, watermark_enabled = $8, expires_at = $9,
cover_media_id = $10, theme_config = $11, branding_config = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $13 AND user_id = $14
`, input.Title, input.ClientName, input.Description, passwordValue, input.DownloadsEnabled,
input.FavoritesEnabled, input.DownloadAllEnabled, input.WatermarkEnabled, expiresValue,
coverValue, jsonValue(input.ThemeConfig), jsonValue(input.BrandingConfig), galleryID, userID)
if err != nil {
return GalleryRecord{}, fmt.Errorf("update gallery: %w", err)
}
return r.GetForUser(ctx, userID, galleryID)
}
func (r *Repository) SetStatus(ctx context.Context, userID, galleryID uuid.UUID, status string) (GalleryRecord, error) {
var query string
if status == StatusPublished {
query = `UPDATE galleries SET status = $1, published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`
} else {
query = `UPDATE galleries SET status = $1, published_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`
}
_, err := r.db.ExecContext(ctx, query, status, galleryID, userID)
if err != nil {
return GalleryRecord{}, fmt.Errorf("set gallery status: %w", err)
}
return r.GetForUser(ctx, userID, galleryID)
}
func (r *Repository) Delete(ctx context.Context, userID, galleryID uuid.UUID) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM galleries WHERE id = $1 AND user_id = $2`, galleryID, userID)
if err != nil {
return fmt.Errorf("delete gallery: %w", err)
}
count, err := result.RowsAffected()
if err != nil || count == 0 {
return ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(...any) error
}
func (r *Repository) get(ctx context.Context, query string, args ...any) (GalleryRecord, error) {
return scanGallery(r.db.QueryRowContext(ctx, query, args...))
}
func scanGallery(row rowScanner) (GalleryRecord, error) {
var (
gallery GalleryRecord
passwordHash, expiresAt, coverMediaID sql.NullString
themeConfig, brandingConfig []byte
createdAt, updatedAt, publishedAt sql.NullString
)
err := row.Scan(
&gallery.ID, &gallery.UserID, &gallery.Slug, &gallery.Title, &gallery.ClientName,
&gallery.Description, &gallery.Status, &passwordHash, &gallery.DownloadsEnabled,
&gallery.FavoritesEnabled, &gallery.DownloadAllEnabled, &gallery.WatermarkEnabled,
&expiresAt, &coverMediaID, &themeConfig, &brandingConfig, &createdAt, &updatedAt, &publishedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return GalleryRecord{}, ErrNotFound
}
if err != nil {
return GalleryRecord{}, fmt.Errorf("scan gallery: %w", err)
}
gallery.PasswordHash = passwordHash.String
gallery.ExpiresAt = expiresAt.String
gallery.CoverMediaID = coverMediaID.String
gallery.ThemeConfig = nonEmptyJSON(themeConfig)
gallery.BrandingConfig = nonEmptyJSON(brandingConfig)
gallery.CreatedAt = createdAt.String
gallery.UpdatedAt = updatedAt.String
gallery.PublishedAt = publishedAt.String
return gallery, nil
}
func nonEmptyJSON(value []byte) json.RawMessage {
if len(value) == 0 {
return json.RawMessage(`{}`)
}
return json.RawMessage(value)
}
func jsonValue(value json.RawMessage) string {
if len(value) == 0 {
return `{}`
}
return string(value)
}
@@ -0,0 +1,87 @@
package galleries
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
"github.com/google/uuid"
)
func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql")
migration, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("read gallery migration: %v", err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply gallery migration: %v", err)
}
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
t.Fatalf("insert user: %v", err)
}
repository := NewRepository(database)
record, err := repository.Create(ctx, userID, "emma-james-wedding-12345678", "Emma & James", "Emma & James", "A summer wedding.")
if err != nil {
t.Fatalf("create gallery: %v", err)
}
if record.Status != StatusDraft || record.Title != "Emma & James" {
t.Fatalf("unexpected gallery: %+v", record)
}
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0)
`, mediaID, record.ID, "one.jpg", "image/jpeg", 4096, "gallery/one.jpg"); err != nil {
t.Fatalf("insert media: %v", err)
}
updated, err := repository.Update(ctx, userID, record.ID, UpdateInput{
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
DownloadsEnabled: true,
FavoritesEnabled: true,
DownloadAllEnabled: true,
ThemeConfig: json.RawMessage(`{"mode":"dark"}`),
BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`),
})
if err != nil {
t.Fatalf("update gallery: %v", err)
}
if string(updated.ThemeConfig) != `{"mode":"dark"}` {
t.Fatalf("theme config was not persisted: %s", updated.ThemeConfig)
}
summaries, err := repository.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("list galleries: %v", err)
}
if len(summaries) != 1 || summaries[0].PhotoCount != 1 || summaries[0].TotalBytes != 4096 {
t.Fatalf("unexpected summary: %+v", summaries)
}
if _, err := repository.SetStatus(ctx, userID, record.ID, StatusPublished); err != nil {
t.Fatalf("publish gallery: %v", err)
}
if _, err := repository.GetPublicBySlug(ctx, record.Slug); err != nil {
t.Fatalf("public gallery lookup: %v", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package galleries
import (
"encoding/json"
"github.com/example/sndit/backend/internal/media"
)
type Detail struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
Status string `json:"status"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
CoverMediaID string `json:"coverMediaId,omitempty"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
CreatedAt string `json:"createdAt"`
PublishedAt string `json:"publishedAt,omitempty"`
Media []media.Public `json:"media"`
}
type Public struct {
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
Preview bool `json:"preview,omitempty"`
RequiresPassword bool `json:"requiresPassword"`
Cover *media.Public `json:"cover,omitempty"`
Media []media.Public `json:"media"`
}
+73
View File
@@ -0,0 +1,73 @@
package gifts
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.Health)
mux.HandleFunc("GET /api/gifts/{slug}", h.GetGift)
return mux
}
func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) GetGift(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
if !validSlug(slug) {
writeError(w, http.StatusBadRequest, "invalid gift slug")
return
}
gift, err := h.service.GetPublicGift(r.Context(), slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "gift not found")
return
}
writeError(w, http.StatusInternalServerError, "could not load gift")
return
}
writeJSON(w, http.StatusOK, gift)
}
func validSlug(slug string) bool {
if len(slug) == 0 || len(slug) > 100 {
return false
}
for index, character := range slug {
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') ||
(character == '-' && index > 0 && index < len(slug)-1) {
continue
}
return false
}
return true
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+125
View File
@@ -0,0 +1,125 @@
package gifts
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
type fakeStore struct {
gift PublicGift
err error
}
func (f fakeStore) GetPublicBySlug(context.Context, string) (PublicGift, error) {
return f.gift, f.err
}
func TestGetGiftReturnsPublicRepresentation(t *testing.T) {
giftID := uuid.MustParse("11111111-1111-4111-8111-111111111111")
itemID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
service := NewService(fakeStore{gift: PublicGift{
ID: giftID,
Slug: "demo",
RecipientName: "Anna",
SenderName: "Alex",
Title: "A little surprise for you",
IntroMessage: "I made something for you.",
RevealMessage: "A beautiful final note.",
Items: []PublicGiftItem{{
ID: itemID,
Type: "text",
Title: "A note",
Text: "Hello",
SortOrder: 1,
}},
}})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("unexpected content type: %q", contentType)
}
var response PublicGift
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.RecipientName != "Anna" || len(response.Items) != 1 {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestGetGiftReturnsNotFound(t *testing.T) {
service := NewService(fakeStore{err: ErrNotFound})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/missing", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"gift not found\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftHidesStoreErrors(t *testing.T) {
service := NewService(fakeStore{err: errors.New("database connection lost")})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"could not load gift\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftRejectsInvalidSlug(t *testing.T) {
service := NewService(fakeStore{})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/not%20a%20slug", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestHealthReturnsOK(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
NewHandler(NewService(fakeStore{})).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"status\":\"ok\"}\n" {
t.Fatalf("unexpected health body: %q", body)
}
}
func TestServiceRejectsEmptySlug(t *testing.T) {
service := NewService(fakeStore{})
_, err := service.GetPublicGift(context.Background(), "")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package gifts
import (
"encoding/json"
"github.com/google/uuid"
)
type PublicGift struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
RecipientName string `json:"recipientName"`
SenderName string `json:"senderName"`
Title string `json:"title"`
IntroMessage string `json:"introMessage"`
RevealMessage string `json:"revealMessage"`
Items []PublicGiftItem `json:"items"`
}
type PublicGiftItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
MediaURL string `json:"mediaUrl,omitempty"`
SortOrder int `json:"sortOrder"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
+97
View File
@@ -0,0 +1,97 @@
package gifts
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (PublicGift, error) {
var gift PublicGift
err := r.db.QueryRowContext(ctx, `
SELECT id, slug, recipient_name, sender_name, title, intro_message, reveal_message
FROM gifts
WHERE slug = $1 AND status = 'published'
`, slug).Scan(
&gift.ID,
&gift.Slug,
&gift.RecipientName,
&gift.SenderName,
&gift.Title,
&gift.IntroMessage,
&gift.RevealMessage,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("find gift: %w", err)
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, title, text, media_url, sort_order, metadata
FROM gift_items
WHERE gift_id = $1
ORDER BY sort_order ASC, id ASC
`, gift.ID)
if err != nil {
return PublicGift{}, fmt.Errorf("find gift items: %w", err)
}
defer rows.Close()
gift.Items = make([]PublicGiftItem, 0)
for rows.Next() {
var (
item PublicGiftItem
title sql.NullString
text sql.NullString
mediaURL sql.NullString
metadata []byte
)
if err := rows.Scan(
&item.ID,
&item.Type,
&title,
&text,
&mediaURL,
&item.SortOrder,
&metadata,
); err != nil {
return PublicGift{}, fmt.Errorf("scan gift item: %w", err)
}
item.Title = title.String
item.Text = text.String
item.MediaURL = mediaURL.String
if len(metadata) == 0 {
item.Metadata = []byte(`{}`)
} else {
item.Metadata = metadata
}
gift.Items = append(gift.Items, item)
}
if err := rows.Err(); err != nil {
return PublicGift{}, fmt.Errorf("iterate gift items: %w", err)
}
return gift, nil
}
// Store is the read contract used by the service. Keeping it small makes the
// HTTP layer straightforward to test without a running database.
type Store interface {
GetPublicBySlug(context.Context, string) (PublicGift, error)
}
var _ Store = (*Repository)(nil)
@@ -0,0 +1,57 @@
package gifts
import (
"context"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
)
func TestRepositoryReadsSQLiteMigrations(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationDirectory := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite")
entries, err := os.ReadDir(migrationDirectory)
if err != nil {
t.Fatalf("read sqlite migrations: %v", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
migration, err := os.ReadFile(filepath.Join(migrationDirectory, entry.Name()))
if err != nil {
t.Fatalf("read migration %s: %v", entry.Name(), err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply migration %s: %v", entry.Name(), err)
}
}
gift, err := NewRepository(database).GetPublicBySlug(ctx, "demo")
if err != nil {
t.Fatalf("load demo gift: %v", err)
}
if gift.RecipientName != "Anna" || gift.SenderName != "Alex" {
t.Fatalf("unexpected gift: %+v", gift)
}
if len(gift.Items) != 3 || gift.Items[0].Type != "image" || gift.Items[1].SortOrder != 2 {
t.Fatalf("unexpected gift items: %+v", gift.Items)
}
}
+33
View File
@@ -0,0 +1,33 @@
package gifts
import (
"context"
"errors"
"fmt"
)
var ErrNotFound = errors.New("gift not found")
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) GetPublicGift(ctx context.Context, slug string) (PublicGift, error) {
if slug == "" {
return PublicGift{}, ErrNotFound
}
gift, err := s.store.GetPublicBySlug(ctx, slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("get public gift: %w", err)
}
return gift, nil
}
+355
View File
@@ -0,0 +1,355 @@
package media
import (
"context"
"encoding/json"
"fmt"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
const (
maxUploadSize = int64(10 << 30)
uploadURLDuration = 24 * time.Hour
)
type ProcessorQueue interface {
Enqueue(uuid.UUID)
}
type Handler struct {
repository *Repository
storage storage.Storage
processor ProcessorQueue
auth *auth.Service
}
func NewHandler(repository *Repository, objectStorage storage.Storage, processor ProcessorQueue, authService *auth.Service) *Handler {
return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List)))
mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload)))
mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload)))
mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update)))
mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download)))
mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete)))
mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete)))
}
type uploadRequest struct {
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
FileSize int64 `json:"fileSize"`
}
type updateRequest struct {
SortOrder *int `json:"sortOrder"`
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
galleryID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id")
return
}
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
items, err := h.repository.ListByGallery(r.Context(), galleryID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load media")
return
}
views := make([]Public, 0, len(items))
for _, item := range items {
view, err := h.view(r.Context(), item, true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
views = append(views, view)
}
writeJSON(w, http.StatusOK, map[string]any{"media": views})
}
func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
galleryID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id")
return
}
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
var request uploadRequest
if !decodeJSON(w, r, &request) {
return
}
filename := safeFilename(request.Filename)
mimeType := strings.ToLower(strings.TrimSpace(request.MimeType))
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(filename))
}
if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) {
writeError(w, http.StatusBadRequest, "unsupported media file")
return
}
if request.FileSize <= 0 || request.FileSize > maxUploadSize {
writeError(w, http.StatusBadRequest, "file size must be between 1 byte and 10 GB")
return
}
mediaID := uuid.New()
storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename)
item, err := h.repository.Create(r.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create upload")
return
}
uploadURL, err := h.storage.CreateUploadURL(r.Context(), storageKey, mimeType, uploadURLDuration)
if err != nil {
_, _ = h.repository.Delete(r.Context(), user.ID, mediaID)
writeError(w, http.StatusInternalServerError, "could not create upload URL")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"uploadId": mediaID.String(),
"uploadUrl": uploadURL,
"media": publicFromRecord(item),
})
}
func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
if err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
info, err := h.storage.Stat(r.Context(), item.StorageKey)
if err != nil {
writeError(w, http.StatusBadRequest, "uploaded object is not available yet")
return
}
item, err = h.repository.Complete(r.Context(), user.ID, mediaID, info.Size)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not complete upload")
return
}
h.processor.Enqueue(item.ID)
writeJSON(w, http.StatusOK, map[string]any{"media": publicFromRecord(item)})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
var request updateRequest
if !decodeJSON(w, r, &request) {
return
}
if request.SortOrder == nil || *request.SortOrder < 0 {
writeError(w, http.StatusBadRequest, "sort order must be zero or greater")
return
}
if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID)
view, err := h.view(r.Context(), item, true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URL")
return
}
writeJSON(w, http.StatusOK, map[string]any{"media": view})
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.Delete(r.Context(), user.ID, mediaID)
if err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} {
if key != "" && key != item.ExternalURL {
_ = h.storage.Delete(r.Context(), key)
}
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
if err != nil || item.ProcessingStatus != StatusReady {
writeError(w, http.StatusNotFound, "media not found")
return
}
url := item.ExternalURL
if url == "" {
url, err = h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download")
return
}
}
_ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String())
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) {
view := publicFromRecord(item)
if item.ProcessingStatus != StatusReady {
return view, nil
}
if item.ExternalURL != "" {
view.ThumbnailURL = item.ExternalURL
view.PreviewURL = item.ExternalURL
if includeOriginal {
view.OriginalURL = item.ExternalURL
}
return view, nil
}
previewKey := item.PreviewKey
if previewKey == "" {
previewKey = item.StorageKey
}
thumbnailKey := item.ThumbnailKey
if thumbnailKey == "" {
thumbnailKey = previewKey
}
var err error
view.ThumbnailURL, err = h.storage.CreateDownloadURL(ctx, thumbnailKey, time.Hour)
if err != nil {
return Public{}, err
}
view.PreviewURL, err = h.storage.CreateDownloadURL(ctx, previewKey, time.Hour)
if err != nil {
return Public{}, err
}
if includeOriginal {
view.OriginalURL, err = h.storage.CreateDownloadURL(ctx, item.StorageKey, time.Hour)
if err != nil {
return Public{}, err
}
}
return view, nil
}
func publicFromRecord(item Record) Public {
return Public{
ID: item.ID,
OriginalFilename: item.OriginalFilename,
MimeType: item.MimeType,
FileSize: item.FileSize,
ProcessingStatus: item.ProcessingStatus,
Width: item.Width,
Height: item.Height,
DurationSeconds: item.DurationSeconds,
SortOrder: item.SortOrder,
}
}
func allowedMimeType(value string) bool {
switch value {
case "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "video/mp4", "video/quicktime", "video/webm":
return true
default:
return false
}
}
func safeFilename(value string) string {
value = strings.ReplaceAll(value, "\\", "/")
value = filepath.Base(value)
if value == "." || value == ".." {
return ""
}
return strings.TrimSpace(value)
}
func parseID(value string) (uuid.UUID, error) {
return uuid.Parse(value)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
return true
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+46
View File
@@ -0,0 +1,46 @@
package media
import "github.com/google/uuid"
const (
StatusUploading = "UPLOADING"
StatusProcessing = "PROCESSING"
StatusReady = "READY"
StatusFailed = "FAILED"
)
type Record struct {
ID uuid.UUID
GalleryID uuid.UUID
OriginalFilename string
MimeType string
FileSize int64
StorageKey string
ExternalURL string
ThumbnailKey string
PreviewKey string
ProcessingStatus string
ProcessingError string
Width int
Height int
DurationSeconds float64
SortOrder int
CreatedAt string
UpdatedAt string
}
type Public struct {
ID uuid.UUID `json:"id"`
OriginalFilename string `json:"originalFilename"`
MimeType string `json:"mimeType"`
FileSize int64 `json:"fileSize"`
ProcessingStatus string `json:"processingStatus"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
SortOrder int `json:"sortOrder"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
OriginalURL string `json:"originalUrl,omitempty"`
Favorited bool `json:"favorited"`
}
+154
View File
@@ -0,0 +1,154 @@
package media
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
_ "image/png"
"io"
"sync"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Processor struct {
repository *Repository
storage storage.Storage
jobs chan uuid.UUID
stop chan struct{}
waitGroup sync.WaitGroup
}
func NewProcessor(repository *Repository, objectStorage storage.Storage, workers int) *Processor {
if workers < 1 {
workers = 1
}
processor := &Processor{
repository: repository,
storage: objectStorage,
jobs: make(chan uuid.UUID, 256),
stop: make(chan struct{}),
}
for index := 0; index < workers; index++ {
processor.waitGroup.Add(1)
go processor.worker()
}
return processor
}
func (p *Processor) Enqueue(mediaID uuid.UUID) {
select {
case p.jobs <- mediaID:
case <-p.stop:
}
}
func (p *Processor) Close() {
close(p.stop)
p.waitGroup.Wait()
}
func (p *Processor) worker() {
defer p.waitGroup.Done()
for {
select {
case mediaID := <-p.jobs:
p.process(mediaID)
case <-p.stop:
return
}
}
}
func (p *Processor) process(mediaID uuid.UUID) {
ctx := context.Background()
item, err := p.repository.GetByID(ctx, mediaID)
if err != nil {
return
}
if item.ExternalURL != "" || !IsImage(item) {
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height)
return
}
object, err := p.storage.Get(ctx, item.StorageKey)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
defer object.Close()
decoded, _, err := image.Decode(io.LimitReader(object, 100<<20))
if err != nil {
// Formats without a stdlib decoder, such as HEIC, remain usable through
// the original object until a dedicated processing service is added.
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, 0, 0)
return
}
width := decoded.Bounds().Dx()
height := decoded.Bounds().Dy()
previewKey := variantKey(item, "preview.jpg")
thumbnailKey := variantKey(item, "thumbnail.jpg")
preview, err := encodeJPEG(resize(decoded, 2400), 88)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
thumbnail, err := encodeJPEG(resize(decoded, 640), 84)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/jpeg"); err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.storage.Put(ctx, thumbnailKey, bytes.NewReader(thumbnail), int64(len(thumbnail)), "image/jpeg"); err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.repository.MarkReady(ctx, mediaID, previewKey, thumbnailKey, width, height); err != nil {
return
}
}
func variantKey(item Record, filename string) string {
return fmt.Sprintf("galleries/%s/%s/%s", item.GalleryID, item.ID, filename)
}
func resize(source image.Image, maxSide int) image.Image {
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxSide && height <= maxSide {
return source
}
scale := float64(maxSide) / float64(width)
if height > width {
scale = float64(maxSide) / float64(height)
}
newWidth := int(float64(width) * scale)
newHeight := int(float64(height) * scale)
destination := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
for y := 0; y < newHeight; y++ {
for x := 0; x < newWidth; x++ {
sourceX := bounds.Min.X + x*width/newWidth
sourceY := bounds.Min.Y + y*height/newHeight
destination.Set(x, y, source.At(sourceX, sourceY))
}
}
return destination
}
func encodeJPEG(source image.Image, quality int) ([]byte, error) {
var output bytes.Buffer
if err := jpeg.Encode(&output, source, &jpeg.Options{Quality: quality}); err != nil {
return nil, fmt.Errorf("encode preview: %w", err)
}
return output.Bytes(), nil
}
+225
View File
@@ -0,0 +1,225 @@
package media
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("media not found")
func (r *Repository) Create(ctx context.Context, galleryID, id uuid.UUID, filename, mimeType string, fileSize int64, storageKey string) (Record, error) {
_, err := r.db.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE((SELECT MAX(sort_order) + 1 FROM media WHERE gallery_id = $2), 0))
`, id, galleryID, filename, mimeType, fileSize, storageKey, StatusUploading)
if err != nil {
return Record{}, fmt.Errorf("create media: %w", err)
}
return r.GetByID(ctx, id)
}
func (r *Repository) GalleryBelongsToUser(ctx context.Context, galleryID, userID uuid.UUID) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM galleries WHERE id = $1 AND user_id = $2 AND status <> 'archived')
`, galleryID, userID).Scan(&exists)
return exists, err
}
func (r *Repository) GetByID(ctx context.Context, id uuid.UUID) (Record, error) {
return r.get(ctx, `
SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key,
external_url, thumbnail_key, preview_key, processing_status, processing_error,
width, height, duration_seconds, sort_order, created_at, updated_at
FROM media
WHERE id = $1
`, id)
}
func (r *Repository) GetForUser(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) {
return r.get(ctx, `
SELECT m.id, m.gallery_id, m.original_filename, m.mime_type, m.file_size, m.storage_key,
m.external_url, m.thumbnail_key, m.preview_key, m.processing_status, m.processing_error,
m.width, m.height, m.duration_seconds, m.sort_order, m.created_at, m.updated_at
FROM media m
JOIN galleries g ON g.id = m.gallery_id
WHERE m.id = $1 AND g.user_id = $2
`, mediaID, userID)
}
func (r *Repository) ListByGallery(ctx context.Context, galleryID uuid.UUID) ([]Record, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key,
external_url, thumbnail_key, preview_key, processing_status, processing_error,
width, height, duration_seconds, sort_order, created_at, updated_at
FROM media
WHERE gallery_id = $1
ORDER BY sort_order ASC, id ASC
`, galleryID)
if err != nil {
return nil, fmt.Errorf("list media: %w", err)
}
defer rows.Close()
result := make([]Record, 0)
for rows.Next() {
item, err := scanMedia(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate media: %w", err)
}
return result, nil
}
func (r *Repository) Complete(ctx context.Context, userID, mediaID uuid.UUID, fileSize int64) (Record, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE media
SET file_size = $1, processing_status = $2, processing_error = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $4)
`, fileSize, StatusProcessing, mediaID, userID)
if err != nil {
return Record{}, fmt.Errorf("complete media upload: %w", err)
}
if count, _ := result.RowsAffected(); count == 0 {
return Record{}, ErrNotFound
}
return r.GetByID(ctx, mediaID)
}
func (r *Repository) MarkReady(ctx context.Context, mediaID uuid.UUID, previewKey, thumbnailKey string, width, height int) error {
_, err := r.db.ExecContext(ctx, `
UPDATE media
SET processing_status = $1, processing_error = NULL, preview_key = $2, thumbnail_key = $3,
width = $4, height = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
`, StatusReady, previewKey, thumbnailKey, width, height, mediaID)
return err
}
func (r *Repository) MarkFailed(ctx context.Context, mediaID uuid.UUID, message string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE media
SET processing_status = $1, processing_error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusFailed, message, mediaID)
return err
}
func (r *Repository) UpdateSortOrder(ctx context.Context, userID, mediaID uuid.UUID, sortOrder int) error {
result, err := r.db.ExecContext(ctx, `
UPDATE media
SET sort_order = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $3)
`, sortOrder, mediaID, userID)
if err != nil {
return fmt.Errorf("update media order: %w", err)
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) Delete(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) {
item, err := r.GetForUser(ctx, userID, mediaID)
if err != nil {
return Record{}, err
}
if _, err := r.db.ExecContext(ctx, `DELETE FROM media WHERE id = $1`, mediaID); err != nil {
return Record{}, fmt.Errorf("delete media: %w", err)
}
return item, nil
}
func (r *Repository) IsFavorited(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3)
`, galleryID, mediaID, visitorID).Scan(&exists)
return exists, err
}
func (r *Repository) SetFavorite(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string, favorited bool) error {
if favorited {
_, err := r.db.ExecContext(ctx, `
INSERT INTO favorites (id, gallery_id, media_id, visitor_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (gallery_id, media_id, visitor_id) DO NOTHING
`, uuid.New(), galleryID, mediaID, visitorID)
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3`, galleryID, mediaID, visitorID)
return err
}
func (r *Repository) RecordDownload(ctx context.Context, galleryID uuid.UUID, mediaID *uuid.UUID, visitorID string) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO downloads (id, gallery_id, media_id, visitor_id)
VALUES ($1, $2, $3, $4)
`, uuid.New(), galleryID, mediaID, visitorID)
return err
}
type rowScanner interface {
Scan(...any) error
}
func (r *Repository) get(ctx context.Context, query string, args ...any) (Record, error) {
return scanMedia(r.db.QueryRowContext(ctx, query, args...))
}
func scanMedia(row rowScanner) (Record, error) {
var (
item Record
externalURL, thumbnailKey, previewKey, processingError sql.NullString
width, height sql.NullInt64
duration sql.NullFloat64
createdAt, updatedAt sql.NullString
)
err := row.Scan(
&item.ID, &item.GalleryID, &item.OriginalFilename, &item.MimeType, &item.FileSize,
&item.StorageKey, &externalURL, &thumbnailKey, &previewKey, &item.ProcessingStatus,
&processingError, &width, &height, &duration, &item.SortOrder, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, fmt.Errorf("scan media: %w", err)
}
item.ExternalURL = externalURL.String
item.ThumbnailKey = thumbnailKey.String
item.PreviewKey = previewKey.String
item.ProcessingError = processingError.String
item.Width = int(width.Int64)
item.Height = int(height.Int64)
item.DurationSeconds = duration.Float64
item.CreatedAt = createdAt.String
item.UpdatedAt = updatedAt.String
return item, nil
}
func IsImage(item Record) bool {
return strings.HasPrefix(strings.ToLower(item.MimeType), "image/")
}
func IsVideo(item Record) bool {
return strings.HasPrefix(strings.ToLower(item.MimeType), "video/")
}
+143
View File
@@ -0,0 +1,143 @@
package storage
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/cors"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type Config struct {
Endpoint string
AccessKey string
SecretKey string
Bucket string
UseSSL bool
CORSOrigins string
}
type ObjectInfo struct {
Size int64
ContentType string
}
// Storage is deliberately S3-shaped so the MinIO implementation can be
// replaced by AWS S3, R2, B2, or another compatible provider later.
type Storage interface {
EnsureBucket(context.Context) error
CreateUploadURL(context.Context, string, string, time.Duration) (string, error)
CreateDownloadURL(context.Context, string, time.Duration) (string, error)
Delete(context.Context, string) error
Stat(context.Context, string) (ObjectInfo, error)
Get(context.Context, string) (io.ReadCloser, error)
Put(context.Context, string, io.Reader, int64, string) error
}
type MinIO struct {
client *minio.Client
bucket string
corsOrigins string
}
func NewMinIO(config Config) (*MinIO, error) {
client, err := minio.New(config.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(config.AccessKey, config.SecretKey, ""),
Secure: config.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("create object storage client: %w", err)
}
if config.Bucket == "" {
return nil, fmt.Errorf("object storage bucket is required")
}
return &MinIO{client: client, bucket: config.Bucket, corsOrigins: config.CORSOrigins}, nil
}
func (s *MinIO) EnsureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
return fmt.Errorf("check object storage bucket: %w", err)
}
if exists {
return nil
}
if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil {
response := minio.ToErrorResponse(err)
if response.Code != "BucketAlreadyExists" && response.Code != "BucketAlreadyOwnedByYou" {
return fmt.Errorf("create object storage bucket: %w", err)
}
}
origins := make([]string, 0)
for _, origin := range strings.Split(s.corsOrigins, ",") {
if value := strings.TrimSpace(origin); value != "" {
origins = append(origins, value)
}
}
if len(origins) == 0 {
origins = []string{"*"}
}
if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{
ID: "northline-browser-uploads",
AllowedOrigin: origins,
AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"},
AllowedHeader: []string{"*"},
ExposeHeader: []string{"ETag", "x-amz-request-id", "x-amz-id-2"},
MaxAgeSeconds: 3600,
}})); err != nil {
return fmt.Errorf("configure object storage CORS: %w", err)
}
return nil
}
func (s *MinIO) CreateUploadURL(ctx context.Context, key, _ string, expiry time.Duration) (string, error) {
url, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry)
if err != nil {
return "", fmt.Errorf("create upload URL: %w", err)
}
return url.String(), nil
}
func (s *MinIO) CreateDownloadURL(ctx context.Context, key string, expiry time.Duration) (string, error) {
url, err := s.client.PresignedGetObject(ctx, s.bucket, key, expiry, nil)
if err != nil {
return "", fmt.Errorf("create download URL: %w", err)
}
return url.String(), nil
}
func (s *MinIO) Delete(ctx context.Context, key string) error {
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
func (s *MinIO) Stat(ctx context.Context, key string) (ObjectInfo, error) {
info, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
if err != nil {
return ObjectInfo{}, fmt.Errorf("stat object: %w", err)
}
return ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil
}
func (s *MinIO) Get(ctx context.Context, key string) (io.ReadCloser, error) {
object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("get object: %w", err)
}
return object, nil
}
func (s *MinIO) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
if _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ContentType: contentType}); err != nil {
return fmt.Errorf("put object: %w", err)
}
return nil
}
var _ Storage = (*MinIO)(nil)