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>
123 lines
2.9 KiB
Go
123 lines
2.9 KiB
Go
// Package store owns all database access. Photo tables use the photos_
|
|
// prefix and are migrated by this service alone; the handful of reads
|
|
// against Drizzle-owned tables (users, events, tickets, payments) are
|
|
// confined to access.go.
|
|
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
_ "modernc.org/sqlite"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
|
)
|
|
|
|
const (
|
|
Postgres = "postgres"
|
|
SQLite = "sqlite"
|
|
|
|
// timeLayout is a fixed-width UTC format so SQLite text timestamps
|
|
// sort correctly; the backend stores ISO strings in SQLite the same way.
|
|
timeLayout = "2006-01-02T15:04:05.000Z"
|
|
)
|
|
|
|
type DB struct {
|
|
*sql.DB
|
|
Type string
|
|
}
|
|
|
|
func Open(cfg config.Config) (*DB, error) {
|
|
switch cfg.DBType {
|
|
case Postgres:
|
|
sqlDB, err := sql.Open("pgx", cfg.DatabaseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open postgres: %w", err)
|
|
}
|
|
sqlDB.SetMaxOpenConns(10)
|
|
return &DB{DB: sqlDB, Type: Postgres}, nil
|
|
case SQLite:
|
|
dsn := "file:" + strings.TrimPrefix(cfg.DatabaseURL, "file:") +
|
|
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
|
sqlDB, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
// One writer at a time keeps the shared file friendly to the backend.
|
|
sqlDB.SetMaxOpenConns(1)
|
|
return &DB{DB: sqlDB, Type: SQLite}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
|
|
}
|
|
}
|
|
|
|
// Rebind converts ?-style placeholders to $n for Postgres. Queries in this
|
|
// package never contain literal question marks in strings.
|
|
func (db *DB) Rebind(query string) string {
|
|
if db.Type != Postgres {
|
|
return query
|
|
}
|
|
var b strings.Builder
|
|
n := 0
|
|
for _, r := range query {
|
|
if r == '?' {
|
|
n++
|
|
fmt.Fprintf(&b, "$%d", n)
|
|
} else {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// TimeArg converts a time for binding: time.Time for Postgres (timestamptz
|
|
// columns), fixed-layout UTC text for SQLite (text columns).
|
|
func (db *DB) TimeArg(t time.Time) any {
|
|
if db.Type == Postgres {
|
|
return t.UTC()
|
|
}
|
|
return t.UTC().Format(timeLayout)
|
|
}
|
|
|
|
// asString normalizes scanned values across drivers.
|
|
func asString(v any) string {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return x
|
|
case []byte:
|
|
return string(x)
|
|
case time.Time:
|
|
return x.UTC().Format(timeLayout)
|
|
default:
|
|
return fmt.Sprintf("%v", x)
|
|
}
|
|
}
|
|
|
|
// asTime parses a scanned timestamp from either driver; zero time on failure.
|
|
func asTime(v any) time.Time {
|
|
switch x := v.(type) {
|
|
case time.Time:
|
|
return x.UTC()
|
|
case string:
|
|
return parseTime(x)
|
|
case []byte:
|
|
return parseTime(string(x))
|
|
default:
|
|
return time.Time{}
|
|
}
|
|
}
|
|
|
|
func parseTime(s string) time.Time {
|
|
for _, layout := range []string{timeLayout, time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t.UTC()
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|