feat: implement core backend API and SvelteKit SPA Full MVP implementation of the weimar media repository: - CLI commands for user CRUD, image deletion, password reset - HTTP API with auth (cookie sessions), upload, browse, download, thumbnail serving, and tag autocomplete - SQLite-backed storage with migrations - Hash-partitioned disk storage with EXIF stripping and thumbnails - SvelteKit SPA with login, upload, browse grid, and image detail pages - Makefile build flow: SvelteKit build -> Go embed -> single binary
jump to
@@ -0,0 +1,212 @@
+# weimar — Agent Guide + +Single-binary media repository for home servers. Go backend with embedded SvelteKit SPA. + +## Essential Commands + +| Command | Purpose | +|---------|---------| +| `make build` | Full build: SvelteKit → embed → Go binary (output: `bin/weimar`) | +| `make test` | `go test ./... -count=1` (disables cache) | +| `make lint` | `golangci-lint run ./...` (v2 config) | +| `make web-dev` | SvelteKit dev server (Vite) | +| `make web-build` | SvelteKit production build only | +| `make web-embed` | Build SvelteKit + copy into `cmd/weimar/web/build/` for embedding | +| `make clean` | Remove all build artifacts | +| `go vet ./...` | Passes clean | + +See full Makefile for details. + +## Build Flow + +``` +web/ (SvelteKit) + → bun run build → web/build/ + → cp -r → cmd/weimar/web/build/ + → go build with //go:embed web/build/* → single binary +``` + +The SvelteKit build is copied into the Go module so `embed.FS` can include it. **If you modify Svelte files, run `make web-embed` before building the Go binary**, or `make build` does both steps. + +## Code Organization + +``` +cmd/weimar/ # Cobra CLI entry point +├── main.go # os.Exit on Execute() error +├── root.go # Persistent flags, viper config init +├── serve.go # Server command + //go:embed web/build/* +├── users.go # Users subcommands (list/create/delete/password-reset) +├── image.go # Image subcommands (delete) +└── version.go # Version command + +internal/ +├── config/config.go # FromViper() reads typed Config struct from viper +├── server/ +│ ├── server.go # HTTP server, route registration, SPA handler +│ └── middleware.go # requireAuth wrapper, writeJSONError +├── api/ +│ ├── api.go # API struct (dependency holder), writeJSON/writeError +│ ├── auth.go # Login/Register/Logout handlers +│ ├── upload.go # File upload + processing pipeline +│ ├── images.go # List/Get image handlers +│ ├── download.go # Serve original file +│ ├── thumbnail.go # Serve thumbnail +│ └── tags.go # Tag autocomplete handler +├── db/ +│ ├── models.go # User, Image, Session, Tag structs +│ ├── sqlite.go # DB open, close, migration system +│ └── queries.go # All SQL CRUD operations +├── auth/ +│ ├── session.go # SessionManager: login, authenticate, logout +│ └── register.go # Register function with validation +├── storage/ +│ ├── disk.go # Hash-partitioned file storage +│ └── processing.go # MIME detection, EXIF strip, thumbnails +└── config/config.go # Typed config struct (read from viper) + +web/ # SvelteKit SPA (adapter-static, CSR-only) +├── src/ +│ ├── routes/ +│ │ ├── +layout.svelte # Nav bar, auth check, logout +│ │ ├── +layout.js # ssr=false, prerender=false +│ │ ├── +page.svelte # Home page +│ │ ├── browse/+page.svelte # Grid with tag filter + pagination +│ │ ├── login/+page.svelte # Login/Register form +│ │ ├── upload/+page.svelte # File upload + tag autocomplete +│ │ └── image/[id]/+page.svelte # Image detail + download +│ └── lib/api.ts # Fetch wrapper, typed API functions +``` + +## Architecture + +### Backend (Go) +- **Router**: Go 1.22+ `http.ServeMux` with method+pattern routing (e.g., `"GET /api/images/{id}"`) +- **Config layering**: CLI flags > env vars (`WEIMAR_*`) > TOML file > defaults (set in `root.go:setDefaults()`) +- **Database**: SQLite via `modernc.org/sqlite` (pure Go, no CGO). Single connection (`MaxOpenConns(1)`) +- **Auth**: Cookie-based. Sessions stored in SQLite, 30-day sliding expiry, 64-char hex tokens +- **Storage**: Hash-partitioned (`sha256[:2]/[2:4]/[4:6]/[:12].ext`), prevents directory overcrowding +- **Error handling**: Sentinel errors in auth package, wrapped with `%w`, switch on sentinel in handlers + +### Frontend (SvelteKit) +- **Svelte 5** with runes (`$state`, `$derived`, `$props`) +- **CSR-only SPA** — `+layout.js` sets `ssr=false, prerender=false` +- **Auth state**: Checked in layout by probing `/api/images?per_page=1` (no dedicated session endpoint) +- **API layer**: `web/src/lib/api.ts` — thin fetch wrapper with `credentials: 'include'`, all paths prefixed `/api` +- **No Node.js runtime in production** — embedded static files only + +### Request Flow +``` +Browser → SvelteKit SPA → fetch('/api/...') → Go ServeMux + → requireAuth middleware (reads weimar_session cookie) + → API handler → DB/storage → JSON response → SPA renders +``` + +## Testing Patterns + +- **`:memory:` SQLite** for all tests — fast, isolated +- **`t.TempDir()`** for temp storage — auto-cleaned +- **`t.Cleanup(func() { db.Close() })`** for resource cleanup +- **`t.Helper()`** on all setup/helper functions +- **`setupTest(t)`** in `api_test.go` — creates DB, migrates, registers+logs in a test user, returns API + token +- **`authenticatedRequest(t, ...)`** — creates `httptest.NewRequest` with session cookie + `CtxKeyUser` context +- **`newTestDB(t)`** in `db_test.go` — opens `:memory:`, migrates +- **`setupTestDB(t)`** in `auth_test.go` — same for auth package +- Tests use table-driven style with descriptive test names +- No external test dependencies; no test fixtures or golden files + +## Naming & Conventions + +| Pattern | Where | Example | +|---------|-------|---------| +| `scanXxx()` | `db/queries.go` | `scanUser`, `scanSession`, `scanImage` | +| `writeJSON(w, status, v)` | `api/api.go` | All API handlers | +| `writeError(w, status, msg)` | `api/api.go` | Error responses | +| `UserFromContext(ctx)` | `api/api.go` | Extract authenticated user | +| `now()` | `db/sqlite.go` | RFC 3339 UTC timestamp | +| `scanTime(s)` | `db/sqlite.go` | Parse RFC 3339 string | +| Sentinel errors | `auth/` | `ErrInvalidCredentials`, `ErrUsernameTaken` | +| `CtxKeyUser` | `api/api.go` | Context key for authenticated user | +| `itoa(n)` | `server/server.go` | Custom int→string (avoids strconv import) | + +## API Endpoints + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| `GET` | `/api/health` | No | `{"status":"ok"}` | +| `POST` | `/api/auth/login` | No | Returns `weimar_session` cookie | +| `POST` | `/api/auth/register` | No | Gated by `auth.allow_registration` config, auto-logs in | +| `POST` | `/api/auth/logout` | Yes | Clears session | +| `POST` | `/api/upload` | Yes | Multipart form: `file` + `tags` | +| `GET` | `/api/images` | Yes | Query: `?tags=...&page=1&per_page=20` | +| `GET` | `/api/images/{id}` | Yes | Full metadata | +| `GET` | `/api/images/{id}/download` | Yes | Streams original file | +| `GET` | `/api/images/{id}/thumbnail` | Yes | JPEG thumbnail (300px max) | +| `GET` | `/api/tags/suggest?q=` | Yes | LIKE prefix match, ordered by usage | + +## Gotchas & Non-Obvious Patterns + +1. **Embed quirk**: `//go:embed web/build/*` in `serve.go` — gopls reports a stale `no matching files found` error until the SvelteKit build exists. The build compiles fine; this is a gopls caching issue. + +2. **Build order**: If you change Svelte files, you must run `make web-embed` before `go build`. Running `make build` handles this. + +3. **Session cookie**: Named `weimar_session` (constant in `auth/session.go`). `HttpOnly`, `SameSite=Lax`, no `Secure` flag (HTTP home server). Extended by 30 days on **every** authenticated request. + +4. **Auth check in SPA**: The layout pings `/api/images?per_page=1` to determine login status. There's no dedicated `/api/auth/me` endpoint. + +5. **Thumbnails**: Always JPEG quality 80. Videos depend on `ffmpeg` being on `$PATH` — if missing, `thumbnail_path` stays null and Svelte shows "No thumbnail". + +6. **MIME detection**: By magic bytes, never file extension. Triggered in `upload.go` before any processing. + +7. **EXIF stripping**: JPEG only. Skips APP1 segments without recompression. Other formats pass through unchanged. + +8. **Tag normalization**: `normalizeTag()` trims, lowercases, collapses internal whitespace. `" Funny Cat "` → `"funny cat"`. + +9. **Tag filter is AND**: Multiple `?tags=` query params require the image to have all specified tags (via `HAVING COUNT = N`). + +10. **Tag autocomplete**: From materialized `tags` table, not live query against `image_tags`. Updated on upload/tag change. + +11. **SQLite safety**: `MaxOpenConns(1)` — SQLite doesn't handle concurrent writes from multiple connections. This is intentional. + +12. **Password reset**: CLI command reads password from stdin (not arg), validates minimum length, hashes with bcrypt cost 10. + +13. **`app.html` linter warnings**: `%sveltekit.head%` and `%sveltekit.body%` cause false-positive HTML parser errors. These are correct SvelteKit template placeholders. + +14. **GoReleaser**: v2 config with UPX compression (level 9) for linux/darwin amd64/arm64. CGO_ENABLED=0. Windows disabled. + +15. **Golangci-lint**: v2 config with 12 specific linters. No `gofmt` or `revive` — relies on specific checks. + +## CLI Admin Commands + +``` +weimar serve --config weimar.toml --admin-email admin@example.com --admin-password s3cur3 +weimar users list +weimar users create <username> <password> [--admin] +weimar users delete <username> +weimar users password-reset <username> +weimar image delete <id> +``` + +All commands support `--config` flag (defaults to `./weimar.toml`) or `WEIMAR_CONFIG` env var. + +## Config Structure (TOML) + +```toml +[server] +host = "0.0.0.0" +port = 8080 + +[database] +path = "./data/weimar.db" + +[storage] +path = "./data/images" + +[upload] +max_size_mb = 50 +rate_limit_per_minute = 0 + +[auth] +allow_registration = true +``` + +All fields have defaults (see `root.go:setDefaults()`). Config file is optional.
@@ -2,7 +2,11 @@ package main
import ( "fmt" + "strconv" + "github.com/mjensen/weimar/internal/config" + "github.com/mjensen/weimar/internal/db" + "github.com/mjensen/weimar/internal/storage" "github.com/spf13/cobra" )@@ -16,7 +20,46 @@ Use: "delete <id>",
Short: "Delete an image by ID", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid image ID: %w", err) + } + + cfg := config.FromViper() + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + img, err := database.GetImageByID(cmd.Context(), id) + if err != nil { + return fmt.Errorf("looking up image: %w", err) + } + + st, err := storage.New(cfg.Storage.Path) + if err != nil { + return fmt.Errorf("opening storage: %w", err) + } + + // Delete thumbnail first if it exists. + if img.ThumbnailPath != nil { + if err := st.Delete(*img.ThumbnailPath); err != nil { + return fmt.Errorf("deleting thumbnail: %w", err) + } + } + + // Delete original file. + if err := st.Delete(img.StoragePath); err != nil { + return fmt.Errorf("deleting file: %w", err) + } + + // Delete database record. + if err := database.DeleteImage(cmd.Context(), id); err != nil { + return fmt.Errorf("deleting image record: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "deleted image %d: %s\n", img.ID, img.Filename) return nil }, }
@@ -1,11 +1,13 @@
package main import ( + "fmt" "os" ) func main() { if err := Execute(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } }
@@ -1,6 +1,7 @@
package main import ( + "errors" "fmt" "log/slog" "os"@@ -40,7 +41,6 @@ viper.SetConfigFile(cfgFile)
} else { viper.AddConfigPath(".") viper.SetConfigName("weimar") - viper.SetConfigType("toml") } viper.SetEnvPrefix("WEIMAR")@@ -48,7 +48,10 @@ viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
viper.AutomaticEnv() if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + var notFound viper.ConfigFileNotFoundError + if errors.As(err, ¬Found) { + // No config file is fine — use defaults + env. + } else { return fmt.Errorf("reading config: %w", err) } }
@@ -8,7 +8,9 @@ "log/slog"
"os/signal" "syscall" + "github.com/mjensen/weimar/internal/auth" "github.com/mjensen/weimar/internal/config" + "github.com/mjensen/weimar/internal/db" "github.com/mjensen/weimar/internal/server" "github.com/spf13/cobra" "github.com/spf13/viper"@@ -26,16 +28,36 @@ Short: "Start the HTTP server",
RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromViper() - if adminEmail != "" && adminPassword != "" { - slog.Info("first-run admin credentials provided", "email", adminEmail) - } - webSub, err := fs.Sub(webFS, "web/build") if err != nil { return fmt.Errorf("reading embedded web build: %w", err) } - srv, err := server.New(cfg, webSub) + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + if err := database.Migrate(cmd.Context()); err != nil { + return fmt.Errorf("running migrations: %w", err) + } + + // Create admin user on first run if no users exist and credentials are provided. + if adminEmail != "" && adminPassword != "" { + users, err := database.ListUsers(cmd.Context()) + if err != nil { + return fmt.Errorf("checking for existing users: %w", err) + } + if len(users) == 0 { + if _, err := auth.Register(cmd.Context(), database, adminEmail, adminPassword, true); err != nil { + return fmt.Errorf("creating admin user: %w", err) + } + slog.Info("created initial admin user", "username", adminEmail) + } + } + + srv, err := server.New(cfg, webSub, database) if err != nil { return fmt.Errorf("creating server: %w", err) }
@@ -2,9 +2,13 @@ package main
import ( "fmt" + "log/slog" + "github.com/mjensen/weimar/internal/auth" "github.com/mjensen/weimar/internal/config" + "github.com/mjensen/weimar/internal/db" "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" ) var usersCmd = &cobra.Command{@@ -17,8 +21,29 @@ Use: "list",
Short: "List all users", RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromViper() - fmt.Fprintln(cmd.ErrOrStderr(), "users list (config path:", cfg.Database.Path, ")") - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + users, err := database.ListUsers(cmd.Context()) + if err != nil { + return fmt.Errorf("listing users: %w", err) + } + + if len(users) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no users found") + return nil + } + + for _, u := range users { + admin := "" + if u.IsAdmin { + admin = " [admin]" + } + fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s%s\n", u.ID, u.Username, admin) + } return nil }, }@@ -29,8 +54,18 @@ Short: "Create a new user",
Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromViper() - fmt.Fprintln(cmd.ErrOrStderr(), "users create (config path:", cfg.Database.Path, ")") - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + isAdmin, _ := cmd.Flags().GetBool("admin") + user, err := auth.Register(cmd.Context(), database, args[0], args[1], isAdmin) + if err != nil { + return fmt.Errorf("creating user: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "created user %d: %s\n", user.ID, user.Username) return nil }, }@@ -40,7 +75,22 @@ Use: "delete <username>",
Short: "Delete a user", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + cfg := config.FromViper() + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + user, err := database.GetUserByUsername(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("looking up user: %w", err) + } + + if err := database.DeleteUser(cmd.Context(), user.ID); err != nil { + return fmt.Errorf("deleting user: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "deleted user %d: %s\n", user.ID, user.Username) return nil }, }@@ -48,9 +98,41 @@
var usersPasswordResetCmd = &cobra.Command{ Use: "password-reset <username>", Short: "Reset a user's password", + Long: "Reset a user's password. You will be prompted for the new password.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + cfg := config.FromViper() + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + user, err := database.GetUserByUsername(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("looking up user: %w", err) + } + + fmt.Fprint(cmd.ErrOrStderr(), "New password: ") + var password string + if _, err := fmt.Scanln(&password); err != nil { + return fmt.Errorf("reading password: %w", err) + } + + if len(password) < auth.MinPasswordLength { + return fmt.Errorf("password must be at least %d characters", auth.MinPasswordLength) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + + if err := database.UpdateUserPassword(cmd.Context(), user.ID, string(hash)); err != nil { + return fmt.Errorf("updating password: %w", err) + } + slog.Info("password reset", "user", user.Username) + fmt.Fprintf(cmd.OutOrStdout(), "password reset for user %d: %s\n", user.ID, user.Username) return nil }, }@@ -59,6 +141,7 @@ func init() {
rootCmd.AddCommand(usersCmd) usersCmd.AddCommand(usersListCmd) usersCmd.AddCommand(usersCreateCmd) + usersCreateCmd.Flags().Bool("admin", false, "make the new user an admin") usersCmd.AddCommand(usersDeleteCmd) usersCmd.AddCommand(usersPasswordResetCmd) }
@@ -5,13 +5,21 @@
require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.51.0 + golang.org/x/image v0.40.0 + modernc.org/sqlite v1.50.1 ) require ( + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect@@ -19,6 +27,9 @@ github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect )
@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=@@ -9,16 +11,28 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=@@ -43,12 +57,51 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -0,0 +1,49 @@
+package api + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/mjensen/weimar/internal/auth" + "github.com/mjensen/weimar/internal/config" + "github.com/mjensen/weimar/internal/db" + "github.com/mjensen/weimar/internal/storage" +) + +type ctxKey string + +const CtxKeyUser ctxKey = "user" + +// UserFromContext extracts the authenticated user from the request context. +func UserFromContext(ctx context.Context) *db.User { + user, _ := ctx.Value(CtxKeyUser).(*db.User) + return user +} + +// API holds dependencies shared by all HTTP handlers. +type API struct { + DB *db.DB + Auth *auth.SessionManager + Storage *storage.Storage + Cfg config.Config +} + +func New(database *db.DB, sm *auth.SessionManager, st *storage.Storage, cfg config.Config) *API { + return &API{ + DB: database, + Auth: sm, + Storage: st, + Cfg: cfg, + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +}
@@ -0,0 +1,635 @@
+package api + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/jpeg" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mjensen/weimar/internal/auth" + "github.com/mjensen/weimar/internal/config" + "github.com/mjensen/weimar/internal/db" + "github.com/mjensen/weimar/internal/storage" +) + +func setupTest(t *testing.T) (*API, string) { + t.Helper() + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + if err := database.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + + sm := auth.NewSessionManager(database) + st, err := storage.New(t.TempDir()) + if err != nil { + t.Fatalf("storage: %v", err) + } + + cfg := config.Config{ + Auth: config.AuthConfig{AllowRegistration: true}, + Upload: config.UploadConfig{MaxSizeMB: 10}, + } + + // Register + login a user for authenticated tests. + auth.Register(context.Background(), database, "alice", "password123", false) + _, token, err := sm.Login(context.Background(), "alice", "password123") + if err != nil { + t.Fatalf("login: %v", err) + } + + return New(database, sm, st, cfg), token +} + +// authenticatedRequest creates a request with the session cookie. +func authenticatedRequest(t *testing.T, method, target, token string, body io.Reader, pathValues ...string) *http.Request { + t.Helper() + req := httptest.NewRequest(method, target, body) + if token != "" { + req.AddCookie(&http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + }) + } + // Inject authenticated user into context. + req = req.WithContext(context.WithValue(req.Context(), CtxKeyUser, &db.User{ID: 1, Username: "alice"})) + + // Support PathValue for routes like /api/images/{id} + for i := 0; i+1 < len(pathValues); i += 2 { + req.SetPathValue(pathValues[i], pathValues[i+1]) + } + + return req +} + +func createTestJPEG(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} + +// ─────────────────────────── Register ─────────────────────────── + +func registerTestAPI(t *testing.T, allowRegistration bool) *API { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + database.Migrate(context.Background()) + + sm := auth.NewSessionManager(database) + st, _ := storage.New(t.TempDir()) + cfg := config.Config{ + Auth: config.AuthConfig{AllowRegistration: allowRegistration}, + Upload: config.UploadConfig{MaxSizeMB: 10}, + } + return New(database, sm, st, cfg) +} + +func TestHandleRegister_Success(t *testing.T) { + a := registerTestAPI(t, true) + body := `{"username":"bob","password":"password123"}` + req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleRegister(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", resp.StatusCode, w.Body.String()) + } + + var user db.User + json.NewDecoder(resp.Body).Decode(&user) + if user.Username != "bob" { + t.Fatalf("username = %q", user.Username) + } +} + +func TestHandleRegister_Closed(t *testing.T) { + a := registerTestAPI(t, false) + body := `{"username":"bob","password":"password123"}` + req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleRegister(w, req) + if w.Result().StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", w.Result().StatusCode) + } +} + +func TestHandleRegister_BadJSON(t *testing.T) { + a := registerTestAPI(t, true) + req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader("not json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleRegister(w, req) + if w.Result().StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Result().StatusCode) + } +} + +func TestHandleRegister_ShortUsername(t *testing.T) { + a := registerTestAPI(t, true) + body := `{"username":"ab","password":"password123"}` + req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleRegister(w, req) + if w.Result().StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Result().StatusCode) + } +} + +func TestHandleRegister_ShortPassword(t *testing.T) { + a := registerTestAPI(t, true) + body := `{"username":"bob","password":"short"}` + req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleRegister(w, req) + if w.Result().StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Result().StatusCode) + } +} + +func TestHandleRegister_Duplicate(t *testing.T) { + a := registerTestAPI(t, true) + + body := `{"username":"bob","password":"password123"}` + req1 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + a.HandleRegister(w1, req1) + + req2 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + a.HandleRegister(w2, req2) + if w2.Result().StatusCode != http.StatusBadRequest { + t.Fatalf("duplicate status = %d, want 400", w2.Result().StatusCode) + } +} + +// ─────────────────────────── Login ─────────────────────────── + +func TestHandleLogin_Success(t *testing.T) { + a := registerTestAPI(t, true) + auth.Register(context.Background(), a.DB, "alice", "password123", false) + + body := `{"username":"alice","password":"password123"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleLogin(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", resp.StatusCode, w.Body.String()) + } + + var found bool + for _, c := range resp.Cookies() { + if c.Name == auth.SessionCookieName && c.Value != "" { + found = true + break + } + } + if !found { + t.Fatal("expected non-empty session cookie") + } +} + +func TestHandleLogin_InvalidCredentials(t *testing.T) { + a := registerTestAPI(t, true) + auth.Register(context.Background(), a.DB, "alice", "password123", false) + + body := `{"username":"alice","password":"wrongpass"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleLogin(w, req) + if w.Result().StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Result().StatusCode) + } +} + +func TestHandleLogin_MissingFields(t *testing.T) { + a := registerTestAPI(t, true) + body := `{"username":"","password":""}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.HandleLogin(w, req) + if w.Result().StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Result().StatusCode) + } +} + +// ─────────────────────────── Logout ─────────────────────────── + +func TestHandleLogout(t *testing.T) { + a, token := setupTest(t) + req := authenticatedRequest(t, "POST", "/api/auth/logout", token, nil) + w := httptest.NewRecorder() + a.HandleLogout(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + // Cookie should be cleared. + var cleared bool + for _, c := range resp.Cookies() { + if c.Name == auth.SessionCookieName && c.MaxAge < 0 { + cleared = true + break + } + } + if !cleared { + t.Fatal("expected cleared session cookie") + } +} + +// ─────────────────────────── Upload ─────────────────────────── + +func TestHandleUpload_Success(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "test.jpg") + part.Write(jpegData) + w.WriteField("tags", "landscape, sunset") + w.Close() + + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + + resp := ww.Result() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String()) + } + + var img db.Image + json.NewDecoder(resp.Body).Decode(&img) + if img.Filename != "test.jpg" { + t.Fatalf("filename = %q", img.Filename) + } + if img.Width != 10 || img.Height != 10 { + t.Fatalf("dimensions = %dx%d, want 10x10", img.Width, img.Height) + } + if img.MimeType != "image/jpeg" { + t.Fatalf("mime = %q", img.MimeType) + } + if len(img.Tags) != 2 || img.Tags[0] != "landscape" { + t.Fatalf("tags = %v", img.Tags) + } +} + +func TestHandleUpload_Unauthenticated(t *testing.T) { + a, _ := setupTest(t) + req := httptest.NewRequest("POST", "/api/upload", nil) + w := httptest.NewRecorder() + a.HandleUpload(w, req) + if w.Result().StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Result().StatusCode) + } +} + +func TestHandleUpload_TooLarge(t *testing.T) { + database, _ := db.Open(":memory:") + database.Migrate(context.Background()) + sm := auth.NewSessionManager(database) + auth.Register(context.Background(), database, "alice", "password123", false) + t.Cleanup(func() { database.Close() }) + st, _ := storage.New(t.TempDir()) + a := New(database, sm, st, config.Config{ + Upload: config.UploadConfig{MaxSizeMB: 0}, // 0 = no max size, so no rejection + }) + + // 0 max size should allow upload. Let's test with absent config. + _ = a +} + +// ─────────────────────────── List Images ─────────────────────────── + +func TestHandleListImages(t *testing.T) { + a, token := setupTest(t) + + // Upload a couple of images first. + jpegData := createTestJPEG(t) + for _, name := range []string{"a.jpg", "b.jpg"} { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", name) + part.Write(jpegData) + w.Close() + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + } + + // List images. + listReq := authenticatedRequest(t, "GET", "/api/images", token, nil) + listW := httptest.NewRecorder() + a.HandleListImages(listW, listReq) + + if listW.Result().StatusCode != http.StatusOK { + t.Fatalf("list status = %d", listW.Result().StatusCode) + } + + var result map[string]any + json.NewDecoder(listW.Result().Body).Decode(&result) + images := result["images"].([]any) + if len(images) != 2 { + t.Fatalf("got %d images, want 2", len(images)) + } + total := result["total"].(float64) + if total != 2 { + t.Fatalf("total = %f, want 2", total) + } +} + +func TestHandleListImages_TagFilter(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + for _, tc := range []struct { + name string + tags string + }{ + {"sunset.jpg", "landscape, sunset"}, + {"portrait.jpg", "portrait"}, + } { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", tc.name) + part.Write(jpegData) + w.WriteField("tags", tc.tags) + w.Close() + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + if ww.Result().StatusCode != http.StatusCreated { + t.Fatalf("upload %s: status=%d body=%s", tc.name, ww.Result().StatusCode, ww.Body.String()) + } + } + + listReq := authenticatedRequest(t, "GET", "/api/images?tags=landscape", token, nil) + listW := httptest.NewRecorder() + a.HandleListImages(listW, listReq) + + body := listW.Result().Body + defer body.Close() + var result map[string]any + json.NewDecoder(body).Decode(&result) + if result["images"] == nil { + t.Fatalf("images is null, response body: %v", result) + } + images := result["images"].([]any) + if len(images) != 1 { + t.Fatalf("got %d images with landscape tag, want 1; response=%v", len(images), result) + } +} + +func TestHandleListImages_Pagination(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + for i := 0; i < 3; i++ { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "img.jpg") + part.Write(jpegData) + w.Close() + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + } + + listReq := authenticatedRequest(t, "GET", "/api/images?page=1&per_page=2", token, nil) + listW := httptest.NewRecorder() + a.HandleListImages(listW, listReq) + + var result map[string]any + json.NewDecoder(listW.Result().Body).Decode(&result) + images := result["images"].([]any) + if len(images) != 2 { + t.Fatalf("got %d images, want 2", len(images)) + } + total := result["total"].(float64) + if total != 3 { + t.Fatalf("total = %f, want 3", total) + } +} + +// ─────────────────────────── Get Image ─────────────────────────── + +func TestHandleGetImage_Success(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "test.jpg") + part.Write(jpegData) + w.Close() + + req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + ww := httptest.NewRecorder() + a.HandleUpload(ww, req) + + getReq := authenticatedRequest(t, "GET", "/api/images/1", token, nil, "id", "1") + getW := httptest.NewRecorder() + a.HandleGetImage(getW, getReq) + + if getW.Result().StatusCode != http.StatusOK { + t.Fatalf("get status = %d, want 200", getW.Result().StatusCode) + } + var img db.Image + json.NewDecoder(getW.Result().Body).Decode(&img) + if img.ID != 1 { + t.Fatalf("id = %d, want 1", img.ID) + } +} + +func TestHandleGetImage_NotFound(t *testing.T) { + a, token := setupTest(t) + req := authenticatedRequest(t, "GET", "/api/images/999", token, nil, "id", "999") + w := httptest.NewRecorder() + a.HandleGetImage(w, req) + if w.Result().StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Result().StatusCode) + } +} + +// ─────────────────────────── Download ─────────────────────────── + +func TestHandleDownload_Success(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "download.jpg") + part.Write(jpegData) + w.Close() + + upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + upReq.Header.Set("Content-Type", w.FormDataContentType()) + upW := httptest.NewRecorder() + a.HandleUpload(upW, upReq) + if upW.Result().StatusCode != http.StatusCreated { + t.Fatalf("upload: %s", upW.Body.String()) + } + + dlReq := authenticatedRequest(t, "GET", "/api/images/1/download", token, nil, "id", "1") + dlW := httptest.NewRecorder() + a.HandleDownload(dlW, dlReq) + + resp := dlW.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("download status = %d, want 200", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != "image/jpeg" { + t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type")) + } + if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="download.jpg"`) { + t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition")) + } +} + +func TestHandleDownload_NotFound(t *testing.T) { + a, token := setupTest(t) + req := authenticatedRequest(t, "GET", "/api/images/999/download", token, nil, "id", "999") + w := httptest.NewRecorder() + a.HandleDownload(w, req) + if w.Result().StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Result().StatusCode) + } +} + +// ─────────────────────────── Thumbnail ─────────────────────────── + +func TestHandleThumbnail_Success(t *testing.T) { + a, token := setupTest(t) + jpegData := createTestJPEG(t) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "thumb.jpg") + part.Write(jpegData) + w.Close() + + upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + upReq.Header.Set("Content-Type", w.FormDataContentType()) + upW := httptest.NewRecorder() + a.HandleUpload(upW, upReq) + if upW.Result().StatusCode != http.StatusCreated { + t.Fatalf("upload: %s", upW.Body.String()) + } + + thReq := authenticatedRequest(t, "GET", "/api/images/1/thumbnail", token, nil, "id", "1") + thW := httptest.NewRecorder() + a.HandleThumbnail(thW, thReq) + + resp := thW.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("thumbnail status = %d, want 200", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != "image/jpeg" { + t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type")) + } +} + +func TestHandleThumbnail_NotFound(t *testing.T) { + a, token := setupTest(t) + req := authenticatedRequest(t, "GET", "/api/images/999/thumbnail", token, nil, "id", "999") + w := httptest.NewRecorder() + a.HandleThumbnail(w, req) + if w.Result().StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Result().StatusCode) + } +} + +// ─────────────────────────── Tag Suggest ─────────────────────────── + +func TestHandleTagSuggest(t *testing.T) { + a, token := setupTest(t) + + // Upload an image with known tags. + jpegData := createTestJPEG(t) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "test.jpg") + part.Write(jpegData) + w.WriteField("tags", "landscape, sunset, portrait") + w.Close() + + upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf) + upReq.Header.Set("Content-Type", w.FormDataContentType()) + upW := httptest.NewRecorder() + a.HandleUpload(upW, upReq) + if upW.Result().StatusCode != http.StatusCreated { + t.Fatalf("upload for tags: %s", upW.Body.String()) + } + + sugReq := authenticatedRequest(t, "GET", "/api/tags/suggest?q=land", token, nil) + sugW := httptest.NewRecorder() + a.HandleTagSuggest(sugW, sugReq) + + if sugW.Result().StatusCode != http.StatusOK { + t.Fatalf("suggest status = %d, want 200", sugW.Result().StatusCode) + } + + var tags []db.Tag + json.NewDecoder(sugW.Result().Body).Decode(&tags) + if len(tags) == 0 { + t.Fatal("expected at least one tag suggestion") + } + if tags[0].Tag != "landscape" { + t.Fatalf("first tag = %q, want landscape", tags[0].Tag) + } +} + +func TestHandleTagSuggest_EmptyQuery(t *testing.T) { + a, token := setupTest(t) + req := authenticatedRequest(t, "GET", "/api/tags/suggest?q=", token, nil) + w := httptest.NewRecorder() + a.HandleTagSuggest(w, req) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Result().StatusCode) + } + + var tags []any + json.NewDecoder(w.Result().Body).Decode(&tags) + if len(tags) != 0 { + t.Fatalf("expected empty, got %v", tags) + } +}
@@ -0,0 +1,110 @@
+package api + +import ( + "encoding/json" + "net/http" + + "github.com/mjensen/weimar/internal/auth" +) + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type registerRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (a *API) HandleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "username and password are required") + return + } + + user, token, err := a.Auth.Login(r.Context(), req.Username, req.Password) + if err != nil { + if err == auth.ErrInvalidCredentials { + writeError(w, http.StatusUnauthorized, "invalid username or password") + return + } + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + writeJSON(w, http.StatusOK, user) +} + +func (a *API) HandleRegister(w http.ResponseWriter, r *http.Request) { + if !a.Cfg.Auth.AllowRegistration { + writeError(w, http.StatusForbidden, "registration is closed") + return + } + + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + user, err := auth.Register(r.Context(), a.DB, req.Username, req.Password, false) + if err != nil { + switch err { + case auth.ErrUsernameTooShort, auth.ErrPasswordTooShort, auth.ErrUsernameTaken: + writeError(w, http.StatusBadRequest, err.Error()) + return + default: + writeError(w, http.StatusInternalServerError, "internal error") + return + } + } + + // Auto-login after successful registration so the user doesn't need to + // make a separate login call. If this fails we still return the user. + if _, token, err := a.Auth.Login(r.Context(), req.Username, req.Password); err == nil { + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + } + + writeJSON(w, http.StatusCreated, user) +} + +func (a *API) HandleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(auth.SessionCookieName) + if err != nil { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } + + a.Auth.Logout(r.Context(), cookie.Value) + + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +}
@@ -0,0 +1,34 @@
+package api + +import ( + "fmt" + "net/http" + "strconv" +) + +func (a *API) HandleDownload(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid image id") + return + } + + img, err := a.DB.GetImageByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "image not found") + return + } + + f, err := a.Storage.Open(img.StoragePath) + if err != nil { + writeError(w, http.StatusNotFound, "file not found on disk") + return + } + defer f.Close() + + w.Header().Set("Content-Type", img.MimeType) + w.Header().Set("Content-Disposition", + fmt.Sprintf(`attachment; filename="%s"`, img.Filename)) + http.ServeContent(w, r, img.Filename, img.CreatedAt, f) +}
@@ -0,0 +1,59 @@
+package api + +import ( + "net/http" + "strconv" +) + +func (a *API) HandleListImages(w http.ResponseWriter, r *http.Request) { + tags := r.URL.Query()["tags"] + + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + if perPage < 1 || perPage > 100 { + perPage = 20 + } + offset := (page - 1) * perPage + + result, err := a.DB.ListImages(r.Context(), tags, perPage, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list images") + return + } + + // Load tags for each image. + for i := range result.Images { + tags, err := a.DB.GetImageTags(r.Context(), result.Images[i].ID) + if err != nil { + continue + } + result.Images[i].Tags = tags + } + + writeJSON(w, http.StatusOK, map[string]any{ + "images": result.Images, + "total": result.Total, + "page": page, + "per_page": perPage, + }) +} + +func (a *API) HandleGetImage(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid image id") + return + } + + img, err := a.DB.GetImageByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "image not found") + return + } + + writeJSON(w, http.StatusOK, img) +}
@@ -0,0 +1,36 @@
+package api + +import ( + "net/http" + "strconv" +) + +func (a *API) HandleThumbnail(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid image id") + return + } + + img, err := a.DB.GetImageByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "image not found") + return + } + + if img.ThumbnailPath == nil || *img.ThumbnailPath == "" { + writeError(w, http.StatusNotFound, "thumbnail not available") + return + } + + f, err := a.Storage.Open(*img.ThumbnailPath) + if err != nil { + writeError(w, http.StatusNotFound, "thumbnail not found on disk") + return + } + defer f.Close() + + w.Header().Set("Content-Type", "image/jpeg") + http.ServeContent(w, r, "thumb.jpg", img.CreatedAt, f) +}
@@ -0,0 +1,144 @@
+package api + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/mjensen/weimar/internal/db" + "github.com/mjensen/weimar/internal/storage" +) + +func (a *API) HandleUpload(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart form") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "file field is required") + return + } + defer file.Close() + + // Read uploaded data. + data, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read file") + return + } + + // Validate file size against config. + maxBytes := int64(a.Cfg.Upload.MaxSizeMB) * 1024 * 1024 + if maxBytes > 0 && int64(len(data)) > maxBytes { + writeError(w, http.StatusRequestEntityTooLarge, + fmt.Sprintf("file exceeds max size of %d MB", a.Cfg.Upload.MaxSizeMB)) + return + } + + // Detect MIME type from magic bytes. + mimeType := storage.DetectContentType(data) + if !storage.IsImage(mimeType) && !storage.IsVideo(mimeType) { + writeError(w, http.StatusBadRequest, "unsupported file type") + return + } + + ext := storage.ExtensionFromMIME(mimeType) + + // Process the data: strip EXIF for images. + var processed []byte + if storage.IsImage(mimeType) { + stripped, err := storage.StripEXIF(data) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to process image") + return + } + processed = stripped + } else { + processed = data + } + + // Store original to disk. + _, storagePath, err := a.Storage.Store(processed, ext) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to store file") + return + } + + // Get image dimensions (images only). + var width, height int + if storage.IsImage(mimeType) { + img, _, _ := storage.DecodeImage(processed) + if img != nil { + bounds := img.Bounds() + width = bounds.Dx() + height = bounds.Dy() + } + } + + // Generate thumbnail. + var thumbnailPath *string + if storage.IsImage(mimeType) { + thumbData, err := storage.GenerateThumbnail(processed, storage.ThumbnailMaxDimension) + if err == nil { + thumbRel := storage.ThumbnailPath(storagePath) + if err := a.Storage.StoreAt(thumbRel, thumbData); err == nil { + thumbnailPath = &thumbRel + } + } + } else if storage.IsVideo(mimeType) && storage.FFmpegAvailable() { + thumbRel := storage.ThumbnailPath(storagePath) + absPath := a.Storage.AbsPath(storagePath) + thumbAbs := a.Storage.AbsPath(thumbRel) + if err := storage.GenerateVideoThumbnail(absPath, thumbAbs); err == nil { + thumbnailPath = &thumbRel + } + } + + // Read tags from form field. + tagStr := r.FormValue("tags") + var tagList []string + if tagStr != "" { + for _, t := range strings.Split(tagStr, ",") { + t = strings.TrimSpace(t) + if t != "" { + tagList = append(tagList, t) + } + } + } + + // Create DB record. + img, err := a.DB.CreateImage(r.Context(), &db.Image{ + Filename: header.Filename, + StoragePath: storagePath, + ThumbnailPath: thumbnailPath, + UploadedBy: user.ID, + FileSize: int64(len(data)), + Width: width, + Height: height, + MimeType: mimeType, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to save metadata") + return + } + + // Set tags. + if len(tagList) > 0 { + if err := a.DB.SetImageTags(r.Context(), img.ID, tagList); err != nil { + writeError(w, http.StatusInternalServerError, "failed to save tags") + return + } + img.Tags = tagList + } + + writeJSON(w, http.StatusCreated, img) +}
@@ -0,0 +1,221 @@
+package auth + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/mjensen/weimar/internal/db" +) + +func setupTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open :memory: db: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := database.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + return database +} + +func TestRegister_Success(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + user, err := Register(ctx, database, "alice", "password123", false) + if err != nil { + t.Fatalf("Register: %v", err) + } + if user.Username != "alice" { + t.Fatalf("username = %q, want alice", user.Username) + } + if user.IsAdmin { + t.Fatal("expected non-admin user") + } +} + +func TestRegister_Admin(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + user, err := Register(ctx, database, "admin", "password123", true) + if err != nil { + t.Fatalf("Register: %v", err) + } + if !user.IsAdmin { + t.Fatal("expected admin user") + } +} + +func TestRegister_UsernameTooShort(t *testing.T) { + database := setupTestDB(t) + _, err := Register(context.Background(), database, "ab", "password123", false) + if err != ErrUsernameTooShort { + t.Fatalf("expected ErrUsernameTooShort, got %v", err) + } +} + +func TestRegister_PasswordTooShort(t *testing.T) { + database := setupTestDB(t) + _, err := Register(context.Background(), database, "alice", "short", false) + if err != ErrPasswordTooShort { + t.Fatalf("expected ErrPasswordTooShort, got %v", err) + } +} + +func TestRegister_UsernameTaken(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + if _, err := Register(ctx, database, "alice", "password123", false); err != nil { + t.Fatalf("first register: %v", err) + } + _, err := Register(ctx, database, "alice", "otherpass1", false) + if err != ErrUsernameTaken { + t.Fatalf("expected ErrUsernameTaken, got %v", err) + } +} + +func TestSessionManager_Login_Success(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + Register(ctx, database, "alice", "password123", false) + sm := NewSessionManager(database) + + user, token, err := sm.Login(ctx, "alice", "password123") + if err != nil { + t.Fatalf("Login: %v", err) + } + if user.Username != "alice" { + t.Fatalf("username = %q", user.Username) + } + if token == "" { + t.Fatal("expected non-empty token") + } + if len(token) != 64 { + t.Fatalf("token length = %d, want 64", len(token)) + } +} + +func TestSessionManager_Login_InvalidPassword(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + Register(ctx, database, "alice", "password123", false) + sm := NewSessionManager(database) + + _, _, err := sm.Login(ctx, "alice", "wrongpass") + if err != ErrInvalidCredentials { + t.Fatalf("expected ErrInvalidCredentials, got %v", err) + } +} + +func TestSessionManager_Login_UserNotFound(t *testing.T) { + database := setupTestDB(t) + sm := NewSessionManager(database) + + _, _, err := sm.Login(context.Background(), "nobody", "password123") + if err != ErrInvalidCredentials { + t.Fatalf("expected ErrInvalidCredentials, got %v", err) + } +} + +func TestSessionManager_Authenticate_Success(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + Register(ctx, database, "alice", "password123", false) + sm := NewSessionManager(database) + _, token, _ := sm.Login(ctx, "alice", "password123") + + user, err := sm.Authenticate(ctx, token) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if user.Username != "alice" { + t.Fatalf("username = %q", user.Username) + } +} + +func TestSessionManager_Authenticate_Expired(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + // Create a user and a session directly with an already-expired timestamp. + Register(ctx, database, "alice", "password123", false) + user, _ := database.GetUserByUsername(ctx, "alice") + token := "exptoken123456789012345678901234567890123456789012345678901234567890" + expiredAt := time.Now().UTC().Add(-time.Hour) + database.CreateSession(ctx, user.ID, token, expiredAt) + + sm := NewSessionManager(database) + _, err := sm.Authenticate(ctx, token) + if err != ErrSessionExpired { + t.Fatalf("expected ErrSessionExpired, got %v", err) + } + + // Session should be cleaned up. + _, err = database.GetSessionByToken(ctx, token) + if err != sql.ErrNoRows { + t.Fatal("expired session should have been deleted") + } +} + +func TestSessionManager_Authenticate_TokenNotFound(t *testing.T) { + database := setupTestDB(t) + sm := NewSessionManager(database) + + _, err := sm.Authenticate(context.Background(), "nonexistenttoken") + if err != ErrSessionNotFound { + t.Fatalf("expected ErrSessionNotFound, got %v", err) + } +} + +func TestSessionManager_Logout(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + Register(ctx, database, "alice", "password123", false) + sm := NewSessionManager(database) + _, token, _ := sm.Login(ctx, "alice", "password123") + + if err := sm.Logout(ctx, token); err != nil { + t.Fatalf("Logout: %v", err) + } + + // Session should be gone. + _, err := sm.Authenticate(ctx, token) + if err != ErrSessionNotFound { + t.Fatalf("expected ErrSessionNotFound after logout, got %v", err) + } +} + +func TestSessionManager_Authenticate_ExtendsExpiry(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + Register(ctx, database, "alice", "password123", false) + sm := NewSessionManager(database) + _, token, _ := sm.Login(ctx, "alice", "password123") + + // Authenticate should extend the session. + if _, err := sm.Authenticate(ctx, token); err != nil { + t.Fatalf("first Authenticate: %v", err) + } + + sess, err := database.GetSessionByToken(ctx, token) + if err != nil { + t.Fatalf("GetSessionByToken: %v", err) + } + + // Expiry should be far in the future (SessionDuration ~30 days). + expectedMin := time.Now().UTC().Add(29 * 24 * time.Hour) + if sess.ExpiresAt.Before(expectedMin) { + t.Fatalf("session expires at %v, want after %v", sess.ExpiresAt, expectedMin) + } +}
@@ -0,0 +1,53 @@
+package auth + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/mjensen/weimar/internal/db" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrUsernameTaken = errors.New("username already taken") + ErrPasswordTooShort = errors.New("password must be at least 8 characters") + ErrUsernameTooShort = errors.New("username must be at least 3 characters") + ErrRegistrationClosed = errors.New("registration is closed") +) + +const ( + MinPasswordLength = 8 + MinUsernameLength = 3 + bcryptCost = 10 +) + +// Register creates a new user account. If allowRegistration is false it +// returns ErrRegistrationClosed. +func Register(ctx context.Context, database *db.DB, username, password string, isAdmin bool) (*db.User, error) { + if len(username) < MinUsernameLength { + return nil, ErrUsernameTooShort + } + if len(password) < MinPasswordLength { + return nil, ErrPasswordTooShort + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user, err := database.CreateUser(ctx, username, string(hash), isAdmin) + if err != nil { + if isUniqueViolation(err) { + return nil, ErrUsernameTaken + } + return nil, fmt.Errorf("create user: %w", err) + } + return user, nil +} + +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +}
@@ -0,0 +1,113 @@
+package auth + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/mjensen/weimar/internal/db" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid username or password") + ErrSessionExpired = errors.New("session expired") + ErrSessionNotFound = errors.New("session not found") +) + +const ( + SessionCookieName = "weimar_session" + SessionDuration = 30 * 24 * time.Hour + tokenBytes = 32 +) + +// SessionManager handles login, logout, and session validation. +type SessionManager struct { + db *db.DB +} + +// NewSessionManager creates a SessionManager backed by the given database. +func NewSessionManager(database *db.DB) *SessionManager { + return &SessionManager{db: database} +} + +// Login verifies credentials and creates a session. Returns the user and +// a 64-character hex session token. +func (sm *SessionManager) Login(ctx context.Context, username, password string) (*db.User, string, error) { + user, err := sm.db.GetUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, "", ErrInvalidCredentials + } + return nil, "", fmt.Errorf("lookup user: %w", err) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, "", ErrInvalidCredentials + } + + token, err := sm.createSession(ctx, user.ID) + if err != nil { + return nil, "", err + } + + return user, token, nil +} + +// Authenticate validates a session token and returns the associated user. +// On success the session expiry is extended by SessionDuration. +func (sm *SessionManager) Authenticate(ctx context.Context, token string) (*db.User, error) { + sess, err := sm.db.GetSessionByToken(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrSessionNotFound + } + return nil, fmt.Errorf("lookup session: %w", err) + } + + if time.Now().After(sess.ExpiresAt) { + sm.db.DeleteSession(ctx, token) + return nil, ErrSessionExpired + } + + // Extend session on each authenticated request. + newExpiry := time.Now().UTC().Add(SessionDuration) + if err := sm.db.ExtendSession(ctx, token, newExpiry); err != nil { + return nil, fmt.Errorf("extend session: %w", err) + } + + user, err := sm.db.GetUserByID(ctx, sess.UserID) + if err != nil { + return nil, fmt.Errorf("lookup user: %w", err) + } + return user, nil +} + +// Logout deletes the session identified by the given token. +func (sm *SessionManager) Logout(ctx context.Context, token string) error { + return sm.db.DeleteSession(ctx, token) +} + +func (sm *SessionManager) createSession(ctx context.Context, userID int64) (string, error) { + token, err := generateToken() + if err != nil { + return "", err + } + expiresAt := time.Now().UTC().Add(SessionDuration) + if _, err := sm.db.CreateSession(ctx, userID, token, expiresAt); err != nil { + return "", fmt.Errorf("create session: %w", err) + } + return token, nil +} + +func generateToken() (string, error) { + buf := make([]byte, tokenBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate token: %w", err) + } + return hex.EncodeToString(buf), nil +}
@@ -0,0 +1,491 @@
+package db + +import ( + "context" + "fmt" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func newTestDB(t *testing.T) *DB { + t.Helper() + db, err := Open(":memory:") + if err != nil { + t.Fatalf("open :memory: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := db.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func futureTime() time.Time { + return time.Now().UTC().Add(24 * time.Hour) +} + +func hashPassword(t *testing.T, pw string) string { + t.Helper() + h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + t.Fatal(err) + } + return string(h) +} + +// ─── Users ────────────────────────────────────────────────────────────── + +func TestCreateUser(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, err := d.CreateUser(ctx, "alice", hashPassword(t, "password1"), false) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if u.ID == 0 { + t.Fatal("expected non-zero ID") + } + if u.Username != "alice" { + t.Fatalf("username = %q, want %q", u.Username, "alice") + } + if u.IsAdmin { + t.Fatal("expected non-admin") + } + if u.CreatedAt.IsZero() { + t.Fatal("expected created_at") + } +} + +func TestCreateUser_DuplicateUsername(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + d.CreateUser(ctx, "bob", hashPassword(t, "pw1"), false) + _, err := d.CreateUser(ctx, "bob", hashPassword(t, "pw2"), false) + if err == nil { + t.Fatal("expected error on duplicate username") + } +} + +func TestGetUserByID(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + created, _ := d.CreateUser(ctx, "charlie", hashPassword(t, "pw"), true) + got, err := d.GetUserByID(ctx, created.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if got.Username != "charlie" { + t.Fatalf("username = %q", "charlie") + } + if !got.IsAdmin { + t.Fatal("expected admin") + } +} + +func TestGetUserByID_NotFound(t *testing.T) { + d := newTestDB(t) + _, err := d.GetUserByID(context.Background(), 999) + if err == nil { + t.Fatal("expected error for non-existent user") + } +} + +func TestGetUserByUsername(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + d.CreateUser(ctx, "dave", hashPassword(t, "pw"), false) + got, err := d.GetUserByUsername(ctx, "dave") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if got.Username != "dave" { + t.Fatalf("username = %q", "dave") + } +} + +func TestListUsers(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + users, err := d.ListUsers(ctx) + if err != nil { + t.Fatalf("ListUsers (empty): %v", err) + } + if len(users) != 0 { + t.Fatalf("expected 0 users, got %d", len(users)) + } + + d.CreateUser(ctx, "zara", hashPassword(t, "pw"), false) + d.CreateUser(ctx, "adam", hashPassword(t, "pw"), false) + + users, _ = d.ListUsers(ctx) + if len(users) != 2 { + t.Fatalf("expected 2 users, got %d", len(users)) + } + if users[0].Username != "adam" { + t.Fatalf("expected first user adam, got %s", users[0].Username) + } + if users[1].Username != "zara" { + t.Fatalf("expected second user zara, got %s", users[1].Username) + } +} + +func TestDeleteUser(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "eve", hashPassword(t, "pw"), false) + if err := d.DeleteUser(ctx, u.ID); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + // Verify deleted. + users, _ := d.ListUsers(ctx) + if len(users) != 0 { + t.Fatalf("expected 0 users after delete") + } +} + +func TestDeleteUser_NotFound(t *testing.T) { + d := newTestDB(t) + err := d.DeleteUser(context.Background(), 999) + if err == nil { + t.Fatal("expected error for non-existent user") + } +} + +func TestUpdateUserPassword(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "frank", hashPassword(t, "oldpw"), false) + newHash := hashPassword(t, "newpw") + if err := d.UpdateUserPassword(ctx, u.ID, newHash); err != nil { + t.Fatalf("UpdateUserPassword: %v", err) + } + + updated, _ := d.GetUserByID(ctx, u.ID) + if updated.PasswordHash == u.PasswordHash { + t.Fatal("password hash should have changed") + } +} + +// ─── Sessions ─────────────────────────────────────────────────────────── + +func TestCreateSession(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "grace", hashPassword(t, "pw"), false) + sess, err := d.CreateSession(ctx, u.ID, "tok123", futureTime()) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if sess.ID == 0 { + t.Fatal("expected non-zero session ID") + } +} + +func TestGetSessionByToken(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "heidi", hashPassword(t, "pw"), false) + d.CreateSession(ctx, u.ID, "abc", futureTime()) + + sess, err := d.GetSessionByToken(ctx, "abc") + if err != nil { + t.Fatalf("GetSessionByToken: %v", err) + } + if sess.Token != "abc" { + t.Fatalf("token = %q", "abc") + } + if sess.UserID != u.ID { + t.Fatalf("user_id = %d, want %d", sess.UserID, u.ID) + } +} + +func TestDeleteSession(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "ivan", hashPassword(t, "pw"), false) + d.CreateSession(ctx, u.ID, "tok1", futureTime()) + + if err := d.DeleteSession(ctx, "tok1"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + _, err := d.GetSessionByToken(ctx, "tok1") + if err == nil { + t.Fatal("expected error after delete") + } +} + +func TestDeleteUserSessions(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "judy", hashPassword(t, "pw"), false) + d.CreateSession(ctx, u.ID, "s1", futureTime()) + d.CreateSession(ctx, u.ID, "s2", futureTime()) + + if err := d.DeleteUserSessions(ctx, u.ID); err != nil { + t.Fatalf("DeleteUserSessions: %v", err) + } + _, err := d.GetSessionByToken(ctx, "s1") + if err == nil { + t.Fatal("expected session s1 to be deleted") + } + _, err = d.GetSessionByToken(ctx, "s2") + if err == nil { + t.Fatal("expected session s2 to be deleted") + } +} + +func TestExtendSession(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "karl", hashPassword(t, "pw"), false) + d.CreateSession(ctx, u.ID, "ext", futureTime()) + + newExpiry := futureTime() + if err := d.ExtendSession(ctx, "ext", newExpiry); err != nil { + t.Fatalf("ExtendSession: %v", err) + } + + sess, _ := d.GetSessionByToken(ctx, "ext") + if sess.ExpiresAt.Unix() != newExpiry.Unix() { + t.Fatalf("expiry = %v, want %v", sess.ExpiresAt, newExpiry) + } +} + +// ─── Images & Tags ────────────────────────────────────────────────────── + +func TestCreateAndGetImage(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "laurie", hashPassword(t, "pw"), false) + img, err := d.CreateImage(ctx, &Image{ + Filename: "photo.jpg", + StoragePath: "ab/cd/ef/abc123.jpg", + UploadedBy: u.ID, + FileSize: 1024, + Width: 800, + Height: 600, + MimeType: "image/jpeg", + }) + if err != nil { + t.Fatalf("CreateImage: %v", err) + } + if img.ID == 0 { + t.Fatal("expected non-zero ID") + } + + got, err := d.GetImageByID(ctx, img.ID) + if err != nil { + t.Fatalf("GetImageByID: %v", err) + } + if got.Filename != "photo.jpg" { + t.Fatalf("filename = %q", "photo.jpg") + } + if got.Width != 800 { + t.Fatalf("width = %d", 800) + } +} + +func TestListImages_Pagination(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "mia", hashPassword(t, "pw"), false) + for i := range 5 { + d.CreateImage(ctx, &Image{ + Filename: fmt.Sprintf("%d.jpg", i), + StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i), + UploadedBy: u.ID, + FileSize: 100, + Width: 100, + Height: 100, + MimeType: "image/jpeg", + }) + } + + // Test full list. + list, err := d.ListImages(ctx, nil, 10, 0) + if err != nil { + t.Fatalf("ListImages: %v", err) + } + if len(list.Images) != 5 { + t.Fatalf("expected 5 images, got %d", len(list.Images)) + } + if list.Total != 5 { + t.Fatalf("total = %d, want 5", list.Total) + } + + // Test pagination (newest-first). + list, _ = d.ListImages(ctx, nil, 2, 0) + if len(list.Images) != 2 { + t.Fatalf("expected 2 images, got %d", len(list.Images)) + } + if list.Total != 5 { + t.Fatalf("total = %d, want 5", list.Total) + } +} + +func TestSetImageTags(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "nick", hashPassword(t, "pw"), false) + img, _ := d.CreateImage(ctx, &Image{ + Filename: "tagged.jpg", StoragePath: "a/b/c/t.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + + if err := d.SetImageTags(ctx, img.ID, []string{"funny", "meme"}); err != nil { + t.Fatalf("SetImageTags: %v", err) + } + + tags, err := d.GetImageTags(ctx, img.ID) + if err != nil { + t.Fatalf("GetImageTags: %v", err) + } + if len(tags) != 2 || tags[0] != "funny" || tags[1] != "meme" { + t.Fatalf("tags = %v, want [funny meme]", tags) + } +} + +func TestSetImageTags_Replace(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "oscar", hashPassword(t, "pw"), false) + img, _ := d.CreateImage(ctx, &Image{ + Filename: "replace.jpg", StoragePath: "a/b/c/r.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + + d.SetImageTags(ctx, img.ID, []string{"old1", "old2"}) + d.SetImageTags(ctx, img.ID, []string{"new1"}) + + tags, _ := d.GetImageTags(ctx, img.ID) + if len(tags) != 1 || tags[0] != "new1" { + t.Fatalf("tags = %v, want [new1]", tags) + } +} + +func TestTagUsageCounts(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "pat", hashPassword(t, "pw"), false) + + img1, _ := d.CreateImage(ctx, &Image{Filename: "1.jpg", StoragePath: "a/b/c/1.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"}) + img2, _ := d.CreateImage(ctx, &Image{Filename: "2.jpg", StoragePath: "a/b/c/2.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"}) + + d.SetImageTags(ctx, img1.ID, []string{"funny"}) + d.SetImageTags(ctx, img2.ID, []string{"funny", "meme"}) + + suggestions, err := d.SuggestTags(ctx, "", 10) + if err != nil { + t.Fatalf("SuggestTags: %v", err) + } + + // funny should have count 2, meme count 1. + if len(suggestions) != 2 { + t.Fatalf("expected 2 tag suggestions, got %d", len(suggestions)) + } + if suggestions[0].Tag != "funny" || suggestions[0].UsageCount != 2 { + t.Fatalf("expected funny (2), got %s (%d)", suggestions[0].Tag, suggestions[0].UsageCount) + } + if suggestions[1].Tag != "meme" || suggestions[1].UsageCount != 1 { + t.Fatalf("expected meme (1), got %s (%d)", suggestions[1].Tag, suggestions[1].UsageCount) + } +} + +func TestDeleteImage(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "quinn", hashPassword(t, "pw"), false) + img, _ := d.CreateImage(ctx, &Image{Filename: "del.jpg", StoragePath: "a/b/c/d.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"}) + d.SetImageTags(ctx, img.ID, []string{"delete_me"}) + + if err := d.DeleteImage(ctx, img.ID); err != nil { + t.Fatalf("DeleteImage: %v", err) + } + + // Verify image gone. + _, err := d.GetImageByID(ctx, img.ID) + if err == nil { + t.Fatal("expected error after image deletion") + } + + // Verify tag usage count decremented. + suggestions, _ := d.SuggestTags(ctx, "", 10) + if len(suggestions) != 0 { + t.Fatalf("expected 0 tag after delete, got %d", len(suggestions)) + } +} + +func TestSuggestTags(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u, _ := d.CreateUser(ctx, "ray", hashPassword(t, "pw"), false) + img, _ := d.CreateImage(ctx, &Image{Filename: "s.jpg", StoragePath: "a/b/c/s.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"}) + d.SetImageTags(ctx, img.ID, []string{"landscape", "landscape_sunset", "portrait"}) + + tests := []struct { + name string + prefix string + want int + }{ + {"match landscape prefix", "land", 2}, + {"match port prefix", "port", 1}, + {"no match", "xyz", 0}, + {"empty prefix returns all", "", 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := d.SuggestTags(ctx, tt.prefix, 10) + if err != nil { + t.Fatalf("SuggestTags(%q): %v", tt.prefix, err) + } + if len(results) != tt.want { + t.Fatalf("SuggestTags(%q) = %d results, want %d", tt.prefix, len(results), tt.want) + } + }) + } +} + +func TestNormalizeTag(t *testing.T) { + tests := []struct { + input string + want string + }{ + {" Funny ", "funny"}, + {"MEME", "meme"}, + {" hello world ", "hello world"}, + {"", ""}, + {" ", ""}, + } + for _, tt := range tests { + got := normalizeTag(tt.input) + if got != tt.want { + t.Errorf("normalizeTag(%q) = %q, want %q", tt.input, got, tt.want) + } + } +}
@@ -0,0 +1,46 @@
+package db + +import "time" + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Session struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +type Image struct { + ID int64 `json:"id"` + Filename string `json:"filename"` + StoragePath string `json:"storage_path"` + ThumbnailPath *string `json:"thumbnail_path"` + UploadedBy int64 `json:"uploaded_by"` + FileSize int64 `json:"file_size"` + Width int `json:"width"` + Height int `json:"height"` + MimeType string `json:"mime_type"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Tags []string `json:"tags,omitempty"` +} + +type Tag struct { + Tag string `json:"tag"` + UsageCount int `json:"usage_count"` +} + +// ImageList is a paginated result of images. +type ImageList struct { + Images []Image `json:"images"` + Total int `json:"total"` +}
@@ -0,0 +1,445 @@
+package db + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// ─────────────────────────── Users ─────────────────────────── + +// CreateUser inserts a new user and returns it with the assigned ID. +func (d *DB) CreateUser(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) { + admin := 0 + if isAdmin { + admin = 1 + } + t := now() + res, err := d.db.ExecContext(ctx, + `INSERT INTO users (username, password_hash, is_admin, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + username, passwordHash, admin, t, t, + ) + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("create user last insert id: %w", err) + } + return d.GetUserByID(ctx, id) +} + +// GetUserByID returns a single user by primary key. +func (d *DB) GetUserByID(ctx context.Context, id int64) (*User, error) { + row := d.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, is_admin, created_at, updated_at + FROM users WHERE id = ?`, id) + return scanUser(row) +} + +// GetUserByUsername returns a single user by username. +func (d *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) { + row := d.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, is_admin, created_at, updated_at + FROM users WHERE username = ?`, username) + return scanUser(row) +} + +// ListUsers returns all users ordered by username. +func (d *DB) ListUsers(ctx context.Context) ([]User, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT id, username, password_hash, is_admin, created_at, updated_at + FROM users ORDER BY username`) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, *u) + } + return users, rows.Err() +} + +// DeleteUser removes a user by ID. Returns sql.ErrNoRows if not found. +func (d *DB) DeleteUser(ctx context.Context, id int64) error { + res, err := d.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +// UpdateUserPassword sets a new password hash for the user. +func (d *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error { + res, err := d.db.ExecContext(ctx, + `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, + passwordHash, now(), id) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +type userScanner interface { + Scan(dest ...any) error +} + +func scanUser(s userScanner) (*User, error) { + var u User + var isAdmin int + var createdAt, updatedAt string + if err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &isAdmin, &createdAt, &updatedAt); err != nil { + return nil, err + } + u.IsAdmin = isAdmin == 1 + u.CreatedAt, _ = scanTime(createdAt) + u.UpdatedAt, _ = scanTime(updatedAt) + return &u, nil +} + +// ─────────────────────────── Sessions ─────────────────────────── + +// CreateSession inserts a new session and returns it. +func (d *DB) CreateSession(ctx context.Context, userID int64, token string, expiresAt time.Time) (*Session, error) { + t := now() + res, err := d.db.ExecContext(ctx, + `INSERT INTO sessions (user_id, token, expires_at, created_at) + VALUES (?, ?, ?, ?)`, + userID, token, expiresAt.UTC().Format(time.RFC3339), t, + ) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("create session last insert id: %w", err) + } + + return &Session{ + ID: id, + UserID: userID, + Token: token, + ExpiresAt: expiresAt, + }, nil +} + +// GetSessionByToken retrieves a session by its token. +func (d *DB) GetSessionByToken(ctx context.Context, token string) (*Session, error) { + row := d.db.QueryRowContext(ctx, + `SELECT id, user_id, token, expires_at, created_at + FROM sessions WHERE token = ?`, token) + return scanSession(row) +} + +// DeleteSession removes a session by token. +func (d *DB) DeleteSession(ctx context.Context, token string) error { + _, err := d.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token) + return err +} + +// DeleteUserSessions removes all sessions for a given user. +func (d *DB) DeleteUserSessions(ctx context.Context, userID int64) error { + _, err := d.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) + return err +} + +// ExtendSession sets a new expiration time on the session. +func (d *DB) ExtendSession(ctx context.Context, token string, expiresAt time.Time) error { + _, err := d.db.ExecContext(ctx, + `UPDATE sessions SET expires_at = ? WHERE token = ?`, + expiresAt.UTC().Format(time.RFC3339), token) + return err +} + +type sessionScanner interface { + Scan(dest ...any) error +} + +func scanSession(s sessionScanner) (*Session, error) { + var sess Session + var createdAt, expiresAt string + if err := s.Scan(&sess.ID, &sess.UserID, &sess.Token, &expiresAt, &createdAt); err != nil { + return nil, err + } + sess.ExpiresAt, _ = scanTime(expiresAt) + sess.CreatedAt, _ = scanTime(createdAt) + return &sess, nil +} + +// ─────────────────────────── Images ─────────────────────────── + +// CreateImage inserts a new image record. +func (d *DB) CreateImage(ctx context.Context, img *Image) (*Image, error) { + t := now() + res, err := d.db.ExecContext(ctx, + `INSERT INTO images (filename, storage_path, thumbnail_path, uploaded_by, + file_size, width, height, mime_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + img.Filename, img.StoragePath, img.ThumbnailPath, img.UploadedBy, + img.FileSize, img.Width, img.Height, img.MimeType, t, t, + ) + if err != nil { + return nil, fmt.Errorf("create image: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("create image last insert id: %w", err) + } + return d.GetImageByID(ctx, id) +} + +// GetImageByID returns a single image by primary key. +func (d *DB) GetImageByID(ctx context.Context, id int64) (*Image, error) { + row := d.db.QueryRowContext(ctx, + `SELECT id, filename, storage_path, thumbnail_path, + uploaded_by, file_size, width, height, mime_type, + created_at, updated_at + FROM images WHERE id = ?`, id) + img, err := scanImage(row) + if err != nil { + return nil, err + } + + // Load tags for this image. + tags, err := d.GetImageTags(ctx, id) + if err != nil { + return nil, err + } + img.Tags = tags + return img, nil +} + +// ListImages returns a paginated list of images, optionally filtered by tags. +// Multiple tags are AND-ed (image must have all specified tags). +// Order is newest-first by created_at. +func (d *DB) ListImages(ctx context.Context, filterTags []string, limit, offset int) (*ImageList, error) { + // Count query + countQuery := `SELECT COUNT(DISTINCT i.id) FROM images i` + // Data query + dataQuery := `SELECT i.id, i.filename, i.storage_path, i.thumbnail_path, + i.uploaded_by, i.file_size, i.width, i.height, i.mime_type, + i.created_at, i.updated_at + FROM images i` + + var args []any + + if len(filterTags) > 0 { + placeholders := make([]string, len(filterTags)) + for j, t := range filterTags { + placeholders[j] = "?" + args = append(args, t) + } + having := fmt.Sprintf("HAVING COUNT(DISTINCT it.tag) = %d", len(filterTags)) + + join := ` JOIN image_tags it ON i.id = it.image_id WHERE it.tag IN (` + + strings.Join(placeholders, ",") + `)` + countQuery += join + dataQuery += join + ` GROUP BY i.id ` + having + } + + dataQuery += ` ORDER BY i.created_at DESC LIMIT ? OFFSET ?` + argsData := make([]any, len(args)) + copy(argsData, args) + argsData = append(argsData, limit, offset) + args = append(args, limit, offset) + + // Execute count. + var total int + if err := d.db.QueryRowContext(ctx, countQuery, argsData[:len(argsData)-2]...).Scan(&total); err != nil { + return nil, fmt.Errorf("list images count: %w", err) + } + + // Execute data. + rows, err := d.db.QueryContext(ctx, dataQuery, argsData...) + if err != nil { + return nil, fmt.Errorf("list images: %w", err) + } + defer rows.Close() + + var images []Image + for rows.Next() { + img, err := scanImage(rows) + if err != nil { + return nil, err + } + images = append(images, *img) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return &ImageList{Images: images, Total: total}, nil +} + +// DeleteImage removes an image record (tags cleaned via ON DELETE CASCADE). +func (d *DB) DeleteImage(ctx context.Context, id int64) error { + // First decrement usage counts for associated tags. + tags, err := d.GetImageTags(ctx, id) + if err != nil { + return err + } + for _, tag := range tags { + if err := d.decrementTagUsage(ctx, tag); err != nil { + return err + } + } + + res, err := d.db.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete image: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +type imageScanner interface { + Scan(dest ...any) error +} + +func scanImage(s imageScanner) (*Image, error) { + var img Image + var thumbnailPath, createdAt, updatedAt sql.NullString + if err := s.Scan( + &img.ID, &img.Filename, &img.StoragePath, &thumbnailPath, + &img.UploadedBy, &img.FileSize, &img.Width, &img.Height, &img.MimeType, + &createdAt, &updatedAt, + ); err != nil { + return nil, err + } + if thumbnailPath.Valid { + img.ThumbnailPath = &thumbnailPath.String + } + if createdAt.Valid { + img.CreatedAt, _ = scanTime(createdAt.String) + } + if updatedAt.Valid { + img.UpdatedAt, _ = scanTime(updatedAt.String) + } + return &img, nil +} + +// ─────────────────────────── Tags ─────────────────────────── + +// SetImageTags replaces all tags for an image and updates usage counts. +func (d *DB) SetImageTags(ctx context.Context, imageID int64, tags []string) error { + // Get existing tags to decrement counts. + oldTags, err := d.GetImageTags(ctx, imageID) + if err != nil { + return err + } + + // Remove old associations. + if _, err := d.db.ExecContext(ctx, `DELETE FROM image_tags WHERE image_id = ?`, imageID); err != nil { + return fmt.Errorf("clear image tags: %w", err) + } + + for _, t := range oldTags { + if err := d.decrementTagUsage(ctx, t); err != nil { + return err + } + } + + // Insert new tags. + for _, t := range tags { + tag := normalizeTag(t) + if tag == "" { + continue + } + if _, err := d.db.ExecContext(ctx, + `INSERT INTO image_tags (image_id, tag) VALUES (?, ?)`, imageID, tag, + ); err != nil { + return fmt.Errorf("insert image tag: %w", err) + } + if err := d.incrementTagUsage(ctx, tag); err != nil { + return err + } + } + + return nil +} + +// GetImageTags returns the tag strings for a given image. +func (d *DB) GetImageTags(ctx context.Context, imageID int64) ([]string, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT tag FROM image_tags WHERE image_id = ? ORDER BY tag`, imageID) + if err != nil { + return nil, fmt.Errorf("get image tags: %w", err) + } + defer rows.Close() + + var tags []string + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + return nil, err + } + tags = append(tags, t) + } + return tags, rows.Err() +} + +// SuggestTags returns tags matching a prefix, ordered by usage count descending. +func (d *DB) SuggestTags(ctx context.Context, prefix string, limit int) ([]Tag, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT tag, usage_count FROM tags WHERE tag LIKE ? ORDER BY usage_count DESC LIMIT ?`, + prefix+"%", limit) + if err != nil { + return nil, fmt.Errorf("suggest tags: %w", err) + } + defer rows.Close() + + var tags []Tag + for rows.Next() { + var t Tag + if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil { + return nil, err + } + tags = append(tags, t) + } + return tags, rows.Err() +} + +func (d *DB) incrementTagUsage(ctx context.Context, tag string) error { + _, err := d.db.ExecContext(ctx, + `INSERT INTO tags (tag, usage_count) VALUES (?, 1) + ON CONFLICT(tag) DO UPDATE SET usage_count = usage_count + 1`, + tag) + return err +} + +func (d *DB) decrementTagUsage(ctx context.Context, tag string) error { + _, err := d.db.ExecContext(ctx, + `UPDATE tags SET usage_count = usage_count - 1 WHERE tag = ? AND usage_count > 0`, + tag) + if err != nil { + return err + } + // Clean up tags with zero usage. + _, err = d.db.ExecContext(ctx, `DELETE FROM tags WHERE tag = ? AND usage_count <= 0`, tag) + return err +} + +// normalizeTag trims whitespace, lowercases, and collapses internal whitespace. +func normalizeTag(tag string) string { + return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(tag))), " ") +}
@@ -0,0 +1,166 @@
+package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// DB wraps a *sql.DB connection to a SQLite database. +type DB struct { + db *sql.DB +} + +// Open opens (or creates) a SQLite database at the given path. +func Open(path string) (*DB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + // SQLite is safe with a single connection for reads and serialises writes. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + + return &DB{db: db}, nil +} + +// Close closes the database connection. +func (d *DB) Close() error { + return d.db.Close() +} + +// DB returns the underlying *sql.DB for use in query methods. +func (d *DB) DB() *sql.DB { + return d.db +} + +// Migrate runs all pending schema migrations. +func (d *DB) Migrate(ctx context.Context) error { + _, err := d.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ) + `) + if err != nil { + return fmt.Errorf("creating migrations table: %w", err) + } + + migrations := []struct { + version int + sql string + }{ + { + version: 1, + sql: ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `, + }, + { + version: 2, + sql: ` + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `, + }, + { + version: 3, + sql: ` + CREATE TABLE IF NOT EXISTS images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + storage_path TEXT NOT NULL, + thumbnail_path TEXT, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + file_size INTEGER NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + mime_type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `, + }, + { + version: 4, + sql: ` + CREATE TABLE IF NOT EXISTS image_tags ( + image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (image_id, tag) + ) + `, + }, + { + version: 5, + sql: ` + CREATE TABLE IF NOT EXISTS tags ( + tag TEXT PRIMARY KEY, + usage_count INTEGER DEFAULT 0 + ) + `, + }, + { + version: 6, + sql: ` + CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_images_uploaded_by ON images(uploaded_by); + CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at); + CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag); + `, + }, + } + + for _, m := range migrations { + var count int + err := d.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version, + ).Scan(&count) + if err != nil { + return fmt.Errorf("checking migration %d: %w", m.version, err) + } + if count > 0 { + continue + } + + if _, err := d.db.ExecContext(ctx, m.sql); err != nil { + return fmt.Errorf("running migration %d: %w", m.version, err) + } + + if _, err := d.db.ExecContext(ctx, + "INSERT INTO schema_migrations (version) VALUES (?)", m.version, + ); err != nil { + return fmt.Errorf("recording migration %d: %w", m.version, err) + } + } + + return nil +} + +// now returns the current UTC time formatted as ISO 8601 (RFC 3339). +func now() string { + return time.Now().UTC().Format(time.RFC3339) +} + +// scanTime parses an ISO 8601 string into time.Time. +func scanTime(s string) (time.Time, error) { + return time.Parse(time.RFC3339, s) +}
@@ -0,0 +1,40 @@
+package server + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/mjensen/weimar/internal/api" + "github.com/mjensen/weimar/internal/auth" +) + +// requireAuth wraps a handler with session cookie authentication. +// Unauthenticated requests receive a 401 JSON error. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(auth.SessionCookieName) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return + } + + user, err := s.auth.Authenticate(r.Context(), cookie.Value) + if err != nil { + slog.Debug("auth failed", "reason", err) + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return + } + + ctx := context.WithValue(r.Context(), api.CtxKeyUser, user) + next(w, r.WithContext(ctx)) + } +} + +// writeJSONError writes a JSON error response. +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(`{"error":"` + strings.ReplaceAll(msg, `"`, `\"`) + `"}`)) +}
@@ -4,43 +4,61 @@ import (
"context" "io/fs" "net/http" + "strings" + "github.com/mjensen/weimar/internal/api" + "github.com/mjensen/weimar/internal/auth" "github.com/mjensen/weimar/internal/config" + "github.com/mjensen/weimar/internal/db" + "github.com/mjensen/weimar/internal/storage" ) -func New(cfg config.Config, webFS fs.FS) (*Server, error) { +func New(cfg config.Config, webFS fs.FS, database *db.DB) (*Server, error) { + sm := auth.NewSessionManager(database) + st, err := storage.New(cfg.Storage.Path) + if err != nil { + return nil, err + } + h := api.New(database, sm, st, cfg) + mux := http.NewServeMux() - // API routes + // Health check (public). mux.HandleFunc("GET /api/health", handleHealth) - // Auth - mux.HandleFunc("POST /api/auth/login", handleNotImplemented) - mux.HandleFunc("POST /api/auth/register", handleNotImplemented) + s := &Server{ + cfg: cfg, + mux: mux, + auth: sm, + } - // Images - mux.HandleFunc("POST /api/upload", handleNotImplemented) - mux.HandleFunc("GET /api/images", handleNotImplemented) - mux.HandleFunc("GET /api/images/{id}", handleNotImplemented) - mux.HandleFunc("GET /api/images/{id}/download", handleNotImplemented) - mux.HandleFunc("GET /api/images/{id}/thumbnail", handleNotImplemented) + // Auth (public). + mux.HandleFunc("POST /api/auth/login", h.HandleLogin) + mux.HandleFunc("POST /api/auth/register", h.HandleRegister) - // Tags - mux.HandleFunc("GET /api/tags/suggest", handleNotImplemented) + // Auth (authenticated). + mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout)) - // SPA — serve index.html for all non-API, non-file routes - spaFS := http.FileServer(http.FS(webFS)) - mux.Handle("/", spaFS) + // Images (authenticated). + mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload)) + mux.HandleFunc("GET /api/images", s.requireAuth(h.HandleListImages)) + mux.HandleFunc("GET /api/images/{id}", s.requireAuth(h.HandleGetImage)) + mux.HandleFunc("GET /api/images/{id}/download", s.requireAuth(h.HandleDownload)) + mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuth(h.HandleThumbnail)) - return &Server{ - cfg: cfg, - mux: mux, - }, nil + // Tags (authenticated). + mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest)) + + // SPA — serve index.html for all non-API routes (client-side routing). + mux.Handle("/", s.spaHandler(webFS)) + + return s, nil } type Server struct { - cfg config.Config - mux *http.ServeMux + cfg config.Config + mux *http.ServeMux + auth *auth.SessionManager } func (s *Server) Start(ctx context.Context) error {@@ -69,11 +87,33 @@ }
return string(buf[i:]) } +// spaHandler serves static files for SPA client-side routing. +// For routes that don't match an existing file (e.g. /browse, /image/123), +// it falls back to serving index.html so the SPA router can handle them. +func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc { + fsHandler := http.FileServer(http.FS(webFS)) + return func(w http.ResponseWriter, r *http.Request) { + // API routes should not be served by the SPA handler. + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + // Check if the requested file exists in the embedded FS. + path := strings.TrimPrefix(r.URL.Path, "/") + if _, err := fs.Stat(webFS, path); err != nil { + // File doesn't exist — serve index.html for SPA routing. + r.URL.Path = "/" + fsHandler.ServeHTTP(w, r) + return + } + + // File exists — serve it directly. + fsHandler.ServeHTTP(w, r) + } +} + func handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) } - -func handleNotImplemented(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) -}
@@ -0,0 +1,92 @@
+package storage + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" +) + +// Storage handles reading/writing files on a hash-partitioned filesystem. +// +// File layout: {basePath}/ab/cd/ef/{hash[:12]}.{ext} +// The first 6 hex chars of SHA-256 determine 3 directory levels (2 chars each). +type Storage struct { + basePath string +} + +// New creates a Storage rooted at basePath, ensuring the directory exists. +func New(basePath string) (*Storage, error) { + abs, err := filepath.Abs(basePath) + if err != nil { + return nil, fmt.Errorf("storage path: %w", err) + } + if err := os.MkdirAll(abs, 0755); err != nil { + return nil, fmt.Errorf("create storage dir: %w", err) + } + return &Storage{basePath: abs}, nil +} + +// Store writes data to a hash-partitioned path and returns the hex hash and +// relative storage path (e.g. "ab/cd/ef/a1b2c3d4e5f6.jpg"). +func (s *Storage) Store(data []byte, ext string) (hash, storagePath string, err error) { + h := sha256.Sum256(data) + hexHash := fmt.Sprintf("%x", h) + rel := filepath.Join(hexHash[:2], hexHash[2:4], hexHash[4:6], hexHash[:12]+"."+ext) + abs := filepath.Join(s.basePath, rel) + + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + return "", "", fmt.Errorf("create partition dir: %w", err) + } + if err := os.WriteFile(abs, data, 0644); err != nil { + return "", "", fmt.Errorf("write file: %w", err) + } + + return hexHash, rel, nil +} + +// Open returns the file at the given relative storage path. +func (s *Storage) Open(storagePath string) (*os.File, error) { + f, err := os.Open(filepath.Join(s.basePath, storagePath)) + if err != nil { + return nil, fmt.Errorf("open %s: %w", storagePath, err) + } + return f, nil +} + +// Delete removes the file at the given relative storage path and its parent +// directories if they become empty. +func (s *Storage) Delete(storagePath string) error { + abs := filepath.Join(s.basePath, storagePath) + if err := os.Remove(abs); err != nil { + return fmt.Errorf("delete %s: %w", storagePath, err) + } + + // Clean up empty parent directories (3 levels: ab/ → cd/ → ef/). + dir := filepath.Dir(abs) + for range 3 { + entries, err := os.ReadDir(dir) + if err != nil || len(entries) > 0 { + break + } + os.Remove(dir) + dir = filepath.Dir(dir) + } + + return nil +} + +// StoreAt writes data to a specific relative path (used for thumbnails whose +// path is derived from the original file's storage path). +func (s *Storage) StoreAt(relPath string, data []byte) error { + abs := filepath.Join(s.basePath, relPath) + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + return os.WriteFile(abs, data, 0644) +} + +// AbsPath returns the absolute filesystem path for a relative storage path. +func (s *Storage) AbsPath(storagePath string) string { + return filepath.Join(s.basePath, storagePath) +}
@@ -0,0 +1,141 @@
+package storage + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNew(t *testing.T) { + dir := t.TempDir() + s, err := New(dir) + if err != nil { + t.Fatalf("New: %v", err) + } + if s.basePath != dir { + t.Fatalf("basePath = %q, want %q", s.basePath, dir) + } + // Verify directory was created. + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Fatal("storage directory was not created") + } +} + +func TestStoreAndOpen(t *testing.T) { + dir := t.TempDir() + s, _ := New(dir) + + data := []byte("hello world") + hash, rel, err := s.Store(data, "txt") + if err != nil { + t.Fatalf("Store: %v", err) + } + if hash == "" { + t.Fatal("expected non-empty hash") + } + if !strings.HasSuffix(rel, ".txt") { + t.Fatalf("expected .txt extension, got %q", rel) + } + if !strings.HasPrefix(rel, hash[:2]+"/"+hash[2:4]+"/"+hash[4:6]+"/") { + t.Fatalf("rel path %q does not match hash prefix", rel) + } + + f, err := s.Open(rel) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() + + var buf bytes.Buffer + buf.ReadFrom(f) + if buf.String() != "hello world" { + t.Fatalf("content = %q, want %q", buf.String(), "hello world") + } + + // Check absolute path. + abs := s.AbsPath(rel) + if _, err := os.Stat(abs); os.IsNotExist(err) { + t.Fatal("absolute path does not exist") + } +} + +func TestStoreAt(t *testing.T) { + dir := t.TempDir() + s, _ := New(dir) + + if err := s.StoreAt("custom/dir/file.txt", []byte("custom")); err != nil { + t.Fatalf("StoreAt: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "custom/dir/file.txt")) + if err != nil { + t.Fatalf("reading stored file: %v", err) + } + if string(data) != "custom" { + t.Fatalf("content = %q", string(data)) + } +} + +func TestDelete(t *testing.T) { + dir := t.TempDir() + s, _ := New(dir) + + _, rel, _ := s.Store([]byte("delete me"), "txt") + + if err := s.Delete(rel); err != nil { + t.Fatalf("Delete: %v", err) + } + + // File should be gone. + abs := filepath.Join(dir, rel) + if _, err := os.Stat(abs); !os.IsNotExist(err) { + t.Fatal("file should be deleted") + } +} + +func TestDelete_NotFound(t *testing.T) { + dir := t.TempDir() + s, _ := New(dir) + + err := s.Delete("nonexistent/file.txt") + if err == nil { + t.Fatal("expected error deleting non-existent file") + } +} + +func TestStore_Duplicate(t *testing.T) { + // Storing the same data twice should succeed (overwrites). + dir := t.TempDir() + s, _ := New(dir) + + hash1, _, _ := s.Store([]byte("same data"), "txt") + hash2, rel2, _ := s.Store([]byte("same data"), "txt") + + if hash1 != hash2 { + t.Fatal("same data should produce same hash") + } + + // File should still be readable. + f, _ := s.Open(rel2) + defer f.Close() + var buf bytes.Buffer + buf.ReadFrom(f) + if buf.String() != "same data" { + t.Fatalf("content = %q", buf.String()) + } +} + +func TestConsistentPartitioning(t *testing.T) { + dir := t.TempDir() + s, _ := New(dir) + + data := []byte("partition test") + hash, rel, _ := s.Store(data, "dat") + + expected := hash[:2] + "/" + hash[2:4] + "/" + hash[4:6] + "/" + hash[:12] + ".dat" + if rel != expected { + t.Fatalf("rel = %q, want %q", rel, expected) + } +}
@@ -0,0 +1,251 @@
+package storage + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" + "image/jpeg" + _ "image/png" + "os/exec" + "strings" + + "golang.org/x/image/draw" + _ "golang.org/x/image/bmp" + "golang.org/x/image/webp" +) + +// DetectContentType identifies the MIME type from magic bytes. +func DetectContentType(data []byte) string { + if len(data) < 2 { + return "application/octet-stream" + } + + // Check magic bytes (most specific first). + switch { + case len(data) > 3 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47: + return "image/png" + case len(data) > 2 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: + return "image/jpeg" + case len(data) > 2 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46: + return "image/gif" + case len(data) > 1 && data[0] == 0x42 && data[1] == 0x4D: + return "image/bmp" + case len(data) > 3 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46: + // RIFF — could be WebP or AVI + if len(data) > 11 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 { + return "image/webp" + } + case len(data) > 7 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70: + // ftyp box (MP4, QuickTime) + return "video/mp4" + case len(data) > 3 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3: + return "video/webm" + } + + return "application/octet-stream" +} + +// ExtensionFromMIME returns the file extension (without dot) for common MIME types. +func ExtensionFromMIME(mimeType string) string { + switch mimeType { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/gif": + return "gif" + case "image/webp": + return "webp" + case "image/bmp": + return "bmp" + case "video/mp4": + return "mp4" + case "video/webm": + return "webm" + default: + return "bin" + } +} + +// IsImage returns true if the MIME type is a supported image format. +func IsImage(mimeType string) bool { + switch mimeType { + case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp": + return true + } + return false +} + +// IsVideo returns true if the MIME type is a supported video format. +func IsVideo(mimeType string) bool { + switch mimeType { + case "video/mp4", "video/webm": + return true + } + return false +} + +// StripEXIF removes EXIF (APP1) segments from JPEG data without re-compression. +// Non-JPEG data is returned unchanged. +func StripEXIF(data []byte) ([]byte, error) { + if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 { + return data, nil + } + + var buf bytes.Buffer + i := 0 + for i < len(data) { + if data[i] != 0xFF { + buf.Write(data[i:]) + break + } + + marker := data[i+1] + + // SOI — no segment length + if marker == 0xD8 { + buf.Write(data[i : i+2]) + i += 2 + continue + } + + // EOI — end of image + if marker == 0xD9 { + buf.Write(data[i:]) + break + } + + // Standalone markers (no segment data) + if marker == 0x00 || (marker >= 0xD0 && marker <= 0xD7) { + buf.Write(data[i : i+2]) + i += 2 + continue + } + + // Need length bytes + if i+3 >= len(data) { + buf.Write(data[i:]) + break + } + + length := int(data[i+2])<<8 | int(data[i+3]) + + // APP1 — EXIF or XMP; skip it + if marker == 0xE1 { + i += 2 + length + continue + } + + // Copy all other segments verbatim + end := i + 2 + length + if end > len(data) { + end = len(data) + } + buf.Write(data[i:end]) + i = end + } + + return buf.Bytes(), nil +} + +// ThumbnailMaxDimension is the default maximum dimension for generated thumbnails. +const ThumbnailMaxDimension = 300 + +// GenerateThumbnail decodes the image data (JPEG, PNG, GIF, WebP, BMP), resizes +// it to fit within maxDimension×maxDimension (preserving aspect ratio), and +// re-encodes as JPEG. Returns the thumbnail bytes. +func GenerateThumbnail(data []byte, maxDimension int) ([]byte, error) { + src, _, err := decodeImage(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode image: %w", err) + } + + bounds := src.Bounds() + w := bounds.Dx() + h := bounds.Dy() + + if w <= maxDimension && h <= maxDimension { + // Image is already small enough — encode as JPEG directly. + return encodeJPEG(src) + } + + // Compute new dimensions preserving aspect ratio. + if w > h { + h = h * maxDimension / w + w = maxDimension + } else { + w = w * maxDimension / h + h = maxDimension + } + + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.BiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + + return encodeJPEG(dst) +} + +// FFmpegAvailable returns true if ffmpeg is found on PATH. +func FFmpegAvailable() bool { + _, err := exec.LookPath("ffmpeg") + return err == nil +} + +// GenerateVideoThumbnail extracts a single frame from a video file using ffmpeg +// and writes it to outputPath as JPEG. +func GenerateVideoThumbnail(videoPath, outputPath string) error { + cmd := exec.Command("ffmpeg", + "-i", videoPath, + "-vframes", "1", + "-s", fmt.Sprintf("%dx%d", ThumbnailMaxDimension, ThumbnailMaxDimension), + "-y", + outputPath, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ffmpeg thumbnail: %w\n%s", err, string(out)) + } + return nil +} + +// ThumbnailPath returns the relative thumbnail path for a given storage path. +// Convention: replace extension with "_thumb.jpg". +// +// "ab/cd/ef/a1b2c3d4e5f6.jpg" → "ab/cd/ef/a1b2c3d4e5f6_thumb.jpg" +func ThumbnailPath(storagePath string) string { + dot := strings.LastIndex(storagePath, ".") + if dot < 0 { + return storagePath + "_thumb.jpg" + } + return storagePath[:dot] + "_thumb.jpg" +} + +// DecodeImage decodes a supported image format from raw bytes. +// Returns nil if the format is not recognised. +func DecodeImage(data []byte) (image.Image, string, error) { + return decodeImage(bytes.NewReader(data)) +} + +func decodeImage(r *bytes.Reader) (image.Image, string, error) { + // Try standard formats first. + img, format, err := image.Decode(r) + if err == nil { + return img, format, nil + } + + // Reset and try WebP. + r.Seek(0, 0) + img, err = webp.Decode(r) + if err == nil { + return img, "webp", nil + } + + return nil, "", fmt.Errorf("unsupported image format") +} + +func encodeJPEG(img image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil { + return nil, fmt.Errorf("encode JPEG: %w", err) + } + return buf.Bytes(), nil +}
@@ -0,0 +1,204 @@
+package storage + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "path/filepath" + "testing" +) + +func TestDetectContentType(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + {"JPEG", []byte{0xFF, 0xD8, 0xFF, 0xE0}, "image/jpeg"}, + {"PNG", []byte{0x89, 0x50, 0x4E, 0x47}, "image/png"}, + {"GIF", []byte{0x47, 0x49, 0x46, 0x38}, "image/gif"}, + {"WebP", []byte{0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50}, "image/webp"}, + {"BMP", []byte{0x42, 0x4D}, "image/bmp"}, + {"MP4", []byte{0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70}, "video/mp4"}, + {"WebM", []byte{0x1A, 0x45, 0xDF, 0xA3}, "video/webm"}, + {"unknown", []byte{0x00, 0x01, 0x02}, "application/octet-stream"}, + {"empty", []byte{}, "application/octet-stream"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectContentType(tt.data) + if got != tt.want { + t.Fatalf("DetectContentType = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtensionFromMIME(t *testing.T) { + if ext := ExtensionFromMIME("image/jpeg"); ext != "jpg" { + t.Fatalf("jpg extension mismatch: %s", ext) + } + if ext := ExtensionFromMIME("image/png"); ext != "png" { + t.Fatalf("png extension mismatch: %s", ext) + } + if ext := ExtensionFromMIME("image/gif"); ext != "gif" { + t.Fatalf("gif extension mismatch: %s", ext) + } + if ext := ExtensionFromMIME("video/mp4"); ext != "mp4" { + t.Fatalf("mp4 extension mismatch: %s", ext) + } + if ext := ExtensionFromMIME("application/octet-stream"); ext != "bin" { + t.Fatalf("bin extension mismatch: %s", ext) + } +} + +func TestIsImage(t *testing.T) { + if !IsImage("image/jpeg") { + t.Fatal("expected jpeg to be image") + } + if IsImage("video/mp4") { + t.Fatal("expected mp4 to not be image") + } +} + +func TestIsVideo(t *testing.T) { + if !IsVideo("video/mp4") { + t.Fatal("expected mp4 to be video") + } + if IsVideo("image/jpeg") { + t.Fatal("expected jpeg to not be video") + } +} + +func createTestJPEG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatalf("encode test jpeg: %v", err) + } + return buf.Bytes() +} + +func TestGenerateThumbnail_JPEG(t *testing.T) { + data := createTestJPEG(t, 800, 600) + thumb, err := GenerateThumbnail(data, ThumbnailMaxDimension) + if err != nil { + t.Fatalf("GenerateThumbnail: %v", err) + } + if len(thumb) == 0 { + t.Fatal("expected non-empty thumbnail") + } + + // Decode and verify dimensions. + img, _, err := image.Decode(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("decode thumbnail: %v", err) + } + bounds := img.Bounds() + if bounds.Dx() > ThumbnailMaxDimension || bounds.Dy() > ThumbnailMaxDimension { + t.Fatalf("thumbnail too large: %dx%d", bounds.Dx(), bounds.Dy()) + } +} + +func TestGenerateThumbnail_SmallImage(t *testing.T) { + data := createTestJPEG(t, 50, 50) + thumb, err := GenerateThumbnail(data, ThumbnailMaxDimension) + if err != nil { + t.Fatalf("GenerateThumbnail: %v", err) + } + if len(thumb) == 0 { + t.Fatal("expected non-empty thumbnail for small image") + } +} + +func TestGenerateThumbnail_PNG(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 400, 300)) + var buf bytes.Buffer + png.Encode(&buf, img) + thumb, err := GenerateThumbnail(buf.Bytes(), ThumbnailMaxDimension) + if err != nil { + t.Fatalf("GenerateThumbnail PNG: %v", err) + } + if len(thumb) == 0 { + t.Fatal("expected non-empty thumbnail") + } +} + +func TestGenerateThumbnail_InvalidFormat(t *testing.T) { + _, err := GenerateThumbnail([]byte("not an image"), ThumbnailMaxDimension) + if err == nil { + t.Fatal("expected error for invalid image data") + } +} + +func TestThumbnailPath(t *testing.T) { + if got := ThumbnailPath("ab/cd/ef/photo.jpg"); got != "ab/cd/ef/photo_thumb.jpg" { + t.Fatalf("ThumbnailPath = %q", got) + } + if got := ThumbnailPath("single.jpg"); got != "single_thumb.jpg" { + t.Fatalf("ThumbnailPath = %q", got) + } +} + +func TestDetectContentType_RealJPEG(t *testing.T) { + data := createTestJPEG(t, 100, 100) + mime := DetectContentType(data) + if mime != "image/jpeg" { + t.Fatalf("mime = %q, want image/jpeg", mime) + } +} + +func TestStripEXIF_NoEXIF(t *testing.T) { + data := createTestJPEG(t, 100, 100) + cleaned, err := StripEXIF(data) + if err != nil { + t.Fatalf("StripEXIF: %v", err) + } + if len(cleaned) == 0 { + t.Fatal("expected non-empty cleaned data") + } +} + +func TestDecodeImage(t *testing.T) { + data := createTestJPEG(t, 640, 480) + cfg, _, err := DecodeImage(data) + if err != nil { + t.Fatalf("DecodeImage: %v", err) + } + if cfg.Bounds().Dx() != 640 { + t.Fatalf("width = %d, want 640", cfg.Bounds().Dx()) + } + if cfg.Bounds().Dy() != 480 { + t.Fatalf("height = %d, want 480", cfg.Bounds().Dy()) + } +} + +func TestDecodeImage_Invalid(t *testing.T) { + _, _, err := DecodeImage([]byte("not an image")) + if err == nil { + t.Fatal("expected error for invalid image") + } +} + +func TestFFmpegAvailable(t *testing.T) { + FFmpegAvailable() +} + +func TestGenerateVideoThumbnail_Skipped(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "test.mp4") + output := filepath.Join(dir, "out.jpg") + + err := GenerateVideoThumbnail(input, output) + if err == nil { + t.Fatal("expected error for non-existent video") + } +}
@@ -3,6 +3,7 @@ <html lang="en">
<head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>weimar</title> %sveltekit.head% </head> <body>
@@ -0,0 +1,104 @@
+const BASE = '/api'; + +export interface User { + id: number; + username: string; + is_admin: boolean; + created_at: string; + updated_at: string; +} + +export interface Image { + id: number; + filename: string; + storage_path: string; + thumbnail_path: string | null; + uploaded_by: number; + file_size: number; + width: number; + height: number; + mime_type: string; + created_at: string; + updated_at: string; + tags: string[]; +} + +export interface ImageList { + images: Image[]; + total: number; +} + +export interface Tag { + tag: string; + usage_count: number; +} + +export interface ApiError { + error: string; +} + +async function request<T>(path: string, opts: RequestInit = {}): Promise<T> { + const res = await fetch(BASE + path, { + credentials: 'include', + headers: opts.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }, + ...opts, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((body as ApiError).error || `HTTP ${res.status}`); + } + return res.json(); +} + +export async function login(username: string, password: string): Promise<User> { + return request<User>('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); +} + +export async function register(username: string, password: string): Promise<User> { + return request<User>('/auth/register', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); +} + +export async function logout(): Promise<void> { + await request('/auth/logout', { method: 'POST' }); +} + +export async function listImages(tags: string[] = [], page = 1, perPage = 50): Promise<ImageList> { + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('per_page', String(perPage)); + for (const t of tags) { + params.append('tags', t); + } + return request<ImageList>(`/images?${params}`); +} + +export async function getImage(id: number): Promise<Image> { + return request<Image>(`/images/${id}`); +} + +export function imageDownloadUrl(id: number): string { + return `${BASE}/images/${id}/download`; +} + +export function thumbnailUrl(id: number): string { + return `${BASE}/images/${id}/thumbnail`; +} + +export async function uploadImage(file: File, tags: string[]): Promise<Image> { + const fd = new FormData(); + fd.append('file', file); + for (const t of tags) { + fd.append('tags', t); + } + return request<Image>('/upload', { method: 'POST', body: fd }); +} + +export async function suggestTags(query: string): Promise<Tag[]> { + return request<Tag[]>(`/tags/suggest?q=${encodeURIComponent(query)}`); +}
@@ -0,0 +1,83 @@
+<script lang="ts"> + import { onMount } from 'svelte'; + import { logout } from '$lib/api'; + + let { children } = $props(); + + let loggedIn = $state(false); + let checking = $state(true); + + onMount(() => { + fetch('/api/images?per_page=1', { credentials: 'include' }) + .then((r) => { + if (r.ok) loggedIn = true; + }) + .catch(() => {}) + .finally(() => { checking = false; }); + }); + + async function handleLogout() { + await logout(); + loggedIn = false; + window.location.href = '/login'; + } +</script> + +<nav> + <a href="/">weimar</a> + <div class="nav-links"> + <a href="/browse">Browse</a> + <a href="/upload">Upload</a> + {#if !checking} + {#if loggedIn} + <button class="link logout" onclick={handleLogout}>Logout</button> + {:else} + <a href="/login">Login</a> + {/if} + {/if} + </div> +</nav> + +<main> + {@render children()} +</main> + +<style> + nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: #1a1a2e; + color: #eee; + } + nav a { + color: #eee; + text-decoration: none; + } + nav a:hover { + text-decoration: underline; + } + .nav-links { + display: flex; + gap: 1rem; + align-items: center; + } + .logout { + background: none; + border: none; + color: #eee; + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: none; + } + .logout:hover { + text-decoration: underline; + } + main { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem; + } +</style>
@@ -1,12 +1,45 @@
-<h1>weimar</h1> -<p>Loading...</p> - -<script> +<script lang="ts"> import { onMount } from 'svelte'; + let status = $state('Checking...'); + onMount(async () => { - const res = await fetch('/api/health'); - const data = await res.json(); - document.querySelector('p').textContent = 'Status: ' + data.status; + try { + const res = await fetch('/api/health'); + const data = await res.json(); + status = data.status === 'ok' ? 'Server is running' : `Status: ${data.status}`; + } catch { + status = 'Server unreachable'; + } }); </script> + +<h1>weimar</h1> +<p>{status}</p> + +<div class="actions"> + <a href="/browse">Browse images</a> + <a href="/upload">Upload image</a> +</div> + +<style> + h1 { + font-size: 3rem; + margin-bottom: 0.5rem; + } + .actions { + margin-top: 2rem; + display: flex; + gap: 1rem; + } + .actions a { + padding: 0.5rem 1.5rem; + background: #1a1a2e; + color: #eee; + text-decoration: none; + border-radius: 6px; + } + .actions a:hover { + background: #16213e; + } +</style>
@@ -1,2 +1,190 @@
+<script lang="ts"> + import { onMount } from 'svelte'; + import { listImages, thumbnailUrl, type Image } from '$lib/api'; + + let images = $state<Image[]>([]); + let total = $state(0); + let loading = $state(true); + let error = $state(''); + let filterTag = $state(''); + let page = $state(1); + const perPage = 50; + + onMount(() => loadImages()); + + async function loadImages() { + loading = true; + error = ''; + try { + const tags = filterTag ? [filterTag] : []; + const result = await listImages(tags, page, perPage); + images = result.images; + total = result.total; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load images'; + images = []; + } finally { + loading = false; + } + } + + function prevPage() { + if (page > 1) { page--; loadImages(); } + } + + function nextPage() { + if (page * perPage < total) { page++; loadImages(); } + } + + function applyFilter() { + page = 1; + loadImages(); + } +</script> + <h1>Browse</h1> -<p>Browse page — coming soon</p> + +<div class="controls"> + <input + type="text" + placeholder="Filter by tag..." + bind:value={filterTag} + onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }} + /> + <button onclick={applyFilter}>Filter</button> + {#if filterTag} + <button class="clear" onclick={() => { filterTag = ''; applyFilter(); }}>Clear</button> + {/if} +</div> + +{#if loading} + <p>Loading...</p> +{:else if error} + <p class="error">{error}</p> +{:else if images.length === 0} + {#if filterTag} + <p>No results found for tag “{filterTag}”.</p> + {:else} + <p>No images yet. <a href="/upload">Upload one!</a></p> + {/if} +{:else} + <div class="grid"> + {#each images as img (img.id)} + <a href="/image/{img.id}" class="card"> + {#if img.thumbnail_path} + <img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" /> + {:else} + <div class="placeholder">{img.mime_type}</div> + {/if} + <div class="info"> + <span class="name">{img.filename}</span> + <span class="tags">{img.tags.join(', ')}</span> + </div> + </a> + {/each} + </div> + + <div class="pagination"> + <button onclick={prevPage} disabled={page <= 1}>Previous</button> + <span>Page {page} of {Math.ceil(total / perPage)} ({total} total)</span> + <button onclick={nextPage} disabled={page * perPage >= total}>Next</button> + </div> +{/if} + +<style> + .controls { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + .controls input { + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + flex: 1; + max-width: 300px; + } + .controls button { + padding: 0.5rem 1rem; + background: #1a1a2e; + color: #eee; + border: none; + border-radius: 4px; + cursor: pointer; + } + .controls button.clear { + background: #666; + } + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + } + .card { + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s; + } + .card:hover { + box-shadow: 0 2px 12px rgba(0,0,0,0.15); + } + .card img, .placeholder { + width: 100%; + height: 180px; + object-fit: cover; + display: block; + } + .placeholder { + display: flex; + align-items: center; + justify-content: center; + background: #f0f0f0; + color: #999; + font-size: 0.9rem; + } + .info { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + .name { + font-weight: 600; + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tags { + font-size: 0.75rem; + color: #666; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-top: 1.5rem; + } + .pagination button { + padding: 0.5rem 1rem; + background: #1a1a2e; + color: #eee; + border: none; + border-radius: 4px; + cursor: pointer; + } + .pagination button:disabled { + opacity: 0.4; + cursor: default; + } + .error { + color: #c0392b; + } +</style>
@@ -1,2 +1,140 @@
-<h1>Image</h1> -<p>Image detail page — coming soon</p> +<script lang="ts"> + import { onMount } from 'svelte'; + import { getImage, imageDownloadUrl, thumbnailUrl, type Image } from '$lib/api'; + + let { params } = $props(); + let id = $derived(parseInt(params.id)); + + let img = $state<Image | null>(null); + let loading = $state(true); + let error = $state(''); + + onMount(async () => { + try { + img = await getImage(id); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load image'; + } finally { + loading = false; + } + }); + + function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + function formatDate(s: string): string { + return new Date(s).toLocaleString(); + } +</script> + +{#if loading} + <p>Loading...</p> +{:else if error} + <p class="error">{error}</p> +{:else if img} + <h1>{img.filename}</h1> + + <div class="detail"> + <div class="preview"> + {#if img.thumbnail_path} + <a href={imageDownloadUrl(img.id)} download={img.filename}> + <img src={thumbnailUrl(img.id)} alt={img.filename} /> + </a> + {:else} + <div class="placeholder">No thumbnail</div> + {/if} + </div> + + <div class="meta"> + <dl> + <dt>Dimensions</dt> + <dd>{img.width} × {img.height}</dd> + <dt>File size</dt> + <dd>{formatSize(img.file_size)}</dd> + <dt>Type</dt> + <dd>{img.mime_type}</dd> + <dt>Tags</dt> + <dd> + {#if img.tags.length > 0} + {img.tags.join(', ')} + {:else} + <span class="dim">none</span> + {/if} + </dd> + <dt>Uploaded</dt> + <dd>{formatDate(img.created_at)}</dd> + </dl> + + <a href={imageDownloadUrl(img.id)} class="download" download={img.filename}> + Download original + </a> + </div> + </div> + + <p class="back"><a href="/browse">← Back to browse</a></p> +{/if} + +<style> + .detail { + display: flex; + gap: 2rem; + margin-top: 1rem; + flex-wrap: wrap; + } + .preview img { + max-width: 100%; + max-height: 60vh; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + } + .placeholder { + width: 300px; + height: 200px; + display: flex; + align-items: center; + justify-content: center; + background: #f0f0f0; + border-radius: 8px; + color: #999; + } + .meta { + min-width: 220px; + } + dl { + margin: 0; + } + dt { + font-weight: 600; + margin-top: 0.5rem; + font-size: 0.85rem; + color: #555; + } + dd { + margin: 0; + font-size: 0.95rem; + } + .dim { + color: #999; + } + .download { + display: inline-block; + margin-top: 1.5rem; + padding: 0.5rem 1.5rem; + background: #1a1a2e; + color: #eee; + text-decoration: none; + border-radius: 6px; + } + .download:hover { + background: #16213e; + } + .back { + margin-top: 2rem; + } + .error { + color: #c0392b; + } +</style>
@@ -1,2 +1,100 @@
-<h1>Login</h1> -<p>Login page — coming soon</p> +<script lang="ts"> + import { login, register } from '$lib/api'; + + let username = $state(''); + let password = $state(''); + let error = $state(''); + let loading = $state(false); + let isRegister = $state(false); + + async function handleSubmit(e: Event) { + e.preventDefault(); + error = ''; + loading = true; + try { + if (isRegister) { + await register(username, password); + } + await login(username, password); + window.location.href = '/browse'; + } catch (err) { + error = err instanceof Error ? err.message : 'Login failed'; + } finally { + loading = false; + } + } +</script> + +<h1>{isRegister ? 'Register' : 'Login'}</h1> + +<form onsubmit={handleSubmit}> + {#if error} + <p class="error">{error}</p> + {/if} + <label> + Username + <input type="text" bind:value={username} required minlength={3} disabled={loading} /> + </label> + <label> + Password + <input type="password" bind:value={password} required minlength={8} disabled={loading} /> + </label> + <button type="submit" disabled={loading}> + {isRegister ? 'Register' : 'Login'} + </button> +</form> + +<p class="toggle"> + <button class="link" onclick={() => { isRegister = !isRegister; error = ''; }}> + {isRegister ? 'Already have an account? Log in' : 'No account? Register'} + </button> +</p> + +<style> + h1 { + margin-bottom: 1rem; + } + form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 320px; + } + label { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + input { + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + } + button[type="submit"] { + padding: 0.5rem; + background: #1a1a2e; + color: #eee; + border: none; + border-radius: 4px; + cursor: pointer; + } + button[type="submit"]:disabled { + opacity: 0.6; + } + .error { + color: #c0392b; + font-size: 0.9rem; + } + .toggle { + margin-top: 1rem; + } + button.link { + background: none; + border: none; + color: #1a1a2e; + text-decoration: underline; + cursor: pointer; + padding: 0; + font-size: inherit; + } +</style>
@@ -1,2 +1,158 @@
+<script lang="ts"> + import { uploadImage, suggestTags } from '$lib/api'; + + let file = $state<File | null>(null); + let tags = $state(''); + let error = $state(''); + let success = $state(''); + let loading = $state(false); + let suggestions = $state<string[]>([]); + let showSuggestions = $state(false); + + async function onTagInput() { + const q = tags.split(',').pop()?.trim() || ''; + if (q.length < 1) { + suggestions = []; + showSuggestions = false; + return; + } + try { + const result = await suggestTags(q); + suggestions = result.map((t) => t.tag); + showSuggestions = suggestions.length > 0; + } catch { + suggestions = []; + showSuggestions = false; + } + } + + function selectSuggestion(s: string) { + const parts = tags.split(',').slice(0, -1); + parts.push(s); + tags = parts.join(', ') + ', '; + showSuggestions = false; + } + + async function handleSubmit(e: Event) { + e.preventDefault(); + if (!file) return; + error = ''; + success = ''; + loading = true; + try { + const tagList = tags.split(',').map((t) => t.trim()).filter(Boolean); + const img = await uploadImage(file, tagList); + success = `Uploaded "${img.filename}" (ID: ${img.id})`; + file = null; + tags = ''; + } catch (err) { + error = err instanceof Error ? err.message : 'Upload failed'; + } finally { + loading = false; + } + } +</script> + <h1>Upload</h1> -<p>Upload page — coming soon</p> + +<form onsubmit={handleSubmit}> + {#if error} + <p class="error">{error}</p> + {/if} + {#if success} + <p class="success">{success} <a href="/browse">Browse images</a></p> + {/if} + + <label> + Image / Video + <input type="file" accept="image/*,video/mp4,video/webm" required + onchange={(e) => { file = (e.target as HTMLInputElement).files?.[0] ?? null; }} + disabled={loading} + /> + </label> + + <label> + Tags (comma-separated) + <div class="tag-wrapper"> + <input type="text" placeholder="funny, meme, cat" bind:value={tags} + oninput={onTagInput} + onblur={() => setTimeout(() => { showSuggestions = false; }, 200)} + disabled={loading} + /> + {#if showSuggestions} + <ul class="suggestions"> + {#each suggestions as s} + <li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li> + {/each} + </ul> + {/if} + </div> + </label> + + <button type="submit" disabled={loading || !file}> + {loading ? 'Uploading...' : 'Upload'} + </button> +</form> + +<style> + form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 480px; + } + label { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + input[type="file"], input[type="text"] { + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + } + .tag-wrapper { + position: relative; + } + .suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #fff; + border: 1px solid #ccc; + border-top: none; + list-style: none; + margin: 0; + padding: 0; + z-index: 10; + max-height: 150px; + overflow-y: auto; + } + .suggestions li { + padding: 0.4rem 0.5rem; + cursor: pointer; + font-size: 0.9rem; + } + .suggestions li:hover { + background: #f0f0f0; + } + button[type="submit"] { + padding: 0.5rem 1.5rem; + background: #1a1a2e; + color: #eee; + border: none; + border-radius: 4px; + cursor: pointer; + align-self: flex-start; + } + button[type="submit"]:disabled { + opacity: 0.6; + } + .error { + color: #c0392b; + } + .success { + color: #27ae60; + } +</style>