Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
// accessDenial describes why a viewer may not see a gallery. Non-public
|
||||
// galleries return 404 (not 403) so their existence is not probeable — the
|
||||
// exception is ticket mode, whose 401/403 lets the frontend prompt login
|
||||
// or explain the attendee requirement (its existence is already public via
|
||||
// the event).
|
||||
type accessDenial struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
// authorize is the single access-control decision point (PLAN.md §7); it is
|
||||
// called from both the gallery-view handler and the file handler so every
|
||||
// byte served re-checks.
|
||||
func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, token string) *accessDenial {
|
||||
if user != nil && user.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
switch g.Visibility {
|
||||
case store.VisibilityPublic:
|
||||
return nil
|
||||
case store.VisibilityLink:
|
||||
if token != "" && token == g.ShareToken {
|
||||
return nil
|
||||
}
|
||||
return &accessDenial{http.StatusNotFound, "Gallery not found"}
|
||||
case store.VisibilityTicket:
|
||||
// The share token is honored as an escape hatch (e.g. attendee +1s
|
||||
// without accounts, at the admin's discretion).
|
||||
if token != "" && token == g.ShareToken {
|
||||
return nil
|
||||
}
|
||||
if user == nil {
|
||||
return &accessDenial{http.StatusUnauthorized, "Authentication required"}
|
||||
}
|
||||
if g.EventID == "" {
|
||||
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
|
||||
}
|
||||
ok, err := s.db.HasPaidTicket(r.Context(), user.ID, g.EventID)
|
||||
if err != nil || !ok {
|
||||
return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"}
|
||||
}
|
||||
return nil
|
||||
default: // private
|
||||
return &accessDenial{http.StatusNotFound, "Gallery not found"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
|
||||
)
|
||||
|
||||
const testSecret = "test-secret"
|
||||
|
||||
// Seed ids are UUID-formatted because the Postgres dialect uses uuid columns.
|
||||
const (
|
||||
uAdmin = "11111111-1111-1111-1111-111111111111"
|
||||
uMember = "22222222-2222-2222-2222-222222222222"
|
||||
uBuyer = "33333333-3333-3333-3333-333333333333"
|
||||
uPending = "44444444-4444-4444-4444-444444444444"
|
||||
uFree = "55555555-5555-5555-5555-555555555555"
|
||||
evPaid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
evFree = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
handler http.Handler
|
||||
db *store.DB
|
||||
worker *worker.Worker
|
||||
}
|
||||
|
||||
// setup migrates a scratch DB (SQLite by default; Postgres when
|
||||
// PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the
|
||||
// main app schema that access.go reads, and returns a ready handler.
|
||||
func setup(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Port: 0,
|
||||
DBType: "sqlite",
|
||||
DatabaseURL: filepath.Join(dir, "test.db"),
|
||||
JWTSecret: testSecret,
|
||||
StoragePath: filepath.Join(dir, "photos"),
|
||||
MaxUploadMB: 5,
|
||||
WorkerConcurrency: 1,
|
||||
FrontendURL: "http://localhost:3000",
|
||||
}
|
||||
if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" {
|
||||
cfg.DBType = "postgres"
|
||||
cfg.DatabaseURL = pgURL
|
||||
}
|
||||
db, err := store.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if cfg.DBType == "postgres" {
|
||||
// The scratch Postgres DB persists across tests; start clean.
|
||||
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idType := "text"
|
||||
if cfg.DBType == "postgres" {
|
||||
idType = "uuid"
|
||||
}
|
||||
seed := []string{
|
||||
`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`,
|
||||
`CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`,
|
||||
`CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`,
|
||||
`CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`,
|
||||
|
||||
`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`,
|
||||
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`,
|
||||
|
||||
`INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`,
|
||||
`INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`,
|
||||
|
||||
`INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`,
|
||||
`INSERT INTO payments VALUES ('p1','t1','paid')`,
|
||||
`INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`,
|
||||
`INSERT INTO payments VALUES ('p2','t2','pending')`,
|
||||
`INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`,
|
||||
}
|
||||
for _, q := range seed {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatalf("seed %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := storage.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrk := worker.New(db, st, nil, cfg.StoragePath, 1)
|
||||
srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk)
|
||||
return &testEnv{handler: srv.Handler(), db: db, worker: wrk}
|
||||
}
|
||||
|
||||
func makeToken(t *testing.T, sub, email, role string) string {
|
||||
t.Helper()
|
||||
tv := 0
|
||||
claims := jwt.MapClaims{
|
||||
"sub": sub, "email": email, "role": role, "tokenVersion": tv,
|
||||
"iss": "spanglish", "aud": "spanglish-app",
|
||||
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(b)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
|
||||
t.Fatalf("decode %s: %v", w.Body.String(), err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func testJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 800, 600))
|
||||
for x := 0; x < 800; x += 10 {
|
||||
for y := 0; y < 600; y++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
e := setup(t)
|
||||
if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 {
|
||||
t.Fatalf("health: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGate(t *testing.T) {
|
||||
e := setup(t)
|
||||
body := map[string]string{"title": "Test"}
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 {
|
||||
t.Fatalf("anon create: want 401, got %d", w.Code)
|
||||
}
|
||||
member := makeToken(t, uMember, "m@x.py", "user")
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 {
|
||||
t.Fatalf("member create: want 403, got %d", w.Code)
|
||||
}
|
||||
unknown := makeToken(t, newID(), "g@x.py", "admin")
|
||||
if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 {
|
||||
t.Fatalf("unknown-user token: want 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type galleryResp struct {
|
||||
Gallery galleryJSON `json:"gallery"`
|
||||
Photos []photoJSON `json:"photos"`
|
||||
}
|
||||
|
||||
func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON {
|
||||
t.Helper()
|
||||
w := e.request(t, "POST", "/api/photos/galleries", admin, body)
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("create gallery: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
return decode[galleryResp](t, w).Gallery
|
||||
}
|
||||
|
||||
func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("files", "test photo.jpg")
|
||||
fw.Write(file)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+admin)
|
||||
w := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(w, req)
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("upload: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
photos := decode[struct {
|
||||
Photos []photoJSON `json:"photos"`
|
||||
}](t, w).Photos
|
||||
if len(photos) != 1 {
|
||||
t.Fatalf("want 1 photo, got %d", len(photos))
|
||||
}
|
||||
return photos[0]
|
||||
}
|
||||
|
||||
// processQueue runs the worker until the photo is ready or failed.
|
||||
func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
e.worker.Run(ctx)
|
||||
e.worker.Nudge()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
p, err := e.db.GetPhoto(ctx, photoID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) {
|
||||
return p
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timeout waiting for processing")
|
||||
return store.Photo{}
|
||||
}
|
||||
|
||||
func TestUploadProcessAndServe(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"})
|
||||
if g.Slug != "fiesta-de-junio" {
|
||||
t.Fatalf("slug: %q", g.Slug)
|
||||
}
|
||||
// Public galleries share a clean URL: the token grants nothing there.
|
||||
if g.ShareToken == "" {
|
||||
t.Fatalf("share token missing: %+v", g)
|
||||
}
|
||||
if strings.Contains(g.ShareURL, "token=") {
|
||||
t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL)
|
||||
}
|
||||
if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) {
|
||||
t.Fatalf("public share url: %s", g.ShareURL)
|
||||
}
|
||||
|
||||
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||
if p.Status != "queued" || p.ContentType != "image/jpeg" {
|
||||
t.Fatalf("uploaded photo: %+v", p)
|
||||
}
|
||||
done := processQueue(t, e, p.ID)
|
||||
if done.Status != "ready" {
|
||||
t.Fatalf("processing failed: %s", done.LastError)
|
||||
}
|
||||
if done.Width != 800 || done.Height != 600 {
|
||||
t.Fatalf("dimensions: %dx%d", done.Width, done.Height)
|
||||
}
|
||||
|
||||
for _, variant := range []string{"thumb", "preview", "original"} {
|
||||
w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s: %d", variant, w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
|
||||
t.Fatalf("%s content type: %s", variant, ct)
|
||||
}
|
||||
}
|
||||
w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil)
|
||||
if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") {
|
||||
t.Fatalf("original disposition: %q", cd)
|
||||
}
|
||||
|
||||
// Bad upload is rejected by magic bytes.
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("files", "notes.txt")
|
||||
fw.Write([]byte("not an image at all"))
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+admin)
|
||||
rec := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 415 {
|
||||
t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMatrix(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
member := makeToken(t, uMember, "m@x.py", "user")
|
||||
buyer := makeToken(t, uBuyer, "b@x.py", "user")
|
||||
pending := makeToken(t, uPending, "p@x.py", "user")
|
||||
freeguy := makeToken(t, uFree, "f@x.py", "user")
|
||||
|
||||
get := func(slug, token, bearer string) int {
|
||||
path := "/api/photos/public/galleries/" + slug
|
||||
if token != "" {
|
||||
path += "?token=" + token
|
||||
}
|
||||
return e.request(t, "GET", path, bearer, nil).Code
|
||||
}
|
||||
|
||||
// private
|
||||
priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"})
|
||||
for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member),
|
||||
"with-token": get(priv.Slug, priv.ShareToken, "")} {
|
||||
if code != 404 {
|
||||
t.Errorf("private/%s: want 404, got %d", name, code)
|
||||
}
|
||||
}
|
||||
if got := get(priv.Slug, "", admin); got != 200 {
|
||||
t.Errorf("private/admin: want 200, got %d", got)
|
||||
}
|
||||
|
||||
// link
|
||||
link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"})
|
||||
if got := get(link.Slug, "", ""); got != 404 {
|
||||
t.Errorf("link/anon: want 404, got %d", got)
|
||||
}
|
||||
if got := get(link.Slug, "wrong-token", ""); got != 404 {
|
||||
t.Errorf("link/bad-token: want 404, got %d", got)
|
||||
}
|
||||
if got := get(link.Slug, link.ShareToken, ""); got != 200 {
|
||||
t.Errorf("link/token: want 200, got %d", got)
|
||||
}
|
||||
|
||||
// ticket, paid event
|
||||
tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid})
|
||||
if got := get(tick.Slug, "", ""); got != 401 {
|
||||
t.Errorf("ticket/anon: want 401, got %d", got)
|
||||
}
|
||||
if got := get(tick.Slug, "", member); got != 403 {
|
||||
t.Errorf("ticket/no-ticket: want 403, got %d", got)
|
||||
}
|
||||
if got := get(tick.Slug, "", pending); got != 403 {
|
||||
t.Errorf("ticket/pending-payment: want 403, got %d", got)
|
||||
}
|
||||
if got := get(tick.Slug, "", buyer); got != 200 {
|
||||
t.Errorf("ticket/paid: want 200, got %d", got)
|
||||
}
|
||||
if got := get(tick.Slug, tick.ShareToken, ""); got != 200 {
|
||||
t.Errorf("ticket/share-token: want 200, got %d", got)
|
||||
}
|
||||
|
||||
// ticket, free event: any confirmed ticket counts (review decision)
|
||||
free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree})
|
||||
if got := get(free.Slug, "", freeguy); got != 200 {
|
||||
t.Errorf("ticket/free-event-confirmed: want 200, got %d", got)
|
||||
}
|
||||
if got := get(free.Slug, "", member); got != 403 {
|
||||
t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got)
|
||||
}
|
||||
|
||||
// public index lists only public galleries
|
||||
pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"})
|
||||
idx := decode[struct {
|
||||
Galleries []galleryJSON `json:"galleries"`
|
||||
}](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil))
|
||||
if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID {
|
||||
t.Errorf("public index: %+v", idx.Galleries)
|
||||
}
|
||||
for _, g := range idx.Galleries {
|
||||
if g.ShareToken != "" {
|
||||
t.Errorf("public index leaks share token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderAndVisibilityUpdate(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"})
|
||||
jpg := testJPEG(t)
|
||||
p1 := uploadPhoto(t, e, admin, g.ID, jpg)
|
||||
p2 := uploadPhoto(t, e, admin, g.ID, jpg)
|
||||
|
||||
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
|
||||
map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 {
|
||||
t.Fatalf("reorder: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
|
||||
if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID {
|
||||
t.Fatalf("order not applied: %+v", detail.Photos)
|
||||
}
|
||||
|
||||
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
|
||||
map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 {
|
||||
t.Fatalf("partial reorder: want 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
|
||||
map[string]any{"visibility": "banana"}); w.Code != 400 {
|
||||
t.Fatalf("bad visibility: want 400, got %d", w.Code)
|
||||
}
|
||||
if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
|
||||
map[string]any{"visibility": "link"}); w.Code != 200 {
|
||||
t.Fatalf("set link: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 404 {
|
||||
t.Fatalf("after switch to link, anon: want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventGalleryRoute(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
buyer := makeToken(t, uBuyer, "b@x.py", "user")
|
||||
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid})
|
||||
|
||||
// Event-linked galleries share via the event URL.
|
||||
if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) {
|
||||
t.Fatalf("share url should use event route: %s", g.ShareURL)
|
||||
}
|
||||
|
||||
// /public/events/{slug}/gallery obeys the same access rules.
|
||||
if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 {
|
||||
t.Fatalf("event gallery anon: want 401, got %d", w.Code)
|
||||
}
|
||||
w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID {
|
||||
t.Fatalf("wrong gallery: %s != %s", got, g.ID)
|
||||
}
|
||||
if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 {
|
||||
t.Fatalf("event gallery share token: want 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Unknown event slug and event without a gallery are both 404.
|
||||
if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 {
|
||||
t.Fatalf("unknown event: want 404, got %d", w.Code)
|
||||
}
|
||||
if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 {
|
||||
t.Fatalf("event without gallery: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// A standalone gallery keeps the /photos share URL; link mode carries
|
||||
// the token, public mode does not.
|
||||
solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"})
|
||||
if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") {
|
||||
t.Fatalf("standalone share url: %s", solo.ShareURL)
|
||||
}
|
||||
pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree})
|
||||
if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") {
|
||||
t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugCollision(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := makeToken(t, uAdmin, "a@x.py", "admin")
|
||||
a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
|
||||
b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
|
||||
if a.Slug == b.Slug {
|
||||
t.Fatalf("slug collision: %s", a.Slug)
|
||||
}
|
||||
if b.Slug != "misma-fiesta-2" {
|
||||
t.Fatalf("second slug: %s", b.Slug)
|
||||
}
|
||||
if a.Visibility != "private" {
|
||||
t.Fatalf("default visibility: %s", a.Visibility)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
// JSON shapes are camelCase like the backend's responses.
|
||||
|
||||
type eventJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
TitleEs string `json:"titleEs,omitempty"`
|
||||
StartDatetime string `json:"startDatetime"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type galleryJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
TitleEs string `json:"titleEs,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DescriptionEs string `json:"descriptionEs,omitempty"`
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
Visibility string `json:"visibility"`
|
||||
CoverPhotoID string `json:"coverPhotoId,omitempty"`
|
||||
PhotoCount int `json:"photoCount"`
|
||||
CoverURL string `json:"coverUrl,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Event *eventJSON `json:"event,omitempty"`
|
||||
// Admin-only fields:
|
||||
ShareToken string `json:"shareToken,omitempty"`
|
||||
ShareURL string `json:"shareUrl,omitempty"`
|
||||
}
|
||||
|
||||
type photoURLs struct {
|
||||
Thumb string `json:"thumb,omitempty"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
Original string `json:"original"`
|
||||
}
|
||||
|
||||
type photoJSON struct {
|
||||
ID string `json:"id"`
|
||||
GalleryID string `json:"galleryId"`
|
||||
Position int `json:"position"`
|
||||
OriginalFilename string `json:"originalFilename,omitempty"`
|
||||
ContentType string `json:"contentType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
TakenAt string `json:"takenAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"` // admin only
|
||||
CreatedAt string `json:"createdAt"`
|
||||
URLs photoURLs `json:"urls"`
|
||||
}
|
||||
|
||||
func isoTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
|
||||
// fileURL builds the access-checked file endpoint URL; token is appended
|
||||
// for link-mode viewers so the client can use URLs verbatim.
|
||||
func fileURL(photoID, variant, token string) string {
|
||||
u := "/api/photos/files/" + photoID + "/" + variant
|
||||
if token != "" {
|
||||
u += "?token=" + token
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
|
||||
out := photoJSON{
|
||||
ID: p.ID,
|
||||
GalleryID: p.GalleryID,
|
||||
Position: p.Position,
|
||||
OriginalFilename: p.OriginalFilename,
|
||||
ContentType: p.ContentType,
|
||||
SizeBytes: p.SizeBytes,
|
||||
Width: p.Width,
|
||||
Height: p.Height,
|
||||
TakenAt: isoTime(p.TakenAt),
|
||||
Status: p.Status,
|
||||
CreatedAt: isoTime(p.CreatedAt),
|
||||
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
|
||||
}
|
||||
if p.ThumbKey != "" {
|
||||
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
out.URLs.Preview = fileURL(p.ID, "preview", token)
|
||||
}
|
||||
if admin {
|
||||
out.LastError = p.LastError
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON {
|
||||
out := galleryJSON{
|
||||
ID: g.ID,
|
||||
Slug: g.Slug,
|
||||
Title: g.Title,
|
||||
TitleEs: g.TitleEs,
|
||||
Description: g.Description,
|
||||
DescriptionEs: g.DescriptionEs,
|
||||
EventID: g.EventID,
|
||||
Visibility: g.Visibility,
|
||||
CoverPhotoID: g.CoverPhotoID,
|
||||
PhotoCount: g.PhotoCount,
|
||||
CreatedAt: isoTime(g.CreatedAt),
|
||||
UpdatedAt: isoTime(g.UpdatedAt),
|
||||
}
|
||||
if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" {
|
||||
out.CoverURL = fileURL(coverID, "thumb", token)
|
||||
}
|
||||
if g.EventID != "" {
|
||||
if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil {
|
||||
out.Event = &eventJSON{
|
||||
ID: ev.ID,
|
||||
Slug: ev.Slug,
|
||||
Title: ev.Title,
|
||||
TitleEs: ev.TitleEs,
|
||||
StartDatetime: isoTime(ev.StartDatetime),
|
||||
Status: ev.Status,
|
||||
}
|
||||
}
|
||||
}
|
||||
if admin {
|
||||
out.ShareToken = g.ShareToken
|
||||
// Event-linked galleries live under the event's URL; standalone
|
||||
// galleries keep their own /photos/<slug> URL.
|
||||
page := "/photos/" + g.Slug
|
||||
if out.Event != nil {
|
||||
page = "/events/" + out.Event.Slug + "/gallery"
|
||||
}
|
||||
out.ShareURL = s.cfg.FrontendURL + page
|
||||
// The token only grants anything in link/ticket mode; public and
|
||||
// private galleries get a clean URL.
|
||||
if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket {
|
||||
out.ShareURL += "?token=" + g.ShareToken
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
||||
)
|
||||
|
||||
const presignExpiry = 15 * time.Minute
|
||||
|
||||
// serveFile delivers photo bytes after re-running the gallery access check.
|
||||
// S3: 302 to a short-lived presigned URL; local: streamed directly.
|
||||
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
||||
variant := r.PathValue("variant")
|
||||
if variant != "thumb" && variant != "preview" && variant != "original" {
|
||||
writeError(w, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
user := s.optionalUser(r)
|
||||
token := r.URL.Query().Get("token")
|
||||
if denial := s.authorize(r, g, user, token); denial != nil {
|
||||
writeError(w, denial.status, denial.msg)
|
||||
return
|
||||
}
|
||||
isAdmin := user != nil && user.IsAdmin()
|
||||
|
||||
var key, contentType, downloadName string
|
||||
switch variant {
|
||||
case "thumb":
|
||||
key, contentType = p.ThumbKey, "image/jpeg"
|
||||
case "preview":
|
||||
key, contentType = p.PreviewKey, "image/jpeg"
|
||||
case "original":
|
||||
key, contentType = p.OriginalKey, p.ContentType
|
||||
downloadName = p.OriginalFilename
|
||||
if downloadName == "" {
|
||||
downloadName = p.ID + extForContentType(p.ContentType)
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
// Variant not generated yet (photo still processing).
|
||||
writeError(w, http.StatusNotFound, "Not ready")
|
||||
return
|
||||
}
|
||||
// Non-ready originals stay admin-only so uploads that fail processing
|
||||
// never leak to viewers.
|
||||
if p.Status != "ready" && !isAdmin {
|
||||
writeError(w, http.StatusNotFound, "Not ready")
|
||||
return
|
||||
}
|
||||
|
||||
if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil {
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
return
|
||||
} else if !errors.Is(err, storage.ErrNoPresign) {
|
||||
log.Printf("Error: presign %s: %v", key, err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
reader, size, err := s.storage.Open(r.Context(), key)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeError(w, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
log.Printf("Error: open %s: %v", key, err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// Keys are immutable (new upload = new key), so private caching is safe
|
||||
// even though access is checked per request.
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
if downloadName != "" {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName))
|
||||
}
|
||||
if _, err := io.Copy(w, reader); err != nil {
|
||||
log.Printf("stream %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func extForContentType(ct string) string {
|
||||
switch ct {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/heic":
|
||||
return ".heic"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
func validVisibility(v string) bool {
|
||||
switch v {
|
||||
case store.VisibilityPublic, store.VisibilityPrivate, store.VisibilityLink, store.VisibilityTicket:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type createGalleryBody struct {
|
||||
Title string `json:"title"`
|
||||
TitleEs string `json:"titleEs"`
|
||||
Description string `json:"description"`
|
||||
DescriptionEs string `json:"descriptionEs"`
|
||||
EventID string `json:"eventId"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth.User) {
|
||||
var body createGalleryBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if body.Visibility == "" {
|
||||
body.Visibility = store.VisibilityPrivate
|
||||
}
|
||||
if !validVisibility(body.Visibility) {
|
||||
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
|
||||
return
|
||||
}
|
||||
if body.EventID != "" {
|
||||
if _, err := s.db.GetEventSummary(r.Context(), body.EventID); err != nil {
|
||||
writeStoreError(w, err, "Event not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
slug, err := s.uniqueSlug(r.Context(), body.Title)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
g := store.Gallery{
|
||||
ID: newID(),
|
||||
Slug: slug,
|
||||
Title: body.Title,
|
||||
TitleEs: body.TitleEs,
|
||||
Description: body.Description,
|
||||
DescriptionEs: body.DescriptionEs,
|
||||
EventID: body.EventID,
|
||||
Visibility: body.Visibility,
|
||||
ShareToken: newShareToken(),
|
||||
CreatedBy: user.ID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.db.CreateGallery(r.Context(), g); err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
limit := queryInt(r, "limit", 100)
|
||||
offset := queryInt(r, "offset", 0)
|
||||
galleries, err := s.db.ListGalleries(r.Context(), r.URL.Query().Get("eventId"), limit, offset)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
out := make([]galleryJSON, 0, len(galleries))
|
||||
for _, g := range galleries {
|
||||
out = append(out, s.galleryToJSON(r.Context(), g, "", true))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
|
||||
}
|
||||
|
||||
func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
g, err := s.db.GetGallery(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
photos, err := s.db.ListPhotos(r.Context(), g.ID, false)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
out := make([]photoJSON, 0, len(photos))
|
||||
for _, p := range photos {
|
||||
out = append(out, s.photoToJSON(p, "", true))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
"photos": out,
|
||||
})
|
||||
}
|
||||
|
||||
type updateGalleryBody struct {
|
||||
Title *string `json:"title"`
|
||||
TitleEs *string `json:"titleEs"`
|
||||
Description *string `json:"description"`
|
||||
DescriptionEs *string `json:"descriptionEs"`
|
||||
EventID *string `json:"eventId"`
|
||||
Visibility *string `json:"visibility"`
|
||||
CoverPhotoID *string `json:"coverPhotoId"`
|
||||
}
|
||||
|
||||
func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
id := r.PathValue("id")
|
||||
var body updateGalleryBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Title != nil && *body.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title cannot be empty")
|
||||
return
|
||||
}
|
||||
if body.Visibility != nil && !validVisibility(*body.Visibility) {
|
||||
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
|
||||
return
|
||||
}
|
||||
if body.EventID != nil && *body.EventID != "" {
|
||||
if _, err := s.db.GetEventSummary(r.Context(), *body.EventID); err != nil {
|
||||
writeStoreError(w, err, "Event not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.CoverPhotoID != nil && *body.CoverPhotoID != "" {
|
||||
p, err := s.db.GetPhoto(r.Context(), *body.CoverPhotoID)
|
||||
if err != nil || p.GalleryID != id {
|
||||
writeError(w, http.StatusBadRequest, "coverPhotoId must be a photo of this gallery")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := s.db.UpdateGallery(r.Context(), id, store.GalleryUpdate{
|
||||
Title: body.Title,
|
||||
TitleEs: body.TitleEs,
|
||||
Description: body.Description,
|
||||
DescriptionEs: body.DescriptionEs,
|
||||
EventID: body.EventID,
|
||||
Visibility: body.Visibility,
|
||||
CoverPhotoID: body.CoverPhotoID,
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetGallery(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
id := r.PathValue("id")
|
||||
keys, err := s.db.PhotoKeys(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteGallery(r.Context(), id); err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
// Object cleanup is best-effort after the rows are gone; orphaned
|
||||
// objects are harmless (unreachable) and logged for manual sweep.
|
||||
for _, key := range keys {
|
||||
if err := s.storage.Delete(r.Context(), key); err != nil {
|
||||
log.Printf("delete object %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Gallery deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.db.RotateShareToken(r.Context(), id, newShareToken()); err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetGallery(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
||||
})
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, def int) int {
|
||||
if v := r.URL.Query().Get(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
)
|
||||
|
||||
// requireAdmin mirrors the backend's requireAuth(['admin','organizer']):
|
||||
// 401 {"error":"Unauthorized"} without a valid token, 403
|
||||
// {"error":"Forbidden"} for valid non-admin users.
|
||||
func (s *Server) requireAdmin(next func(http.ResponseWriter, *http.Request, auth.User)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.verifier.FromRequest(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin() {
|
||||
writeError(w, http.StatusForbidden, "Forbidden")
|
||||
return
|
||||
}
|
||||
next(w, r, user)
|
||||
}
|
||||
}
|
||||
|
||||
// optionalUser returns the authenticated user or nil; an invalid token is
|
||||
// treated as anonymous rather than an error, matching how public backend
|
||||
// routes behave.
|
||||
func (s *Server) optionalUser(r *http.Request) *auth.User {
|
||||
user, err := s.verifier.FromRequest(r)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &user
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
// uploadPhotos accepts multipart form data with one or more "files" parts.
|
||||
// Each file is sniffed by magic bytes (client filename/Content-Type are
|
||||
// untrusted, same policy as /api/media/upload), stored as the untouched
|
||||
// original, and queued for variant processing.
|
||||
func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
galleryID := r.PathValue("id")
|
||||
g, err := s.db.GetGallery(r.Context(), galleryID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
|
||||
maxFile := int64(s.cfg.MaxUploadMB) << 20
|
||||
// Generous request ceiling; nginx enforces its own client_max_body_size.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 40*maxFile)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Expected multipart/form-data")
|
||||
return
|
||||
}
|
||||
|
||||
position, err := s.db.NextPosition(r.Context(), g.ID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
var created []photoJSON
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Malformed multipart body")
|
||||
return
|
||||
}
|
||||
if part.FormName() != "files" && part.FormName() != "file" {
|
||||
part.Close()
|
||||
continue
|
||||
}
|
||||
photo, uploadErr := s.saveUpload(r, g, part, position, maxFile)
|
||||
part.Close()
|
||||
if uploadErr != nil {
|
||||
// One bad file fails the request explicitly rather than silently
|
||||
// skipping it; the admin UI uploads files individually.
|
||||
writeError(w, uploadErr.status, uploadErr.msg)
|
||||
return
|
||||
}
|
||||
created = append(created, s.photoToJSON(photo, "", true))
|
||||
position++
|
||||
}
|
||||
|
||||
if len(created) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "No file provided")
|
||||
return
|
||||
}
|
||||
s.worker.Nudge()
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"photos": created})
|
||||
}
|
||||
|
||||
type uploadError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) {
|
||||
head := make([]byte, 16)
|
||||
n, err := io.ReadFull(part, head)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return store.Photo{}, &uploadError{http.StatusBadRequest, "Could not read file"}
|
||||
}
|
||||
head = head[:n]
|
||||
contentType, ext, ok, reason := imaging.Sniff(head)
|
||||
if !ok {
|
||||
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, reason}
|
||||
}
|
||||
if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil {
|
||||
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType,
|
||||
"HEIC uploads need an image converter on the server (install libvips-tools); please upload JPEG instead"}
|
||||
}
|
||||
|
||||
// Spool to a temp file to learn the size before handing to storage
|
||||
// (S3 wants a length; local rename wants a file anyway).
|
||||
tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*")
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
|
||||
size, err := io.Copy(tmp, io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1)))
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
if size > maxFile {
|
||||
return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)}
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
|
||||
photoID := newID()
|
||||
key := fmt.Sprintf("galleries/%s/orig/%s%s", g.ID, photoID, ext)
|
||||
if err := s.storage.Put(r.Context(), key, tmp, size, contentType); err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
photo := store.Photo{
|
||||
ID: photoID,
|
||||
GalleryID: g.ID,
|
||||
Position: position,
|
||||
OriginalKey: key,
|
||||
OriginalFilename: sanitizeFilename(part.FileName()),
|
||||
ContentType: contentType,
|
||||
SizeBytes: size,
|
||||
Status: "queued",
|
||||
NextAttemptAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.db.InsertPhoto(r.Context(), photo); err != nil {
|
||||
s.storage.Delete(r.Context(), key)
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
type reorderBody struct {
|
||||
PhotoIDs []string `json:"photoIds"`
|
||||
}
|
||||
|
||||
func (s *Server) reorderPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
galleryID := r.PathValue("id")
|
||||
var body reorderBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
existing, err := s.db.ListPhotos(r.Context(), galleryID, false)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
writeStoreError(w, store.ErrNotFound, "Gallery not found or empty")
|
||||
return
|
||||
}
|
||||
current := map[string]bool{}
|
||||
for _, p := range existing {
|
||||
current[p.ID] = true
|
||||
}
|
||||
if len(body.PhotoIDs) != len(existing) {
|
||||
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range body.PhotoIDs {
|
||||
if !current[id] || seen[id] {
|
||||
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if err := s.db.ReorderPhotos(r.Context(), galleryID, body.PhotoIDs); err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Order updated"})
|
||||
}
|
||||
|
||||
func (s *Server) deletePhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePhoto(r.Context(), p.ID); err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{p.OriginalKey, p.ThumbKey, p.PreviewKey} {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.storage.Delete(r.Context(), key); err != nil {
|
||||
log.Printf("delete object %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Photo deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) retryPhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
id := r.PathValue("photoId")
|
||||
if err := s.db.RequeuePhoto(r.Context(), id); err != nil {
|
||||
writeStoreError(w, err, "Photo not found or not failed")
|
||||
return
|
||||
}
|
||||
s.worker.Nudge()
|
||||
p, err := s.db.GetPhoto(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"photo": s.photoToJSON(p, "", true)})
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(name)
|
||||
if name == "." || name == "/" {
|
||||
return ""
|
||||
}
|
||||
if len(name) > 200 {
|
||||
name = name[len(name)-200:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
// listPublicGalleries is the public index: only visibility='public'
|
||||
// galleries ever appear here.
|
||||
func (s *Server) listPublicGalleries(w http.ResponseWriter, r *http.Request) {
|
||||
galleries, err := s.db.ListPublicGalleries(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
out := make([]galleryJSON, 0, len(galleries))
|
||||
for _, g := range galleries {
|
||||
out = append(out, s.galleryToJSON(r.Context(), g, "", false))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
|
||||
}
|
||||
|
||||
// getPublicGallery serves a single gallery to any authorized viewer.
|
||||
// ?token= carries the share token for link/ticket modes.
|
||||
func (s *Server) getPublicGallery(w http.ResponseWriter, r *http.Request) {
|
||||
g, err := s.db.GetGalleryBySlug(r.Context(), r.PathValue("slug"))
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
s.respondGalleryView(w, r, g)
|
||||
}
|
||||
|
||||
// getEventGallery serves the newest gallery linked to an event, backing the
|
||||
// /events/{slug}/gallery public page. Same access rules as by-slug.
|
||||
func (s *Server) getEventGallery(w http.ResponseWriter, r *http.Request) {
|
||||
ev, err := s.db.GetEventSummaryBySlug(r.Context(), r.PathValue("eventSlug"))
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetGalleryByEventID(r.Context(), ev.ID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
s.respondGalleryView(w, r, g)
|
||||
}
|
||||
|
||||
func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g store.Gallery) {
|
||||
user := s.optionalUser(r)
|
||||
token := r.URL.Query().Get("token")
|
||||
if denial := s.authorize(r, g, user, token); denial != nil {
|
||||
writeError(w, denial.status, denial.msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Only pass the token through to file URLs when it was the granting
|
||||
// credential, so it doesn't leak into public/ticket responses.
|
||||
urlToken := ""
|
||||
if token != "" && token == g.ShareToken {
|
||||
urlToken = token
|
||||
}
|
||||
|
||||
photos, err := s.db.ListPhotos(r.Context(), g.ID, true)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
out := make([]photoJSON, 0, len(photos))
|
||||
for _, p := range photos {
|
||||
out = append(out, s.photoToJSON(p, urlToken, false))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"gallery": s.galleryToJSON(r.Context(), g, urlToken, false),
|
||||
"photos": out,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
log.Printf("write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError follows the backend's error shape: {"error": string}.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// writeStoreError maps store errors: ErrNotFound -> 404, else 500 with the
|
||||
// backend's generic message (details go to the log, not the client).
|
||||
func writeStoreError(w http.ResponseWriter, err error, notFoundMsg string) {
|
||||
if err == store.ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, notFoundMsg)
|
||||
return
|
||||
}
|
||||
log.Printf("Error: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package httpapi exposes the photo-api HTTP surface under /api/photos,
|
||||
// following the backend's route and response conventions (resource-keyed
|
||||
// success bodies, {"error": string} failures, Bearer auth).
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
db *store.DB
|
||||
storage storage.Storage
|
||||
verifier *auth.Verifier
|
||||
worker *worker.Worker
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *store.DB, st storage.Storage, verifier *auth.Verifier, w *worker.Worker) *Server {
|
||||
return &Server{cfg: cfg, db: db, storage: st, verifier: verifier, worker: w}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
health := func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
mux.HandleFunc("GET /health", health)
|
||||
mux.HandleFunc("GET /api/photos/health", health)
|
||||
|
||||
// Admin surface: role admin|organizer only (mirrors /api/media).
|
||||
mux.HandleFunc("POST /api/photos/galleries", s.requireAdmin(s.createGallery))
|
||||
mux.HandleFunc("GET /api/photos/galleries", s.requireAdmin(s.listGalleries))
|
||||
mux.HandleFunc("GET /api/photos/galleries/{id}", s.requireAdmin(s.getGallery))
|
||||
mux.HandleFunc("PATCH /api/photos/galleries/{id}", s.requireAdmin(s.updateGallery))
|
||||
mux.HandleFunc("DELETE /api/photos/galleries/{id}", s.requireAdmin(s.deleteGallery))
|
||||
mux.HandleFunc("POST /api/photos/galleries/{id}/photos", s.requireAdmin(s.uploadPhotos))
|
||||
mux.HandleFunc("PATCH /api/photos/galleries/{id}/order", s.requireAdmin(s.reorderPhotos))
|
||||
mux.HandleFunc("POST /api/photos/galleries/{id}/share-token", s.requireAdmin(s.rotateShareToken))
|
||||
mux.HandleFunc("DELETE /api/photos/photos/{photoId}", s.requireAdmin(s.deletePhoto))
|
||||
mux.HandleFunc("POST /api/photos/photos/{photoId}/retry", s.requireAdmin(s.retryPhoto))
|
||||
|
||||
// Viewer surface: optional auth, checked per gallery visibility.
|
||||
mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries)
|
||||
mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery)
|
||||
mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery)
|
||||
mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile)
|
||||
|
||||
return s.withCommon(mux)
|
||||
}
|
||||
|
||||
// withCommon adds request logging and a same-spirit CORS allowance as the
|
||||
// backend's cors() (origin = FRONTEND_URL); in production nginx serves
|
||||
// same-origin so this mostly matters in development.
|
||||
func (s *Server) withCommon(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && origin == s.cfg.FrontendURL {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// slugify follows the spirit of backend/src/lib/slugify.ts: lowercase,
|
||||
// ASCII-fold common Spanish characters, dashes for everything else.
|
||||
func slugify(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u", "ü", "u", "ñ", "n",
|
||||
"Á", "a", "É", "e", "Í", "i", "Ó", "o", "Ú", "u", "Ü", "u", "Ñ", "n")
|
||||
s = replacer.Replace(strings.ToLower(strings.TrimSpace(s)))
|
||||
var b strings.Builder
|
||||
prevDash := true // avoids a leading dash
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(r) && r < 128, unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
default:
|
||||
if !prevDash {
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if len(out) > 140 {
|
||||
out = strings.Trim(out[:140], "-")
|
||||
}
|
||||
if out == "" {
|
||||
out = "gallery"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueSlug appends -2, -3, ... until the slug is free.
|
||||
func (s *Server) uniqueSlug(ctx context.Context, title string) (string, error) {
|
||||
base := slugify(title)
|
||||
slug := base
|
||||
for i := 2; ; i++ {
|
||||
exists, err := s.db.SlugExists(ctx, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return slug, nil
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
// newShareToken returns 32 random bytes, base64url (43 chars, no padding).
|
||||
func newShareToken() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic(err) // crypto/rand failure is unrecoverable
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
Reference in New Issue
Block a user