feat: public browsing, shareable image URLs, and tag editing Public browsing is the new default — unauthenticated users can browse the gallery, view images, and download originals. Lock it down by setting `auth.require_auth_for_browse = true` in weimar.toml. Each image now gets a shareable URL when opened in the overlay viewer: `/browse?image=<id>`. Navigate to it directly and the viewer opens automatically (or redirects to the detail page if the image is on a different page). Image owners can now edit tags from the overlay viewer or the detail page. A text field with autocomplete and ADD/REMOVE buttons appears below the tag display on your own uploads. Backend: new public/optional auth middleware, AddImageTags/RemoveImageTags queries, is_owner field on image detail, require_auth_for_browse config option. Frontend: viewer tag editing, shareable URL via pushState, image detail page uses is_owner from backend instead of separate getMe() call. Co-authored-by: Crush
jump to
@@ -67,14 +67,18 @@
web/ # SvelteKit SPA (adapter-static, CSR-only) ├── src/ │ ├── routes/ -│ │ ├── +layout.svelte # Nav bar, auth check, logout +│ │ ├── +layout.svelte # Nav bar, auth check (getMe), logout │ │ ├── +layout.js # ssr=false, prerender=false │ │ ├── +page.svelte # Home page -│ │ ├── browse/+page.svelte # Grid with tag filter + pagination +│ │ ├── browse/+page.svelte # Gallery grid (6-col, tags, pagination, viewer) │ │ ├── login/+page.svelte # Login/Register form │ │ ├── upload/+page.svelte # File upload + tag autocomplete +│ │ ├── settings/+page.svelte # Avatar upload, user info │ │ └── image/[id]/+page.svelte # Image detail + download -│ └── lib/api.ts # Fetch wrapper, typed API functions +│ └── lib/ +│ ├── api.ts # Fetch wrapper, typed API functions +│ ├── Avatar.svelte # Avatar component (image or SVG fallback) +│ └── ImageViewer.svelte # Full-screen overlay viewer ``` ## Architecture@@ -90,7 +94,7 @@
### 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) +- **Auth state**: Checked in layout by `GET /api/users/me` via `getMe()` - **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@@ -139,11 +143,16 @@ | `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/images` | No* | Query: `?tags=...&page=1&per_page=60` *public unless `auth.require_auth_for_browse=true` | +| `GET` | `/api/images/{id}` | No* | Full metadata (incl. tags_detail, uploader info, is_owner) | +| `GET` | `/api/images/{id}/download` | No* | Streams original file | +| `GET` | `/api/images/{id}/thumbnail` | No* | JPEG thumbnail (300px max) | | `GET` | `/api/tags/suggest?q=` | Yes | LIKE prefix match, ordered by usage | +| `POST` | `/api/images/{id}/tags` | Yes | Add tags (owner only) | +| `DELETE` | `/api/images/{id}/tags` | Yes | Remove tags (owner only) | +| `GET` | `/api/users/me` | Yes | Current user (username, avatar_url, is_admin) | +| `POST` | `/api/users/me/avatar` | Yes | Upload avatar (128px JPEG) | +| `GET` | `/api/users/{id}/avatar` | No | Serve avatar image | ## Gotchas & Non-Obvious Patterns@@ -153,7 +162,7 @@ 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. +4. **Auth check in SPA**: The layout calls `GET /api/users/me` via `getMe()` to determine login status and fetch user info. 5. **Thumbnails**: Always JPEG quality 80. Videos depend on `ffmpeg` being on `$PATH` — if missing, `thumbnail_path` stays null and Svelte shows "No thumbnail".@@ -209,6 +218,7 @@ rate_limit_per_minute = 0
[auth] allow_registration = true +require_auth_for_browse = false ``` All fields have defaults (see `root.go:setDefaults()`). Config file is optional.
@@ -0,0 +1,146 @@
+# weimar + +A single-binary media repository for your home server. Share memes, +screenshots, and short videos with your group without uploading to someone +else's cloud. Self-host on any Linux machine: all you need is the binary and a +place to store files. + +weimar is inspired by [Chevereto](https://github.com/chevereto/chevereto) but +stripped down to the essentials: upload media, tag it, browse a gallery, and +share a direct link. + +## What it looks like + +The web interface is a modern single-page app with a full-screen image viewer, +keyboard navigation, and tag-based discovery. Upload from your phone's browser, +browse on your laptop, download originals from anywhere. The design is clean +and bold — Swiss typography with red accents. + +## What you get + +The core loop is dead simple. Register an account (or have an admin make one +for you), upload files through the web form with whatever tags make sense, and +they appear in the gallery. Click any thumbnail to open the full image in an +overlay viewer: arrow keys to browse, `Esc` to close, click outside to dismiss. + +Tags are how you find things later. Type a few words when uploading and the +autocomplete will suggest tags other people have used. Filter the gallery by +clicking tags or adding `?tag=funny` to the URL. Each image gets a permanent +page you can share, a download link, and an auto-generated thumbnail. + +## Where data lives on disk + +Everything lives in two directories you control: one for the SQLite database +(user accounts, sessions, metadata) and one for the image files. Files are +stored in a hash-partitioned folder structure (three levels of two-character +subdirectories) so no single folder ever gets crowded. The config file points +to these locations and you can put them anywhere. + +## How to install + +The simplest path is to download a pre-built binary from the releases page. +There are builds for Linux and macOS, both amd64 and arm64. Put the binary +somewhere in your PATH like `/usr/local/bin/weimar`, make it executable with +`chmod +x`, and you're done. + +If you want to build from source, you'll need Go and Bun. Run `make build` and +the binary lands in `bin/weimar`. + +## How to configure + +Create a file called `weimar.toml` in the same directory you run the server +from. This is the only required step. Everything has sensible defaults. + +```toml +[server] +host = "0.0.0.0" +port = 8080 + +[database] +path = "./data/weimar.db" + +[storage] +path = "./data/images" + +[upload] +max_size_mb = 50 + +[auth] +allow_registration = true +``` + +If you don't want to bother with a config file at all, you can set every option +through environment variables prefixed with `WEIMAR_`. For example, +`WEIMAR_SERVER_PORT=9090 weimar serve` overrides the default port. + +## How to run + +The first time you start the server, create an admin account with +`--admin-email` and `--admin-password`: + +```bash +weimar serve --config weimar.toml --admin-email you@example.com --admin-password s3cur3 +``` + +This flags only work on the very first run when no users exist yet. After that, +admin accounts can be managed through the CLI or by other admins. + +Once the server is running, open `http://your-server-ip:8080` in a browser. + +### Running as a systemd service + +For a proper home-server setup you'll want weimar to start on boot and stay +running. + +``` +[Unit] +Description=weimar media repository +After=network.target + +[Service] +Type=simple +User=weimar +Group=weimar +WorkingDirectory=/home/weimar +ExecStart=/usr/local/bin/weimar serve +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Create a dedicated `weimar` system user, place the config and data directories +under its home folder, enable the service with `systemctl enable weimar`, and +start it with `systemctl start weimar`. + +## Admin commands + +User management happens on the command line, not through the web interface. + +```bash +weimar users list +weimar users create newuser password123 +weimar users create adminuser str0ngp4ss --admin +weimar users delete bob +weimar users password-reset alice +weimar image delete 42 +``` + +Every command accepts `--config` or `WEIMAR_CONFIG` to point at your config +file. + +## What about video? + +weimar handles short videos just fine. It stores them, streams them, and +generates thumbnails. Thumbnails require `ffmpeg` on the server's PATH. If +`ffmpeg` isn't available, videos still play in the browser; they just show a +placeholder instead of a thumbnail in the gallery. + +## Things weimar doesn't do + +This is intentionally a simple tool. There's no full-text search beyond tags, +no albums or collections, no user profiles, no comment threads, no federation +with other servers, no Redis or caching layer, and no Docker image. The +database is a single SQLite file with one writer at a time: fine for a small +group, probably not ideal for hundreds of concurrent users.
@@ -80,4 +80,5 @@ viper.SetDefault("storage.path", "./data/images")
viper.SetDefault("upload.max_size_mb", 50) viper.SetDefault("upload.rate_limit_per_minute", 0) viper.SetDefault("auth.allow_registration", true) + viper.SetDefault("auth.require_auth_for_browse", false) }
@@ -19,11 +19,12 @@ ## 2. Tech Stack
| Layer | Technology | |-------|------------| -| **Backend** | Go (net/http, standard library + chi or similar router) | +| **Backend** | Go 1.22+ (net/http, http.ServeMux with method+pattern routing) | | **Frontend** | SvelteKit (adapter-static, pre-rendered SPA) | | **Database** | SQLite (via modernc.org/sqlite or mattn/go-sqlite3) | | **Session store** | SQLite (same database) | | **Image processing** | Go native (image/jpeg, image/png, image/gif, golang.org/x/image) | +| **Avatar processing** | Go native (golang.org/x/image/draw), resize to 128px JPEG | | **Video thumbnails** | `ffmpeg` binary — optional, skipped if missing | | **EXIF stripping** | Go native (exif library) |@@ -33,46 +34,61 @@ ## 3. Directory Structure
``` weimar/ -├── main.go # Entry point: CLI flag parsing, serve ├── go.mod / go.sum ├── weimar.toml # User-provided config file +├── AGENTS.md # Agent guide (commands, patterns, gotchas) │ ├── cmd/ -│ ├── serve.go # weimar serve (HTTP server) -│ ├── users.go # weimar users (list/create/delete/password-reset) -│ └── image.go # weimar image delete +│ └── weimar/ +│ ├── main.go # Entry point: os.Exit on Execute() error +│ ├── root.go # Persistent flags, viper config init, defaults +│ ├── serve.go # weimar serve (HTTP server + //go:embed web/build/*) +│ ├── users.go # weimar users (list/create/delete/password-reset) +│ ├── image.go # weimar image delete +│ └── version.go # weimar version │ ├── internal/ │ ├── server/ -│ │ ├── server.go # HTTP server, routes, embedded SPA -│ │ └── middleware.go # Auth middleware, CORS, session handling +│ │ ├── server.go # HTTP server, route registration, embedded SPA +│ │ └── middleware.go # requireAuth wrapper, writeJSONError │ ├── auth/ │ │ ├── session.go # Server-side session (cookie-based, SQLite-backed) -│ │ └── register.go # Registration logic +│ │ └── register.go # Registration logic + sentinel errors │ ├── api/ -│ │ ├── upload.go # POST /api/upload +│ │ ├── api.go # API struct (dependency holder), writeJSON/writeError +│ │ ├── auth.go # POST /api/auth/login, /register, /logout +│ │ ├── upload.go # POST /api/upload (file + tags + processing) │ │ ├── images.go # GET /api/images, GET /api/images/{id} │ │ ├── download.go # GET /api/images/{id}/download │ │ ├── thumbnail.go # GET /api/images/{id}/thumbnail -│ │ ├── auth.go # POST /api/auth/login, /register -│ │ └── tags.go # GET /api/tags/suggest +│ │ ├── tags.go # GET /api/tags/suggest +│ │ ├── users.go # GET /api/users/me +│ │ └── avatar.go # POST /api/users/me/avatar, GET /api/users/{id}/avatar │ ├── db/ │ │ ├── sqlite.go # Database connection and migrations │ │ ├── models.go # Structs: User, Image, Session, Tag -│ │ └── queries.go # SQL queries +│ │ └── queries.go # All SQL CRUD operations │ ├── storage/ │ │ ├── disk.go # File read/write/delete, hash-partitioned paths -│ │ └── processing.go # EXIF stripping, thumbnail generation +│ │ └── processing.go # MIME detection, EXIF stripping, thumbnail generation │ └── config/ -│ └── config.go # TOML config parsing +│ └── config.go # FromViper() reads typed Config from viper │ -├── web/ # SvelteKit SPA (built, embedded) +├── web/ # SvelteKit SPA (adapter-static, CSR-only) │ ├── src/ -│ │ ├── routes/ # SvelteKit pages (login, browse, upload, image view) -│ │ ├── lib/ -│ │ │ ├── api.ts # Fetch wrapper for API calls -│ │ │ └── types.ts # TypeScript interfaces -│ │ └── app.html +│ │ ├── routes/ +│ │ │ ├── +layout.svelte # Nav bar, auth check (getMe), logout +│ │ │ ├── +layout.js # ssr=false, prerender=false +│ │ │ ├── +page.svelte # Home page (constructivist design) +│ │ │ ├── browse/+page.svelte # Gallery grid (6-col, tags, pagination, viewer) +│ │ │ ├── login/+page.svelte # Login/Register form +│ │ │ ├── upload/+page.svelte # File upload + tag autocomplete +│ │ │ ├── settings/+page.svelte # Avatar upload, user info +│ │ │ └── image/[id]/+page.svelte # Image detail + download +│ │ └── lib/ +│ │ ├── api.ts # Fetch wrapper, typed API functions +│ │ ├── Avatar.svelte # Avatar component (image or SVG fallback) +│ │ └── ImageViewer.svelte # Full-screen overlay viewer (keyboard nav, idle fade) │ ├── svelte.config.js # adapter-static │ └── package.json │@@ -99,6 +115,7 @@ | id | INTEGER PRIMARY KEY | |
| username | TEXT UNIQUE NOT NULL | | | password_hash | TEXT NOT NULL | bcrypt | | is_admin | INTEGER DEFAULT 0 | | +| avatar_path | TEXT | Relative path in storage dir, nullable | | created_at | TEXT (ISO 8601) | | | updated_at | TEXT (ISO 8601) | |@@ -146,12 +163,20 @@ | Method | Path | Auth | Description |
|--------|------|------|-------------| | POST | `/api/auth/login` | No | Login, returns session cookie | | POST | `/api/auth/register` | No* | Create account (*if registration open) | +| POST | `/api/auth/logout` | Yes | Clear session | | POST | `/api/upload` | Yes | Upload image/video + tags | -| GET | `/api/images` | Yes | Paginated listing, ?tags filter, newest-first | -| GET | `/api/images/{id}` | Yes | Single image metadata | -| GET | `/api/images/{id}/download` | Yes | Serve original file | -| GET | `/api/images/{id}/thumbnail` | Yes | Serve thumbnail | -| GET | `/api/tags/suggest?q=` | Yes | Tag autocomplete (LIKE query) | +| GET | `/api/images` | No* | Paginated listing, ?tags filter, newest-first *public unless require_auth_for_browse=true | +| GET | `/api/images/{id}` | No* | Single image metadata (incl. tags_detail, uploader) | +| GET | `/api/images/{id}/download` | No* | Serve original file | +| GET | `/api/images/{id}/thumbnail` | No* | Serve thumbnail (JPEG, 300px max) | +| GET | `/api/tags/suggest?q=` | Yes | Tag autocomplete (LIKE query by usage) | +| POST | `/api/images/{id}/tags` | Yes | Add tags (owner only) | +| DELETE | `/api/images/{id}/tags` | Yes | Remove tags (owner only) | +| GET | `/api/users/me` | Yes | Current user info (username, avatar_url, is_admin) | +| POST | `/api/users/me/avatar` | Yes | Upload avatar (resized to 128px JPEG) | +| GET | `/api/users/{id}/avatar` | No | Serve avatar (Cache-Control: public, 86400s) | + +Browse endpoints (GET `/api/images*`) use `optionalAuth` middleware: if `auth.require_auth_for_browse` is false (default), unauthenticated requests pass through without a user context instead of returning 401. Authenticated users still get `is_owner` and other user-specific fields. All authenticated endpoints use the session cookie (not Bearer tokens).@@ -164,7 +189,7 @@ Browser → SvelteKit SPA
↓ fetch() Go HTTP server ↓ middleware - Auth check (session cookie validation) + Auth check (session cookie validation — requireAuth or optionalAuth) ↓ Route handler ├─ db queries (SQLite)@@ -173,6 +198,8 @@ └─ image processing (EXIF strip, thumbnail)
↓ JSON response → SPA renders ``` + +Browse endpoints use `optionalAuth` middleware: tries to authenticate from the session cookie but passes through without a user context on failure (unless `auth.require_auth_for_browse` is true, in which case it rejects like `requireAuth`). Mutating endpoints (upload, tag edit, logout, settings) always use `requireAuth` which rejects unauthenticated requests with 401. ---@@ -233,6 +260,7 @@ rate_limit_per_minute = 0 # 0 = unlimited
[auth] allow_registration = true # false = gated, admin creates users only +require_auth_for_browse = false # true = unauth users blocked from browsing ``` ---
@@ -1,6 +1,7 @@
package api import ( + "encoding/json" "net/http" "strconv"@@ -81,6 +82,9 @@ avatarURL = "/api/users/" + strconv.FormatInt(img.UploadedBy, 10) + "/avatar"
} } + authUser := UserFromContext(r.Context()) + isOwner := authUser != nil && authUser.ID == img.UploadedBy + writeJSON(w, http.StatusOK, map[string]any{ "id": img.ID, "filename": img.Filename,@@ -89,6 +93,7 @@ "thumbnail_path": img.ThumbnailPath,
"uploaded_by": img.UploadedBy, "uploader_username": uploaderUsername, "avatar_url": avatarURL, + "is_owner": isOwner, "file_size": img.FileSize, "width": img.Width, "height": img.Height,@@ -99,3 +104,103 @@ "tags": img.Tags,
"tags_detail": tagsDetail, }) } + +type tagsRequest struct { + Tags []string `json:"tags"` +} + +func (a *API) HandleAddTags(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + 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.UploadedBy != user.ID { + writeError(w, http.StatusForbidden, "you can only edit tags on your own uploads") + return + } + + var req tagsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + if len(req.Tags) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"tags_detail": img.Tags}) + return + } + + if err := a.DB.AddImageTags(r.Context(), id, req.Tags); err != nil { + writeError(w, http.StatusInternalServerError, "failed to add tags") + return + } + + tagsDetail, err := a.DB.GetImageTagsWithCounts(r.Context(), id) + if err != nil { + tagsDetail = make([]db.Tag, 0) + } + + writeJSON(w, http.StatusOK, map[string]any{"tags_detail": tagsDetail}) +} + +func (a *API) HandleRemoveTags(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + 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.UploadedBy != user.ID { + writeError(w, http.StatusForbidden, "you can only edit tags on your own uploads") + return + } + + var req tagsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + if len(req.Tags) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"tags_detail": img.Tags}) + return + } + + if err := a.DB.RemoveImageTags(r.Context(), id, req.Tags); err != nil { + writeError(w, http.StatusInternalServerError, "failed to remove tags") + return + } + + tagsDetail, err := a.DB.GetImageTagsWithCounts(r.Context(), id) + if err != nil { + tagsDetail = make([]db.Tag, 0) + } + + writeJSON(w, http.StatusOK, map[string]any{"tags_detail": tagsDetail}) +}
@@ -29,7 +29,8 @@ RateLimitPerMinute int `mapstructure:"rate_limit_per_minute"`
} type AuthConfig struct { - AllowRegistration bool `mapstructure:"allow_registration"` + AllowRegistration bool `mapstructure:"allow_registration"` + RequireAuthForBrowse bool `mapstructure:"require_auth_for_browse"` } func FromViper() Config {@@ -49,7 +50,8 @@ MaxSizeMB: viper.GetInt("upload.max_size_mb"),
RateLimitPerMinute: viper.GetInt("upload.rate_limit_per_minute"), }, Auth: AuthConfig{ - AllowRegistration: viper.GetBool("auth.allow_registration"), + AllowRegistration: viper.GetBool("auth.allow_registration"), + RequireAuthForBrowse: viper.GetBool("auth.require_auth_for_browse"), }, } }
@@ -474,6 +474,57 @@ _, err = d.db.ExecContext(ctx, `DELETE FROM tags WHERE tag = ? AND usage_count <= 0`, tag)
return err } +// AddImageTags adds tags to an image, skipping existing ones, and increments usage counts. +func (d *DB) AddImageTags(ctx context.Context, imageID int64, tags []string) error { + for _, t := range tags { + tag := normalizeTag(t) + if tag == "" { + continue + } + // Check if tag already exists for this image. + var count int + if err := d.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM image_tags WHERE image_id = ? AND tag = ?`, + imageID, tag).Scan(&count); err != nil { + return fmt.Errorf("check existing tag: %w", err) + } + if count > 0 { + 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 +} + +// RemoveImageTags removes specific tags from an image and decrements usage counts. +func (d *DB) RemoveImageTags(ctx context.Context, imageID int64, tags []string) error { + for _, t := range tags { + tag := normalizeTag(t) + if tag == "" { + continue + } + res, err := d.db.ExecContext(ctx, + `DELETE FROM image_tags WHERE image_id = ? AND tag = ?`, imageID, tag) + if err != nil { + return fmt.Errorf("remove image tag: %w", err) + } + n, _ := res.RowsAffected() + if n > 0 { + if err := d.decrementTagUsage(ctx, tag); err != nil { + return err + } + } + } + return nil +} + // normalizeTag trims whitespace, lowercases, and collapses internal whitespace. func normalizeTag(tag string) string { return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(tag))), " ")
@@ -32,6 +32,36 @@ next(w, r.WithContext(ctx))
} } +// requireAuthOrPublic wraps a handler with optional auth. +// If the user has a valid session, it sets the user in context. +// If not, the request passes through without a user context. +// This is controlled by the require_auth_for_browse config setting. +func (s *Server) requireAuthOrPublic(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.cfg.Auth.RequireAuthForBrowse { + // Act like requireAuth — reject unauthenticated. + s.requireAuth(next).ServeHTTP(w, r) + return + } + + // Try to authenticate, but don't reject if it fails. + cookie, err := r.Cookie(auth.SessionCookieName) + if err != nil { + next(w, r) + return + } + + user, err := s.auth.Authenticate(r.Context(), cookie.Value) + if err != nil { + next(w, r) + 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")
@@ -39,12 +39,14 @@
// Auth (authenticated). mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout)) - // Images (authenticated). + // Images (conditionally public). 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)) + mux.HandleFunc("GET /api/images", s.requireAuthOrPublic(h.HandleListImages)) + mux.HandleFunc("GET /api/images/{id}", s.requireAuthOrPublic(h.HandleGetImage)) + mux.HandleFunc("GET /api/images/{id}/download", s.requireAuthOrPublic(h.HandleDownload)) + mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail)) + mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags)) + mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags)) // Tags (authenticated). mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest))
@@ -1,5 +1,5 @@
<script lang="ts"> - import { imageDownloadUrl, getImage, type Image, type TagDetail } from '$lib/api'; + import { imageDownloadUrl, getImage, addTags, removeTags, suggestTags, type Image, type TagDetail } from '$lib/api'; import Avatar from '$lib/Avatar.svelte'; let {@@ -16,16 +16,25 @@ let currentIndex = $state(initialIndex);
let loading = $state(true); let showControls = $state(true); let tagDetails = $state<TagDetail[]>([]); + let isOwner = $state(false); let idleTimer: ReturnType<typeof setTimeout> | undefined = $state(); + let tagInput = $state(''); + let suggestions = $state<string[]>([]); + let showSuggestions = $state(false); + let tagError = $state(''); + let tagBusy = $state(false); + let current = $derived(images[currentIndex]); async function loadTagDetails() { try { const detail = await getImage(current.id); tagDetails = detail.tags_detail; + isOwner = detail.is_owner; } catch { tagDetails = []; + isOwner = false; } }@@ -89,6 +98,62 @@ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } + async function onTagInput() { + const q = tagInput.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 = tagInput.split(',').slice(0, -1); + parts.push(s); + tagInput = parts.join(', ') + ', '; + showSuggestions = false; + } + + async function handleAddTags() { + const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean); + if (tags.length === 0) return; + tagError = ''; + tagBusy = true; + try { + const result = await addTags(current.id, tags); + tagDetails = result.tags_detail; + tagInput = ''; + } catch (err) { + tagError = err instanceof Error ? err.message : 'Failed to add tags'; + } finally { + tagBusy = false; + } + } + + async function handleRemoveTags() { + const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean); + if (tags.length === 0) return; + tagError = ''; + tagBusy = true; + try { + const result = await removeTags(current.id, tags); + tagDetails = result.tags_detail; + tagInput = ''; + } catch (err) { + tagError = err instanceof Error ? err.message : 'Failed to remove tags'; + } finally { + tagBusy = false; + } + } + $effect(() => { current.id; loadTagDetails();@@ -184,6 +249,35 @@ <span class="tag-name">{t.tag}</span>
<span class="tag-count">{t.usage_count}</span> </button> {/each} + </div> + {/if} + + {#if isOwner} + <div class="viewer-edit-tags" class:controls-hidden={!showControls}> + {#if tagError} + <span class="tag-err">{tagError}</span> + {/if} + <div class="tag-edit-row"> + <div class="tag-edit-wrapper"> + <input + type="text" + placeholder="funny, meme, cat" + bind:value={tagInput} + oninput={onTagInput} + onblur={() => setTimeout(() => { showSuggestions = false; }, 200)} + disabled={tagBusy} + /> + {#if showSuggestions} + <ul class="viewer-suggestions"> + {#each suggestions as s} + <li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li> + {/each} + </ul> + {/if} + </div> + <button class="viewer-tag-btn add" onclick={handleAddTags} disabled={tagBusy}>ADD</button> + <button class="viewer-tag-btn remove" onclick={handleRemoveTags} disabled={tagBusy}>REMOVE</button> + </div> </div> {/if}@@ -438,5 +532,109 @@ text-align: center;
} .tag-pill:hover .tag-count { background: rgba(0, 0, 0, 0.25); + } + + .viewer-edit-tags { + position: absolute; + top: 9rem; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + max-width: 420px; + width: 90vw; + transition: opacity 0.3s; + z-index: 5; + } + .tag-err { + color: #ef5350; + font-family: 'Inter', sans-serif; + font-size: 0.7rem; + font-weight: 600; + } + .tag-edit-row { + display: flex; + gap: 0.3rem; + width: 100%; + } + .tag-edit-wrapper { + flex: 1; + position: relative; + min-width: 0; + } + .tag-edit-wrapper input[type="text"] { + width: 100%; + padding: 0.35rem 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 0; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-family: 'Inter', sans-serif; + font-size: 0.75rem; + font-weight: 500; + box-sizing: border-box; + } + .tag-edit-wrapper input[type="text"]:focus { + outline: none; + border-color: #c62828; + } + .tag-edit-wrapper input[type="text"]::placeholder { + color: rgba(255, 255, 255, 0.3); + } + .viewer-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #1a1a1a; + border: 1px solid rgba(255, 255, 255, 0.2); + border-top: none; + list-style: none; + margin: 0; + padding: 0; + z-index: 15; + max-height: 120px; + overflow-y: auto; + } + .viewer-suggestions li { + padding: 0.3rem 0.5rem; + cursor: pointer; + font-family: 'Inter', sans-serif; + font-size: 0.75rem; + font-weight: 500; + color: #fff; + } + .viewer-suggestions li:hover { + background: #c62828; + } + .viewer-tag-btn { + padding: 0.35rem 0.6rem; + border: none; + font-family: 'Oswald', sans-serif; + font-weight: 500; + font-size: 0.68rem; + letter-spacing: 0.08em; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + color: #fff; + } + .viewer-tag-btn.add { + background: rgba(255, 255, 255, 0.15); + } + .viewer-tag-btn.add:hover { + background: #2e7d32; + } + .viewer-tag-btn.remove { + background: rgba(255, 255, 255, 0.15); + } + .viewer-tag-btn.remove:hover { + background: #c62828; + } + .viewer-tag-btn:disabled { + opacity: 0.4; + cursor: default; } </style>
@@ -42,6 +42,7 @@ }
export interface ImageDetail extends Image { tags_detail: TagDetail[]; + is_owner: boolean; } export interface CurrentUser {@@ -132,3 +133,17 @@
export async function getMe(): Promise<CurrentUser> { return request<CurrentUser>('/users/me'); } + +export async function addTags(imageId: number, tags: string[]): Promise<{ tags_detail: TagDetail[] }> { + return request<{ tags_detail: TagDetail[] }>(`/images/${imageId}/tags`, { + method: 'POST', + body: JSON.stringify({ tags }), + }); +} + +export async function removeTags(imageId: number, tags: string[]): Promise<{ tags_detail: TagDetail[] }> { + return request<{ tags_detail: TagDetail[] }>(`/images/${imageId}/tags`, { + method: 'DELETE', + body: JSON.stringify({ tags }), + }); +}
@@ -2,6 +2,7 @@ <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { listImages, thumbnailUrl, type Image } from '$lib/api'; import ImageViewer from '$lib/ImageViewer.svelte'; + import { goto } from '$app/navigation'; let images = $state<Image[]>([]); let total = $state(0);@@ -20,7 +21,17 @@ mainEl.style.maxWidth = 'none';
} const params = new URLSearchParams(window.location.search); filterTag = params.get('tag') || ''; - loadImages(); + loadImages().then(() => { + const imageParam = params.get('image'); + if (imageParam) { + const idx = images.findIndex((img) => String(img.id) === imageParam); + if (idx !== -1) { + viewerIndex = idx; + } else { + goto('/image/' + imageParam); + } + } + }); }); onDestroy(() => {@@ -62,6 +73,12 @@ }
function openViewer(index: number) { viewerIndex = index; + history.replaceState(null, '', '/browse?image=' + images[index].id); + } + + function closeViewer() { + viewerIndex = -1; + history.replaceState(null, '', filterTag ? '/browse?tag=' + encodeURIComponent(filterTag) : '/browse'); } </script>@@ -133,7 +150,7 @@ {#if viewerIndex >= 0}
<ImageViewer images={images} initialIndex={viewerIndex} - onclose={() => { viewerIndex = -1; }} + onclose={closeViewer} /> {/if}
@@ -1,6 +1,6 @@
<script lang="ts"> import { onMount } from 'svelte'; - import { getImage, imageDownloadUrl, thumbnailUrl, type ImageDetail } from '$lib/api'; + import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api'; let { params } = $props(); let id = $derived(parseInt(params.id));@@ -8,6 +8,11 @@
let img = $state<ImageDetail | null>(null); let loading = $state(true); let error = $state(''); + + let tagInput = $state(''); + let suggestions = $state<string[]>([]); + let showSuggestions = $state(false); + let tagError = $state(''); onMount(async () => { try {@@ -28,6 +33,60 @@
function formatDate(s: string): string { return new Date(s).toLocaleString(); } + + async function onTagInput() { + const q = tagInput.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 = tagInput.split(',').slice(0, -1); + parts.push(s); + tagInput = parts.join(', ') + ', '; + showSuggestions = false; + } + + async function handleAddTags() { + if (!img) return; + const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean); + if (tags.length === 0) return; + tagError = ''; + try { + const result = await addTags(img.id, tags); + img.tags_detail = result.tags_detail; + img.tags = result.tags_detail.map((t) => t.tag); + tagInput = ''; + } catch (err) { + tagError = err instanceof Error ? err.message : 'Failed to add tags'; + } + } + + async function handleRemoveTags() { + if (!img) return; + const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean); + if (tags.length === 0) return; + tagError = ''; + try { + const result = await removeTags(img.id, tags); + img.tags_detail = result.tags_detail; + img.tags = result.tags_detail.map((t) => t.tag); + tagInput = ''; + } catch (err) { + tagError = err instanceof Error ? err.message : 'Failed to remove tags'; + } + } </script> {#if loading}@@ -94,6 +153,32 @@ <span class="none">none</span>
{/if} </div> </div> + + {#if img.is_owner} + <div class="meta-block edit-tags-block"> + <span class="meta-label">EDIT TAGS</span> + <div class="tag-input-row"> + <div class="tag-wrapper"> + <input type="text" placeholder="funny, meme, cat" bind:value={tagInput} + oninput={onTagInput} + onblur={() => setTimeout(() => { showSuggestions = false; }, 200)} + /> + {#if showSuggestions} + <ul class="suggestions"> + {#each suggestions as s} + <li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li> + {/each} + </ul> + {/if} + </div> + <button class="tag-btn add" onclick={handleAddTags}>ADD</button> + <button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button> + </div> + {#if tagError} + <span class="tag-err">{tagError}</span> + {/if} + </div> + {/if} <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}> DOWNLOAD ORIGINAL@@ -270,5 +355,93 @@ font-family: 'Oswald', sans-serif;
font-weight: 500; font-size: 0.85rem; letter-spacing: 0.1em; + } + + .edit-tags-block { + border-top: 2px solid #1a1a1a; + padding-top: 0.75rem; + } + .tag-input-row { + display: flex; + gap: 0.35rem; + align-items: flex-start; + } + .tag-wrapper { + flex: 1; + position: relative; + min-width: 0; + } + .tag-wrapper input[type="text"] { + width: 100%; + padding: 0.4rem 0.5rem; + border: 2px solid #1a1a1a; + border-radius: 0; + font-family: 'Inter', sans-serif; + font-size: 0.78rem; + font-weight: 500; + background: #fff; + box-sizing: border-box; + } + .tag-wrapper input[type="text"]:focus { + outline: none; + border-color: #c62828; + } + .suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #fff; + border: 2px solid #1a1a1a; + border-top: none; + list-style: none; + margin: 0; + padding: 0; + z-index: 10; + max-height: 130px; + overflow-y: auto; + } + .suggestions li { + padding: 0.35rem 0.5rem; + cursor: pointer; + font-family: 'Inter', sans-serif; + font-size: 0.78rem; + font-weight: 500; + } + .suggestions li:hover { + background: #f0f0f0; + } + .tag-btn { + padding: 0.4rem 0.65rem; + border: none; + font-family: 'Oswald', sans-serif; + font-weight: 500; + font-size: 0.72rem; + letter-spacing: 0.08em; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + } + .tag-btn.add { + background: #1a1a1a; + color: #fff; + } + .tag-btn.add:hover { + background: #2e7d32; + } + .tag-btn.remove { + background: #1a1a1a; + color: #fff; + } + .tag-btn.remove:hover { + background: #c62828; + } + .tag-err { + display: block; + margin-top: 0.3rem; + color: #c62828; + font-family: 'Inter', sans-serif; + font-size: 0.72rem; + font-weight: 600; } </style>