init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = ¤t.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 ©
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user