ソースを参照

added a buncha stuff like being able to unlist images, as well as serve out videos and stuff

aeth 6 時間 前
親
コミット
73c2f9d7ee
45 ファイル変更、3143 行追加、237 行削除
  1. 1 1
      .gitignore
  2. 11 4
      Makefile
  3. 106 0
      README.md
  4. 52 8
      cmd/keiji-ctl/keiji-ctl.go
  5. 129 0
      cmd/keiji-ctl/keiji-ctl_test.go
  6. 14 0
      cmd/keiji-migrate/README.md
  7. 51 0
      cmd/keiji-migrate/main.go
  8. 36 0
      cmd/keiji-migrate/main_test.go
  9. 15 6
      cmd/keiji/keiji.go
  10. 12 0
      deploy/keiji.env.example
  11. 21 0
      deploy/keiji.service
  12. 56 0
      deploy/setup.sh
  13. 36 0
      pkg/controller/SECURITY.md
  14. 215 48
      pkg/controller/admin_handlers.go
  15. 140 0
      pkg/controller/admin_list_errors_test.go
  16. 66 0
      pkg/controller/backup_integration_test.go
  17. 51 17
      pkg/controller/cdn_handlers.go
  18. 97 0
      pkg/controller/cdn_stream_test.go
  19. 77 10
      pkg/controller/html_handlers.go
  20. 131 0
      pkg/controller/html_list_errors_test.go
  21. 256 0
      pkg/controller/media_test.go
  22. 77 0
      pkg/controller/media_type.go
  23. 125 6
      pkg/controller/middleware.go
  24. 171 0
      pkg/controller/security_test.go
  25. 11 0
      pkg/env/env.go
  26. 12 0
      pkg/env/env_test.go
  27. 6 2
      pkg/routes/register.go
  28. 34 0
      pkg/routes/register_test.go
  29. 115 0
      pkg/storage/digital_art_test.go
  30. 75 0
      pkg/storage/list_errors_test.go
  31. 75 0
      pkg/storage/media_stream_test.go
  32. 148 0
      pkg/storage/migration.go
  33. 77 0
      pkg/storage/migration_media.go
  34. 119 0
      pkg/storage/migration_test.go
  35. 2 1
      pkg/storage/queries.go
  36. 301 114
      pkg/storage/storage.go
  37. 73 1
      pkg/storage/storage_test.go
  38. 13 1
      pkg/webpages/cdn/custom.css
  39. 9 3
      pkg/webpages/cdn/image-insert.js
  40. 40 0
      pkg/webpages/digital_art_admin_test.go
  41. 13 9
      pkg/webpages/html/blogpost_editor.html
  42. 9 5
      pkg/webpages/html/digital_art.html
  43. 53 0
      pkg/webpages/html/digital_art_admin.html
  44. 1 1
      pkg/webpages/html/upload.html
  45. 11 0
      pkg/webpages/pages_test.go

+ 1 - 1
.gitignore

@@ -9,7 +9,7 @@ build/linux/keiji-ctl/*
 html/assets/images/*
 
 
-sqlite.db
+*.db
 
 # ignoring nohup.out file
 nohup.out

+ 11 - 4
Makefile

@@ -1,4 +1,4 @@
-.PHONY: build format test coverage dev-run install
+.PHONY: build format test coverage dev-run install root-install
 
 
 WEBSERVER = keiji
@@ -14,10 +14,17 @@ root-install:
 	cp ./build/linux/$(SEED_CMD)/$(SEED_CMD) /bin \
 	&& cp ./build/linux/$(WEBSERVER)/$(WEBSERVER) /bin
 
+# Build as an unprivileged user first; invoke this target as root (sudo make install).
+# Existing binaries and the vendor unit are replaced; configuration and data are not.
 install:
-	sudo mkdir -p /usr/local/bin \
-	&& sudo cp ./build/linux/$(SEED_CMD)/$(SEED_CMD) /usr/local/bin/ \
-	&& sudo cp ./build/linux/$(WEBSERVER)/$(WEBSERVER) /usr/local/bin/
+	@test "$$(id -u)" -eq 0 || { echo "Run sudo make install after make build" >&2; exit 1; }
+	@test -f ./build/linux/$(WEBSERVER)/$(WEBSERVER) -a -f ./build/linux/$(SEED_CMD)/$(SEED_CMD) || { echo "Run make build first" >&2; exit 1; }
+	./deploy/setup.sh
+	install -d -o root -g root -m 0755 /usr/local/bin /etc/systemd/system
+	install -o root -g root -m 0755 ./build/linux/$(WEBSERVER)/$(WEBSERVER) /usr/local/bin/$(WEBSERVER)
+	install -o root -g root -m 0755 ./build/linux/$(SEED_CMD)/$(SEED_CMD) /usr/local/bin/$(SEED_CMD)
+	install -o root -g root -m 0644 ./deploy/keiji.service /etc/systemd/system/keiji.service
+	@echo "Installed. Review /etc/keiji/keiji.env, then run systemctl daemon-reload and start keiji explicitly."
 
 
 format:

+ 106 - 0
README.md

@@ -9,3 +9,109 @@ This is my personal blogging site, in lieu of having social media like Facebook,
 - cool mid 2000s website feel
 
 
+### Deployment and persistent storage
+
+Set the required environment variables `HOST_ADDR`, `HOST_PORT`, `DOMAIN_NAME`,
+`USE_SSL`, `KEIJI_USERNAME`, and `KEIJI_PASSWORD`. `IMAGE_STORE` must also be
+set to a writable directory for uploaded images. The `-env` flag selects an
+environment file (default `.env`); existing process environment variables take
+precedence. Run `keiji -blank` to generate an environment template, then start
+with `keiji -content embed` (or `-content fs` with `WEB_ROOT` configured).
+Protect the environment file and credentials from unauthorized access.
+
+`SQLITE_PATH` optionally selects the SQLite database file; when unset or empty,
+it defaults to `sqlite.db` in the process working directory, as in older
+versions. Set it to an absolute path such as `/data/sqlite.db` for deployment.
+The parent directory must already exist and be writable by the service; SQLite
+may create journal, WAL, and shared-memory files alongside the database. Moving
+an existing installation requires moving the existing database to the configured
+path, not starting with an empty database. Protect database and image files
+with appropriate filesystem permissions.
+
+For containers, mount persistent storage for **both** the database directory
+and `IMAGE_STORE`, for example mount a volume at `/data`, set
+`SQLITE_PATH=/data/sqlite.db` and `IMAGE_STORE=/data/images`, and ensure the
+container user can write there. Do not rely on the container writable layer or
+working directory surviving replacement. Keep a single application instance
+writing to these files; SQLite locking and image storage require a filesystem
+with suitable local locking semantics.
+
+### Backup and restore
+
+Back up both the SQLite database and the entire image store: image metadata and
+image files are separate, and either alone is incomplete. The simplest consistent
+procedure is to stop all Keiji writers, copy the database and image directory
+as one backup, then restart Keiji. If using an online SQLite backup mechanism,
+coordinate it with image writes or quiesce the application to obtain a matching
+snapshot; copying a live database file alone is not a reliable SQLite backup,
+especially in WAL mode. Keep backups off-host, secure them, and periodically
+test restoration. To restore, stop Keiji, restore the matching database and
+image directory to the configured paths, ensure ownership and permissions, then
+restart and verify content. Preserve or correctly checkpoint any SQLite
+sidecar files before moving a database.
+
+### HTTPS
+
+For direct TLS set `USE_SSL=true`, `CHAIN` to the certificate chain, and `KEY`
+to its private key. Listener errors, including invalid certificates, abort startup
+without falling back to HTTP. Alternatively terminate HTTPS at a trusted reverse
+proxy and set `USE_SSL=false` for a protected backend connection. See
+[pkg/controller/SECURITY.md](pkg/controller/SECURITY.md) for cookie, proxy,
+CSRF and rate-limiting deployment requirements; do not use insecure cookies in
+production.
+
+### Migrating older databases
+
+For legacy image tables or missing media types, use the offline, repeatable
+[`keiji-migrate` command](cmd/keiji-migrate/README.md). Stop all writers and
+back up both the database and media directory first. Choose the legacy category
+based on the old installation's actual content; the tool does not guess it.
+
+### Service account setup
+
+`sudo ./deploy/setup.sh` creates the `keiji` system group and a system user with
+no home directory created and `/sbin/nologin` as its shell. It creates
+`/etc/keiji` as `root:keiji` mode `0750` and `/var/lib/keiji` plus its `images`
+subdirectory as `keiji:root` mode `0700`. Root manages configuration; the service
+can read it but cannot change it. Install the actual environment file separately:
+
+```sh
+sudo install -o root -g keiji -m 0640 /path/to/keiji.env /etc/keiji/keiji.env
+```
+
+Only put the service account and trusted administrators in the `keiji` group.
+The script does not recursively change existing files, ACLs, or mounted
+filesystems: inspect and fix any pre-existing contents and ACLs before use.
+It does not install binaries, create a database, or enable/start a service. An
+existing `keiji` account with a different primary group or shell must be reviewed
+manually; the script will not silently repurpose it.
+
+### Install with systemd
+
+Build and test as an ordinary user, then install as root:
+
+```sh
+make build
+make test
+sudo make install
+```
+
+The install target runs `deploy/setup.sh`, installs `keiji` and `keiji-ctl` in
+`/usr/local/bin`, and replaces `/etc/systemd/system/keiji.service` with the
+repo's unit. It does not restart or enable the service, and does not create or
+replace the env file, database, or media. Put local unit customizations in a
+systemd drop-in (`systemctl edit keiji`) so reinstalls won't overwrite them.
+
+For a first installation, edit a private copy of `deploy/keiji.env.example` and
+install it as `/etc/keiji/keiji.env` with the permissions above. Replace the
+example credentials. The example binds loopback for TLS termination by Caddy;
+see the security configuration for reverse-proxy requirements. The service's
+`ProtectSystem=strict` allows writes only under `/var/lib/keiji`, so other data
+locations need an appropriate systemd drop-in. The unit does not load the file
+via systemd `EnvironmentFile`; Keiji reads it directly via `-env`.
+
+After installing or changing the unit, run `sudo systemctl daemon-reload`.
+When configuration and data are ready, start explicitly with
+`sudo systemctl enable --now keiji` (or `sudo systemctl restart keiji` for an
+existing service). Review status with `systemctl status keiji` and logs with
+`journalctl -u keiji`. Back up existing data before upgrading or migrating.

+ 52 - 8
cmd/keiji-ctl/keiji-ctl.go

@@ -13,6 +13,7 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"strings"
 
 	"git.aetherial.dev/aeth/keiji/pkg/auth"
 	"git.aetherial.dev/aeth/keiji/pkg/controller"
@@ -20,6 +21,34 @@ import (
 	_ "github.com/mattn/go-sqlite3"
 )
 
+// newRequest sends an Origin matching the target for unsafe requests. Never use
+// a caller-provided Origin or include URL credentials, paths, queries or fragments.
+func newRequest(method, target string, body io.Reader) (*http.Request, error) {
+	req, err := http.NewRequest(method, target, body)
+	if err != nil {
+		return nil, err
+	}
+	u := req.URL
+	if u.Opaque != "" || u.User != nil || u.Hostname() == "" ||
+		(!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) {
+		return nil, fmt.Errorf("invalid HTTP target URL: %q", target)
+	}
+	switch method {
+	case http.MethodGet, http.MethodHead, http.MethodOptions:
+	default:
+		req.Header.Set("Origin", strings.ToLower(u.Scheme)+"://"+u.Host)
+	}
+	return req, nil
+}
+
+// Treat redirects as errors at the call site. In particular, do not replay
+// credentials, cookies or mutation bodies at a redirect destination.
+func newHTTPClient() *http.Client {
+	return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+		return http.ErrUseLastResponse
+	}}
+}
+
 func newfileUploadRequest(uri string, params map[string]string, paramName, path string) (*http.Request, error) {
 	file, err := os.Open(path)
 	if err != nil {
@@ -46,16 +75,22 @@ func newfileUploadRequest(uri string, params map[string]string, paramName, path
 		return nil, err
 	}
 
-	req, err := http.NewRequest("POST", uri, body)
+	req, err := newRequest(http.MethodPost, uri, body)
+	if err != nil {
+		return nil, err
+	}
 	req.Header.Set("Content-Type", writer.FormDataContentType())
 	return req, err
 }
 
 // authenticate and get the cookie needed to make updates
 func authenticate(url, username, password string) *http.Cookie {
-	client := http.Client{}
+	client := newHTTPClient()
 	b, _ := json.Marshal(auth.Credentials{Username: username, Password: password})
-	req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
+	req, err := newRequest(http.MethodPost, url, bytes.NewReader(b))
+	if err != nil {
+		log.Fatal("auth failed: ", err)
+	}
 	req.Header.Add("Content-Type", "application/json")
 	resp, err := client.Do(req)
 	if err != nil {
@@ -80,10 +115,10 @@ func authenticate(url, username, password string) *http.Cookie {
 func prepareCookie(address string) *http.Cookie {
 
 	parsedAddr, err := url.Parse(address)
-	dn := parsedAddr.Hostname()
 	if err != nil {
 		log.Fatal("unparseable address: ", address, " error: ", err)
 	}
+	dn := parsedAddr.Hostname()
 	var preparedCookie *http.Cookie
 	if cookie == "" {
 		log.Fatal("Cookie cannot be empty.")
@@ -112,7 +147,7 @@ func main() {
 	flag.StringVar(&cookie, "cookie", "", "pass a cookie to bypass direct authentication")
 	flag.Parse()
 
-	client := http.Client{}
+	client := newHTTPClient()
 
 	switch cmd {
 	case "auth":
@@ -130,7 +165,10 @@ func main() {
 			Data: b,
 		}
 		data, _ := json.Marshal(item)
-		req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/admin/asset", address), bytes.NewReader(data))
+		req, err := newRequest(http.MethodPost, fmt.Sprintf("%s/admin/asset", address), bytes.NewReader(data))
+		if err != nil {
+			log.Fatal(err)
+		}
 		req.AddCookie(prepareCookie(address))
 		req.Header.Add("Content-Type", "application/json")
 		resp, err := client.Do(req)
@@ -174,7 +212,10 @@ func main() {
 		os.Exit(0)
 	case "menu":
 		b, _ := json.Marshal(storage.LinkPair{Text: text, Link: redirect})
-		req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/admin/menu", address), bytes.NewReader(b))
+		req, err := newRequest(http.MethodPost, fmt.Sprintf("%s/admin/menu", address), bytes.NewReader(b))
+		if err != nil {
+			log.Fatal(err)
+		}
 		req.AddCookie(prepareCookie(address))
 		req.Header.Add("Content-Type", "application/json")
 		resp, err := client.Do(req)
@@ -195,7 +236,10 @@ func main() {
 		adminPage := storage.AdminPage{Tables: tables}
 		adminPage.Tables[col] = append(adminPage.Tables[col], storage.TableData{Link: redirect, DisplayName: text})
 		b, _ := json.Marshal(adminPage)
-		req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/admin/panel", address), bytes.NewReader(b))
+		req, err := newRequest(http.MethodPost, fmt.Sprintf("%s/admin/panel", address), bytes.NewReader(b))
+		if err != nil {
+			log.Fatal(err)
+		}
 		req.AddCookie(prepareCookie(address))
 		req.Header.Add("Content-Type", "application/json")
 		resp, err := client.Do(req)

+ 129 - 0
cmd/keiji-ctl/keiji-ctl_test.go

@@ -0,0 +1,129 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"git.aetherial.dev/aeth/keiji/pkg/controller"
+	"github.com/gin-gonic/gin"
+)
+
+func TestNewRequestOrigin(t *testing.T) {
+	for _, tc := range []struct{ target, origin string }{
+		{"https://example.com:8443/a?x=1#fragment", "https://example.com:8443"},
+		{"http://[::1]:8080/path", "http://[::1]:8080"},
+	} {
+		req, err := newRequest(http.MethodPost, tc.target, nil)
+		if err != nil || req.Header.Get("Origin") != tc.origin {
+			t.Errorf("%s: request=%v err=%v", tc.target, req, err)
+		}
+	}
+	for _, target := range []string{"/relative", "ftp://example.com/path", "https://user:pass@example.com", "http:///path", "://invalid"} {
+		if _, err := newRequest(http.MethodPost, target, nil); err == nil {
+			t.Errorf("accepted invalid target %q", target)
+		}
+	}
+	req, err := newRequest(http.MethodGet, "https://example.com", nil)
+	if err != nil || req.Header.Get("Origin") != "" {
+		t.Fatalf("GET: request=%v err=%v", req, err)
+	}
+	if _, err := newfileUploadRequest("://invalid", nil, "file", createFile(t)); err == nil {
+		t.Fatal("upload accepted invalid target")
+	}
+}
+
+func createFile(t *testing.T) string {
+	t.Helper()
+	file := filepath.Join(t.TempDir(), "image.png")
+	if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	return file
+}
+
+// Run the real CLI in a subprocess because several command paths call os.Exit.
+func TestCLIHelper(t *testing.T) {
+	if os.Getenv("KEIJI_CTL_TEST_HELPER") != "1" {
+		return
+	}
+	for i, arg := range os.Args {
+		if arg == "--" {
+			os.Args = append([]string{"keiji-ctl"}, os.Args[i+1:]...)
+			flag.CommandLine = flag.NewFlagSet("keiji-ctl", flag.ExitOnError)
+			main()
+			return
+		}
+	}
+	t.Fatal("missing helper arguments")
+}
+
+func TestCLIWithSameOrigin(t *testing.T) {
+	t.Setenv("KEIJI_INSECURE_COOKIE", "true")
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	r.POST("/login", controller.SameOrigin, func(c *gin.Context) {
+		http.SetCookie(c.Writer, &http.Cookie{Name: controller.AUTH_COOKIE_NAME, Value: "test-cookie"})
+		c.Status(http.StatusOK)
+	})
+	for _, endpoint := range []string{"asset", "navbar", "menu", "panel"} {
+		r.POST("/admin/"+endpoint, controller.SameOrigin, func(c *gin.Context) {
+			cookie, err := c.Request.Cookie(controller.AUTH_COOKIE_NAME)
+			if err != nil || cookie.Value != "test-cookie" {
+				c.Status(http.StatusUnauthorized)
+				return
+			}
+			c.Status(http.StatusOK)
+		})
+	}
+	server := httptest.NewServer(r)
+	defer server.Close()
+	file := createFile(t)
+	for _, command := range []string{"auth", "asset", "nav", "menu", "admin"} {
+		t.Run(command, func(t *testing.T) {
+			args := []string{"-test.run=^TestCLIHelper$", "--", "-cmd", command, "-address", server.URL, "-cookie", "test-cookie", "-png", file}
+			cmd := exec.Command(os.Args[0], args...)
+			cmd.Env = append(os.Environ(), "KEIJI_CTL_TEST_HELPER=1")
+			output, err := cmd.CombinedOutput()
+			if err != nil {
+				t.Fatalf("CLI failed: %v: %s", err, output)
+			}
+			if command == "auth" && !strings.Contains(string(output), "test-cookie") {
+				t.Fatalf("missing auth cookie: %s", output)
+			}
+		})
+	}
+}
+
+func TestRedirectNotFollowed(t *testing.T) {
+	for _, code := range []int{http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} {
+		t.Run(fmt.Sprint(code), func(t *testing.T) {
+			called := false
+			destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
+			defer destination.Close()
+			source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				http.Redirect(w, r, destination.URL, code)
+			}))
+			defer source.Close()
+			req, err := newRequest(http.MethodPost, source.URL+"/login", strings.NewReader("credentials"))
+			if err != nil {
+				t.Fatal(err)
+			}
+			req.AddCookie(&http.Cookie{Name: "session", Value: "secret"})
+			resp, err := newHTTPClient().Do(req)
+			if err != nil {
+				t.Fatal(err)
+			}
+			defer resp.Body.Close()
+			if resp.StatusCode != code || called {
+				t.Fatalf("status=%d, destination called=%v", resp.StatusCode, called)
+			}
+		})
+	}
+}

+ 14 - 0
cmd/keiji-migrate/README.md

@@ -0,0 +1,14 @@
+# Offline legacy image migration
+
+Stop Keiji **and every other database and media writer** before starting. Take and verify a restorable backup of the SQLite database (including any WAL/SHM state; use SQLite's backup facilities or a cleanly closed database) **and** media directory. Store it elsewhere. This tool does not create or verify backups and does not establish that the application is offline. Restore the backup if recovery is needed.
+
+```sh
+go run ./cmd/keiji-migrate -db /path/to/sqlite.db -media-dir /path/to/images \
+  -legacy-category digital_art -offline -backup-confirmed
+```
+
+The acknowledgements are mandatory. Choose the category for all legacy rows only when `images.category` is absent; existing categories are never overwritten. Missing `category` and `media_type` columns are added and backfill runs in one SQLite transaction. SQLite must be writable, the database must already exist, and `images` must have an `id` and a rowid. The tool does not modify media files. Repeated runs preserve populated media types and retry unknowns, allowing missing files to be restored later.
+
+Detection uses content, not extensions: JPEG, PNG, GIF, WebP and WebM (an EBML header with WebM DocType). Only canonical lowercase UUID filenames are opened; symlinks and non-regular files are ignored. At most 1 MiB is read per file; exceptionally large EBML headers are unknown. Missing, invalid-name, symlink, and unrecognized files are counted as unknown; unexpected filesystem or database errors roll back the transaction. Newly added media type columns store unknown as NULL. If an existing column is NOT NULL with empty-string unknowns, those values remain empty instead of violating its constraint. Existing populated values are never re-detected. Output counts only rows with missing media types, not already-populated rows.
+
+Run tests with `go test ./pkg/storage ./cmd/keiji-migrate`.

+ 51 - 0
cmd/keiji-migrate/main.go

@@ -0,0 +1,51 @@
+package main
+
+import (
+	"context"
+	"database/sql"
+	"flag"
+	"fmt"
+	"net/url"
+	"os"
+	"path/filepath"
+
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+	_ "github.com/mattn/go-sqlite3"
+)
+
+func run(args []string) error {
+	flags := flag.NewFlagSet("keiji-migrate", flag.ContinueOnError)
+	dbPath := flags.String("db", "", "existing SQLite database file")
+	media := flags.String("media-dir", "", "directory containing UUID media files")
+	category := flags.String("legacy-category", "", "category for all rows if category column is missing")
+	offline := flags.Bool("offline", false, "confirm application and other writers are stopped")
+	backup := flags.Bool("backup-confirmed", false, "confirm a verified backup exists")
+	if err := flags.Parse(args); err != nil {
+		return err
+	}
+	if flags.NArg() != 0 || *dbPath == "" || *media == "" || *category == "" || !*offline || !*backup {
+		return fmt.Errorf("required: -db, -media-dir, -legacy-category, -offline, and -backup-confirmed")
+	}
+	abs, err := filepath.Abs(*dbPath)
+	if err != nil {
+		return err
+	}
+	uri := (&url.URL{Scheme: "file", Path: abs, RawQuery: "mode=rw&_busy_timeout=5000&_txlock=immediate"}).String()
+	db, err := sql.Open("sqlite3", uri)
+	if err != nil {
+		return err
+	}
+	defer db.Close()
+	report, err := storage.MigrateLegacyImages(context.Background(), db, *media, *category)
+	if err != nil {
+		return err
+	}
+	fmt.Printf("Migration committed: examined=%d updated=%d unknown=%d\n", report.Examined, report.Updated, report.Unknown)
+	return nil
+}
+func main() {
+	if err := run(os.Args[1:]); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}

+ 36 - 0
cmd/keiji-migrate/main_test.go

@@ -0,0 +1,36 @@
+package main
+
+import (
+	"database/sql"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestRun(t *testing.T) {
+	dir := t.TempDir()
+	dbPath := filepath.Join(dir, "with space?#.db")
+	db, err := sql.Open("sqlite3", (&url.URL{Scheme: "file", Path: dbPath}).String())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err = db.Exec(`CREATE TABLE images (id TEXT)`); err != nil {
+		t.Fatal(err)
+	}
+	db.Close()
+	args := []string{"-db", dbPath, "-media-dir", dir, "-legacy-category", "digital_art", "-offline", "-backup-confirmed"}
+	if err = run(args[:len(args)-1]); err == nil {
+		t.Fatal("missing acknowledgement accepted")
+	}
+	if err = run(args); err != nil {
+		t.Fatal(err)
+	}
+	args[1] = filepath.Join(dir, "missing.db")
+	if err = run(args); err == nil {
+		t.Fatal("missing database accepted")
+	}
+	if _, err = os.Stat(args[1]); !os.IsNotExist(err) {
+		t.Fatal("created missing database")
+	}
+}

+ 15 - 6
cmd/keiji/keiji.go

@@ -80,6 +80,9 @@ func main() {
 	if srcOpt == webpages.FILESYSTEM {
 		e.LoadHTMLGlob(path.Join(os.Getenv("WEB_ROOT"), "html", "*.html"))
 	} else {
+		digitalArtAdmin := webpages.ReadToString(htmlReader, "html/digital_art_admin.html")
+		renderer.AddFromString("digital_art_admin_list", digitalArtAdmin)
+		renderer.AddFromString("digital_art_admin_editor", digitalArtAdmin)
 		for i := range templateNames {
 			name := templateNames[i]
 			renderer.AddFromString(
@@ -89,8 +92,7 @@ func main() {
 		}
 		e.HTMLRender = renderer
 	}
-	dbfile := "sqlite.db"
-	db, err := sql.Open("sqlite3", dbfile)
+	db, err := sql.Open("sqlite3", env.SQLitePath())
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -108,9 +110,16 @@ func main() {
 	if err != nil {
 		log.Fatal("Invalid option passed to USE_SSL: ", os.Getenv("USE_SSL"))
 	}
-	if ssl {
-		e.RunTLS(fmt.Sprintf("%s:%s", os.Getenv("HOST_ADDR"), os.Getenv("HOST_PORT")),
-			os.Getenv(env.CHAIN), os.Getenv(env.KEY))
+	if err := runListener(e, ssl, fmt.Sprintf("%s:%s", os.Getenv("HOST_ADDR"), os.Getenv("HOST_PORT")),
+		os.Getenv(env.CHAIN), os.Getenv(env.KEY)); err != nil {
+		log.Fatal("Server listener failed: ", err)
 	}
-	e.Run(fmt.Sprintf("%s:%s", os.Getenv("HOST_ADDR"), os.Getenv("HOST_PORT")))
+}
+
+// runListener starts exactly one listener; TLS failures must never fall back to HTTP.
+func runListener(e *gin.Engine, tls bool, addr, cert, key string) error {
+	if tls {
+		return e.RunTLS(addr, cert, key)
+	}
+	return e.Run(addr)
 }

+ 12 - 0
deploy/keiji.env.example

@@ -0,0 +1,12 @@
+# Copy to /etc/keiji/keiji.env as root:keiji mode 0640 and set credentials.
+# This example assumes Caddy terminates TLS and proxies to this loopback address.
+HOST_ADDR=127.0.0.1
+HOST_PORT=8080
+DOMAIN_NAME=example.com
+USE_SSL=false
+KEIJI_USERNAME=replace-me
+KEIJI_PASSWORD=replace-me
+SQLITE_PATH=/var/lib/keiji/sqlite.db
+IMAGE_STORE=/var/lib/keiji/images
+# See pkg/controller/SECURITY.md before configuring trusted proxies.
+# KEIJI_TRUSTED_PROXIES=127.0.0.1,::1

+ 21 - 0
deploy/keiji.service

@@ -0,0 +1,21 @@
+[Unit]
+Description=Keiji personal website
+After=network.target
+
+[Service]
+Type=simple
+User=keiji
+Group=keiji
+WorkingDirectory=/var/lib/keiji
+ExecStart=/usr/local/bin/keiji -content embed -env /etc/keiji/keiji.env
+Restart=on-failure
+RestartSec=3
+UMask=0077
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/keiji
+
+[Install]
+WantedBy=multi-user.target

+ 56 - 0
deploy/setup.sh

@@ -0,0 +1,56 @@
+#!/bin/sh
+# Provision Keiji's service identity and private directories on Linux.
+set -eu
+
+if [ "$(id -u)" -ne 0 ]; then
+    echo 'Run this script as root (for example, sudo ./deploy/setup.sh).' >&2
+    exit 1
+fi
+if [ ! -x /sbin/nologin ]; then
+    echo '/sbin/nologin is not available; refusing to create the service user.' >&2
+    exit 1
+fi
+
+# Do not silently repurpose an existing human account or change its shell.
+if getent group keiji >/dev/null; then
+    :
+else
+    groupadd --system keiji
+fi
+if getent passwd keiji >/dev/null; then
+    entry=$(getent passwd keiji)
+    existing_gid=$(printf '%s\n' "$entry" | cut -d: -f4)
+    existing_shell=$(printf '%s\n' "$entry" | cut -d: -f7)
+    group_gid=$(getent group keiji | cut -d: -f3)
+    if [ "$existing_shell" != /sbin/nologin ] || [ "$existing_gid" != "$group_gid" ]; then
+        echo 'Existing keiji account has a different shell or primary group; review it manually.' >&2
+        exit 1
+    fi
+else
+    useradd --system --no-create-home --gid keiji --shell /sbin/nologin keiji
+fi
+
+# Refuse symlinks at managed paths rather than following them as root.
+for directory in /etc/keiji /var/lib/keiji /var/lib/keiji/images; do
+    if [ -L "$directory" ] || { [ -e "$directory" ] && [ ! -d "$directory" ]; }; then
+        echo "Not a safe directory: $directory" >&2
+        exit 1
+    fi
+done
+
+# Config is root-managed, readable by the service group, never service-writable.
+install -d -o root -g keiji -m 0750 /etc/keiji
+# Existing configuration contents are deliberately not changed recursively.
+# An administrator must install individual secrets as root:keiji mode 0640.
+
+# SQLite needs write access to its directory for journal and sidecar files.
+install -d -o keiji -g root -m 0700 /var/lib/keiji /var/lib/keiji/images
+# Do not recursively change ownership or modes on existing application data.
+
+cat <<'MSG'
+Keiji identity and directories are ready.
+Install your environment file as root:keiji mode 0640, for example:
+  install -o root -g keiji -m 0640 /path/to/keiji.env /etc/keiji/keiji.env
+Existing files were not modified. Review their ownership, permissions and ACLs.
+This script does not install or start Keiji.
+MSG

+ 36 - 0
pkg/controller/SECURITY.md

@@ -0,0 +1,36 @@
+# Admin security configuration
+
+Run the site over HTTPS. Authentication cookies are host-only, HttpOnly,
+Secure, SameSite=Lax, scoped to `/`, and expire after 20 minutes (the same
+lifetime as the in-memory session). `DOMAIN_NAME` no longer controls the cookie
+domain. Sessions and login counters are per process and are lost on restart.
+
+For local HTTP development **only**, set `KEIJI_INSECURE_COOKIE=true`. This
+removes Secure from the cookie and changes the expected browser origin to HTTP;
+never use it on an Internet-facing deployment. TLS termination at a reverse proxy
+requires no special cookie configuration: cookies are Secure by default, even if
+the proxy connects to Keiji over HTTP. The proxy must preserve the public Host
+header and reject unrecognized hosts, prevent direct access to the backend, and
+serve only HTTPS. Forwarded host and proto headers are not trusted for CSRF.
+
+Unsafe `/admin` requests and POST `/login` require an `Origin` matching the
+public scheme and Host (including any non-default port); if Origin is absent,
+Referer is used. Missing, `null`, cross-origin, or malformed values are rejected.
+This supports browser forms, multipart uploads, and HTMX without template changes;
+a restrictive Referrer-Policy that suppresses both headers will break requests.
+Non-browser clients such as keiji-ctl must send an appropriate Origin header on
+mutations. Authentication remains required independently. Same-origin XSS is not
+prevented by this check.
+
+Login is limited to 10 attempts per IP per 10-minute fixed window, including
+successful and malformed attempts; excess requests return 429. By default the
+TCP peer is used and forwarding headers are ignored. If a reverse proxy is used,
+set `KEIJI_TRUSTED_PROXIES` to a comma-separated list of its IPs or CIDRs, e.g.
+`10.0.0.1,192.168.1.0/24`. Only configure addresses under your control: the
+limiter walks X-Forwarded-For from the trusted peer to the first untrusted hop.
+The proxy must correctly append or overwrite X-Forwarded-For and must not allow
+clients to connect directly using a trusted source address. Unconfigured proxies
+share a single limit. Invalid entries are ignored. The limiter is in-memory,
+per instance, and bounded to 10,000 active IPs; at capacity, new IPs receive
+429 until entries expire. For distributed deployments, apply a shared edge rate
+limit as well.

+ 215 - 48
pkg/controller/admin_handlers.go

@@ -7,6 +7,7 @@ import (
 	"io"
 	"log"
 	"net/http"
+	"strings"
 	"time"
 
 	"git.aetherial.dev/aeth/keiji/pkg/auth"
@@ -16,6 +17,21 @@ import (
 
 const AUTH_COOKIE_NAME = "X-Server-Auth"
 
+// adminTables and adminImages preserve compatibility with legacy repositories.
+func (c *Controller) adminTables() (storage.AdminPage, error) {
+	if reader, ok := c.database.(storage.DocumentListReader); ok {
+		return reader.GetAdminTablesWithError()
+	}
+	return c.database.GetAdminTables(), nil
+}
+
+func (c *Controller) adminImages() ([]storage.Image, error) {
+	if reader, ok := c.database.(storage.DocumentListReader); ok {
+		return reader.GetAllImagesWithError()
+	}
+	return c.database.GetAllImages(), nil
+}
+
 // @Name ServeLogin
 // @Summary serves the HTML login page
 // @Tags admin
@@ -54,14 +70,30 @@ func (c *Controller) Auth(ctx *gin.Context) {
 		})
 		return
 	}
-	ctx.SetCookie(AUTH_COOKIE_NAME, cookie, 3600, "/", c.Domain, false, false)
+	page, err := c.adminTables()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	ctx.SetSameSite(http.SameSiteLaxMode)
+	ctx.SetCookie(AUTH_COOKIE_NAME, cookie, 1200, "/", "", !insecureCookies(), true)
 
+	tables := page.Tables
+	if tables == nil {
+		tables = map[string][]storage.TableData{}
+	}
+	tables["modify"] = append(tables["modify"], storage.TableData{DisplayName: "Digital Art", Link: "/admin/digital"})
 	ctx.HTML(http.StatusOK, "admin", gin.H{
 		"navigation": gin.H{
-			"headers": c.database.GetNavBarLinks(),
-			"menu":    c.database.GetDropdownElements(),
+			"headers": headers,
+			"menu":    menu,
 		},
-		"Tables": c.database.GetAdminTables().Tables,
+		"Tables": tables,
 	})
 
 }
@@ -200,7 +232,11 @@ func (c *Controller) RemoveNavbarItem(ctx *gin.Context) {
 }
 
 func (c *Controller) ServeNavbarModify(ctx *gin.Context) {
-	navbar := c.database.GetNavBarLinks()
+	navbar, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	tableData := storage.AdminPage{Tables: map[string][]storage.TableData{}}
 	for i := range navbar {
 		tableData.Tables["items"] = append(tableData.Tables["items"],
@@ -212,8 +248,8 @@ func (c *Controller) ServeNavbarModify(ctx *gin.Context) {
 	}
 	ctx.HTML(200, "admin", gin.H{
 		"navigation": gin.H{
-			"menu":    c.database.GetDropdownElements(),
-			"headers": c.database.GetNavBarLinks(),
+			"menu":    menu,
+			"headers": navbar,
 		},
 		"Tables": tableData.Tables,
 	})
@@ -251,12 +287,27 @@ func (c *Controller) AddAsset(ctx *gin.Context) {
 // @Router /admin/panel [get]
 func (c *Controller) AdminPanel(ctx *gin.Context) {
 
+	page, err := c.adminTables()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	tables := page.Tables
+	if tables == nil {
+		tables = map[string][]storage.TableData{}
+	}
+	tables["modify"] = append(tables["modify"], storage.TableData{DisplayName: "Digital Art", Link: "/admin/digital"})
 	ctx.HTML(http.StatusOK, "admin", gin.H{
 		"navigation": gin.H{
-			"headers": c.database.GetNavBarLinks(),
-			"menu":    c.database.GetDropdownElements(),
+			"headers": headers,
+			"menu":    menu,
 		},
-		"Tables": c.database.GetAdminTables().Tables,
+		"Tables": tables,
 	})
 
 }
@@ -267,7 +318,11 @@ Serves the admin panel with all of the documents in each blog category for editi
 func (c *Controller) ServeBlogDirectory(ctx *gin.Context) {
 	tableData := storage.AdminPage{Tables: map[string][]storage.TableData{}}
 	for i := range storage.Topics {
-		docs := c.database.GetByCategory(storage.Topics[i])
+		docs, err := c.htmlDocuments(storage.Topics[i])
+		if err != nil {
+			htmlListError(ctx, err)
+			return
+		}
 		for z := range docs {
 			tableData.Tables[storage.Topics[i]] = append(tableData.Tables[storage.Topics[i]],
 				storage.TableData{
@@ -278,10 +333,15 @@ func (c *Controller) ServeBlogDirectory(ctx *gin.Context) {
 		}
 	}
 
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	ctx.HTML(200, "admin", gin.H{
 		"navigation": gin.H{
-			"menu":    c.database.GetDropdownElements(),
-			"headers": c.database.GetNavBarLinks(),
+			"menu":    menu,
+			"headers": headers,
 		},
 		"Tables": tableData.Tables,
 	})
@@ -306,16 +366,20 @@ func (c *Controller) GetBlogPostEditor(ctx *gin.Context) {
 		})
 		return
 	}
-	images := c.database.GetAllImages()
-	imgIds := []storage.Identifier{}
-	for i := range images {
-		imgIds = append(imgIds, images[i].Ident)
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	images, err := c.adminImages()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
 	}
-
 	ctx.HTML(200, "blogpost_editor", gin.H{
 		"navigation": gin.H{
-			"menu":    c.database.GetDropdownElements(),
-			"headers": c.database.GetNavBarLinks(),
+			"menu":    menu,
+			"headers": headers,
 		},
 		"Ident":        doc.Ident,
 		"Topics":       storage.Topics,
@@ -323,7 +387,7 @@ func (c *Controller) GetBlogPostEditor(ctx *gin.Context) {
 		"DefaultTopic": doc.Category,
 		"Created":      doc.Created,
 		"Body":         doc.Body,
-		"ImageIds":     imgIds,
+		"Images":       images,
 	})
 }
 
@@ -352,12 +416,23 @@ Serving the new blogpost page. Serves the editor with the method to POST a new d
 */
 func (c *Controller) ServeNewBlogPage(ctx *gin.Context) {
 
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	images, err := c.adminImages()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	ctx.HTML(200, "blogpost_editor", gin.H{
 		"navigation": gin.H{
-			"menu":    c.database.GetDropdownElements(),
-			"headers": c.database.GetNavBarLinks(),
+			"menu":    menu,
+			"headers": headers,
 		},
 		"Post":    true,
+		"Images":  images,
 		"Topics":  storage.Topics,
 		"Created": time.Now().UTC().String(),
 	})
@@ -368,10 +443,15 @@ Serving the new blogpost page. Serves the editor with the method to POST a new d
 */
 func (c *Controller) ServeNewNavbarItem(ctx *gin.Context) {
 
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	ctx.HTML(200, "navbar_editor", gin.H{
 		"navigation": gin.H{
-			"menu":    c.database.GetDropdownElements(),
-			"headers": c.database.GetNavBarLinks(),
+			"menu":    menu,
+			"headers": headers,
 		},
 		"Post": true,
 	})
@@ -400,10 +480,15 @@ func (c *Controller) MakeBlogPost(ctx *gin.Context) {
 Serves the HTML page for a new visual media post
 */
 func (c *Controller) ServeFileUpload(ctx *gin.Context) {
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	ctx.HTML(200, "upload", gin.H{
 		"navigation": gin.H{
-			"menu":    c.database.GetDropdownElements(),
-			"headers": c.database.GetNavBarLinks(),
+			"menu":    menu,
+			"headers": headers,
 		},
 		"Topics":       storage.ImageCategories,
 		"DefaultTopic": storage.DIGITAL_ART,
@@ -413,42 +498,64 @@ func (c *Controller) ServeFileUpload(ctx *gin.Context) {
 /*
 Reciever for the page served to created a new visual media post
 */
+// maxUploadSize limits the file itself; the request has additional room for form fields.
+const maxUploadSize = 32 << 20
+const maxUploadRequestSize = maxUploadSize + (1 << 20)
+
 func (c *Controller) SaveFile(ctx *gin.Context) {
+	ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxUploadRequestSize)
+	defer func() {
+		if ctx.Request.MultipartForm != nil {
+			ctx.Request.MultipartForm.RemoveAll()
+		}
+	}()
+	fail := func(status int, message string) {
+		ctx.HTML(status, "upload_status", gin.H{"UpdateMessage": message, "Color": "red"})
+	}
 	var img storage.Image
-	err := ctx.ShouldBind(&img)
-	if err != nil {
-		ctx.HTML(500, "upload_status", gin.H{"UpdateMessage": err, "Color": "red"})
+	if err := ctx.ShouldBind(&img); err != nil {
+		var maxErr *http.MaxBytesError
+		if errors.As(err, &maxErr) {
+			fail(http.StatusRequestEntityTooLarge, "Upload too large")
+		} else {
+			fail(http.StatusBadRequest, "Invalid upload")
+		}
 		return
 	}
 	file, err := ctx.FormFile("file")
 	if err != nil {
-		ctx.HTML(500, "upload_status", gin.H{"UpdateMessage": err, "Color": "red"})
+		fail(http.StatusBadRequest, "Missing upload file")
+		return
+	}
+	if file.Size > maxUploadSize {
+		fail(http.StatusRequestEntityTooLarge, "Upload too large")
 		return
 	}
 	fh, err := file.Open()
 	if err != nil {
-		ctx.HTML(500, "upload_status", gin.H{"UpdateMessage": err, "Color": "red"})
+		fail(http.StatusInternalServerError, "Could not open upload")
 		return
 	}
-	fb := make([]byte, file.Size)
-	var output bytes.Buffer
-	for {
-		n, err := fh.Read(fb)
-		if err == io.EOF {
-			break
-		}
-		if err != nil {
-			log.Fatal(err)
-		}
-		output.Write(fb[:n])
-	}
-	_, err = c.database.AddImage(fb, img.Title, img.Desc, img.Category)
+	defer fh.Close()
+	data, err := io.ReadAll(io.LimitReader(fh, maxUploadSize+1))
 	if err != nil {
-		ctx.HTML(500, "upload_status", gin.H{"UpdateMessage": err, "Color": "red"})
+		fail(http.StatusInternalServerError, "Could not read upload")
 		return
 	}
-
-	ctx.HTML(200, "upload_status", gin.H{"UpdateMessage": "Update Successful!", "Color": "green"})
+	if len(data) > maxUploadSize {
+		fail(http.StatusRequestEntityTooLarge, "Upload too large")
+		return
+	}
+	mediaType := detectMediaType(data)
+	if mediaType == "" {
+		fail(http.StatusUnsupportedMediaType, "Unsupported media type")
+		return
+	}
+	if _, err = c.database.AddImage(data, img.Title, img.Desc, img.Category, mediaType); err != nil {
+		fail(http.StatusInternalServerError, "Could not save upload")
+		return
+	}
+	ctx.HTML(http.StatusOK, "upload_status", gin.H{"UpdateMessage": "Update Successful!", "Color": "green"})
 }
 
 // Serve the document deletion template
@@ -497,3 +604,63 @@ func (c *Controller) DeleteDocument(ctx *gin.Context) {
 	ctx.HTML(200, "upload_status", gin.H{"UpdateMessage": "Delete Successful!", "Color": "green"})
 
 }
+
+// Digital art administration is restricted by the /admin authentication middleware.
+func (c *Controller) ListDigitalArt(ctx *gin.Context) {
+	images, err := c.htmlImages(storage.DIGITAL_ART)
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	ctx.HTML(http.StatusOK, "digital_art_admin_list", gin.H{"Images": images})
+}
+
+func (c *Controller) EditDigitalArt(ctx *gin.Context) {
+	image, err := c.database.GetImage(storage.Identifier(ctx.Param("id")))
+	if err != nil || image.Category != storage.DIGITAL_ART {
+		ctx.String(http.StatusNotFound, "Digital art not found")
+		return
+	}
+	ctx.HTML(http.StatusOK, "digital_art_admin_editor", image)
+}
+
+func (c *Controller) UpdateDigitalArt(ctx *gin.Context) {
+	title := strings.TrimSpace(ctx.PostForm("title"))
+	desc := ctx.PostForm("description")
+	if title == "" {
+		ctx.HTML(http.StatusBadRequest, "upload_status", gin.H{"UpdateMessage": "Title is required", "Color": "red"})
+		return
+	}
+	err := c.database.UpdateDigitalArt(storage.Identifier(ctx.Param("id")), title, desc)
+	if err != nil {
+		status := http.StatusInternalServerError
+		message := "Could not update digital art"
+		if errors.Is(err, storage.ErrNotExists) {
+			status = http.StatusNotFound
+			message = "Digital art not found"
+		} else {
+			log.Printf("update digital art failed: %v", err)
+		}
+		ctx.HTML(status, "upload_status", gin.H{"UpdateMessage": message, "Color": "red"})
+		return
+	}
+	ctx.HTML(http.StatusOK, "upload_status", gin.H{"UpdateMessage": "Digital art updated", "Color": "green"})
+}
+
+// RemoveDigitalArt removes the gallery listing while preserving referenced media.
+func (c *Controller) RemoveDigitalArt(ctx *gin.Context) {
+	err := c.database.ArchiveDigitalArt(storage.Identifier(ctx.Param("id")))
+	if err != nil {
+		status := http.StatusInternalServerError
+		message := "Could not remove digital art listing"
+		if errors.Is(err, storage.ErrNotExists) {
+			status = http.StatusNotFound
+			message = "Digital art not found"
+		} else {
+			log.Printf("remove digital art listing failed: %v", err)
+		}
+		ctx.HTML(status, "upload_status", gin.H{"UpdateMessage": message, "Color": "red"})
+		return
+	}
+	ctx.HTML(http.StatusOK, "upload_status", gin.H{"UpdateMessage": "Listing removed. The media file remains available for posts.", "Color": "green"})
+}

+ 140 - 0
pkg/controller/admin_list_errors_test.go

@@ -0,0 +1,140 @@
+package controller
+
+import (
+	"errors"
+	"html/template"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"git.aetherial.dev/aeth/keiji/pkg/auth"
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+	"github.com/gin-gonic/gin"
+)
+
+type adminLegacyListRepo struct{ *htmlLegacyRepo }
+
+func (r *adminLegacyListRepo) GetAdminTables() storage.AdminPage { return storage.AdminPage{} }
+func (r *adminLegacyListRepo) GetAllImages() []storage.Image     { return nil }
+
+type adminErrorListRepo struct {
+	*adminLegacyListRepo
+	storage.DocumentListReader
+	fail string
+}
+
+func (r *adminErrorListRepo) failure(name string) error {
+	if r.fail == name {
+		return errors.New("list failure: " + name)
+	}
+	return nil
+}
+func (r *adminErrorListRepo) GetAdminTablesWithError() (storage.AdminPage, error) {
+	return storage.AdminPage{}, r.failure("tables")
+}
+func (r *adminErrorListRepo) GetAllImagesWithError() ([]storage.Image, error) {
+	return nil, r.failure("all images")
+}
+func (r *adminErrorListRepo) GetImagesByCategoryWithError(category string) ([]storage.Image, error) {
+	r.category = category
+	return nil, r.failure("images")
+}
+func (r *adminErrorListRepo) GetByCategoryWithError(category string) ([]storage.Document, error) {
+	r.category = category
+	return nil, r.failure("documents")
+}
+func (r *adminErrorListRepo) GetNavBarLinksWithError() ([]storage.NavBarItem, error) {
+	return nil, r.failure("navbar")
+}
+func (r *adminErrorListRepo) GetDropdownElementsWithError() ([]storage.LinkPair, error) {
+	return nil, r.failure("menu")
+}
+
+type adminListAuth struct{}
+
+func (adminListAuth) AdminUsername() string { return "user" }
+func (adminListAuth) AdminPassword() string { return "pass" }
+
+func adminListRouter(repo storage.DocumentIO) *gin.Engine {
+	r := gin.New()
+	r.SetHTMLTemplate(template.Must(template.New("test").Parse(`{{define "admin"}}admin {{range index .Tables "modify"}}{{.DisplayName}}{{end}}{{end}}{{define "blogpost_editor"}}editor{{end}}{{define "navbar_editor"}}navbar editor{{end}}{{define "upload"}}upload{{end}}{{define "digital_art_admin_list"}}digital list{{end}}`)))
+	c := &Controller{database: repo, Cache: auth.NewCache(), AuthSource: adminListAuth{}}
+	r.POST("/login", c.Auth)
+	r.GET("/panel", c.AdminPanel)
+	r.GET("/navbar", c.ServeNavbarModify)
+	r.GET("/blog", c.ServeBlogDirectory)
+	r.GET("/editor/:id", c.GetBlogPostEditor)
+	r.GET("/new-blog", c.ServeNewBlogPage)
+	r.GET("/new-navbar", c.ServeNewNavbarItem)
+	r.GET("/upload", c.ServeFileUpload)
+	r.GET("/digital", c.ListDigitalArt)
+	return r
+}
+
+func adminListRequest(r *gin.Engine, path string) *httptest.ResponseRecorder {
+	method, body := http.MethodGet, ""
+	if path == "/login" {
+		method, body = http.MethodPost, "username=user&password=pass"
+	}
+	req := httptest.NewRequest(method, path, strings.NewReader(body))
+	if method == http.MethodPost {
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	}
+	response := httptest.NewRecorder()
+	r.ServeHTTP(response, req)
+	return response
+}
+
+func TestAdminListFailures(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, tc := range []struct{ path, failure string }{
+		{"/login", "tables"}, {"/login", "navbar"}, {"/login", "menu"},
+		{"/panel", "tables"}, {"/panel", "navbar"}, {"/panel", "menu"},
+		{"/navbar", "navbar"}, {"/navbar", "menu"},
+		{"/blog", "documents"}, {"/blog", "navbar"}, {"/blog", "menu"},
+		{"/editor/id", "all images"}, {"/editor/id", "navbar"}, {"/editor/id", "menu"},
+		{"/new-blog", "all images"}, {"/new-blog", "navbar"}, {"/new-blog", "menu"},
+		{"/new-navbar", "navbar"}, {"/new-navbar", "menu"},
+		{"/upload", "navbar"}, {"/upload", "menu"}, {"/digital", "images"},
+	} {
+		t.Run(tc.path+"/"+tc.failure, func(t *testing.T) {
+			repo := &adminErrorListRepo{adminLegacyListRepo: &adminLegacyListRepo{&htmlLegacyRepo{}}, fail: tc.failure}
+			response := adminListRequest(adminListRouter(repo), tc.path)
+			if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), "Could not load content") {
+				t.Fatalf("got status %d body %q", response.Code, response.Body.String())
+			}
+			if tc.path == "/login" && len(response.Result().Cookies()) != 0 {
+				t.Fatal("failed login response set a cookie")
+			}
+		})
+	}
+}
+
+func TestAdminListEmptyAndLegacyCompatibility(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, legacy := range []bool{false, true} {
+		for _, tc := range []struct{ path, body string }{
+			{"/login", "Digital Art"}, {"/panel", "Digital Art"}, {"/navbar", "admin"}, {"/blog", "admin"},
+			{"/editor/id", "editor"}, {"/new-blog", "editor"}, {"/new-navbar", "navbar editor"}, {"/upload", "upload"}, {"/digital", "digital list"},
+		} {
+			t.Run(tc.path+map[bool]string{true: "/legacy", false: "/error-reader"}[legacy], func(t *testing.T) {
+				base := &adminLegacyListRepo{&htmlLegacyRepo{}}
+				var repo storage.DocumentIO = base
+				if !legacy {
+					repo = &adminErrorListRepo{adminLegacyListRepo: base}
+				}
+				response := adminListRequest(adminListRouter(repo), tc.path)
+				if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), tc.body) {
+					t.Fatalf("got status %d body %q", response.Code, response.Body.String())
+				}
+				if tc.path == "/digital" && base.category != storage.DIGITAL_ART {
+					t.Fatalf("category = %q", base.category)
+				}
+				if tc.path == "/login" && len(response.Result().Cookies()) == 0 {
+					t.Fatal("successful login did not set a cookie")
+				}
+			})
+		}
+	}
+}

+ 66 - 0
pkg/controller/backup_integration_test.go

@@ -0,0 +1,66 @@
+package controller
+
+import (
+	"bytes"
+	"database/sql"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+	"github.com/gin-gonic/gin"
+	_ "github.com/mattn/go-sqlite3"
+)
+
+// Run explicitly with KEIJI_BACKUP_DIR pointing to a backup containing the
+// migrated database and image store. The database is opened read-only.
+func TestMigratedBackup(t *testing.T) {
+	dir := os.Getenv("KEIJI_BACKUP_DIR")
+	if dir == "" {
+		t.Skip("set KEIJI_BACKUP_DIR to test a migrated backup")
+	}
+	db, err := sql.Open("sqlite3", "file:"+filepath.Join(dir, "sqlite-media-types-category.db")+"?mode=ro&immutable=1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+	repo := storage.NewSQLiteRepo(db, storage.FilesystemImageIO{RootDir: filepath.Join(dir, "images")})
+	images := repo.GetImagesByCategory(storage.DIGITAL_ART)
+	if len(images) != 8 {
+		t.Fatalf("got %d images, want 8", len(images))
+	}
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	c := &Controller{database: repo}
+	r.GET("/images/:file", c.ServeImage)
+	counts := map[string]int{}
+	for _, img := range images {
+		counts[img.MediaType]++
+		original, err := os.ReadFile(filepath.Join(dir, "images", string(img.Ident)))
+		if err != nil {
+			t.Fatal(err)
+		}
+		req := httptest.NewRequest("GET", "/images/"+string(img.Ident), nil)
+		rec := httptest.NewRecorder()
+		r.ServeHTTP(rec, req)
+		if rec.Code != 200 || rec.Header().Get("Content-Type") != img.MediaType || !bytes.Equal(rec.Body.Bytes(), original) {
+			t.Fatalf("bad response for %s: status %d type %s", img.Ident, rec.Code, rec.Header().Get("Content-Type"))
+		}
+		if img.MediaType == "video/webm" {
+			if detectMediaType(original) != "video/webm" {
+				t.Fatal("webm detection failed")
+			}
+			req = httptest.NewRequest("GET", "/images/"+string(img.Ident), nil)
+			req.Header.Set("Range", "bytes=0-15")
+			rec = httptest.NewRecorder()
+			r.ServeHTTP(rec, req)
+			if rec.Code != 206 || !bytes.Equal(rec.Body.Bytes(), original[:16]) {
+				t.Fatal("webm range request failed")
+			}
+		}
+	}
+	if counts["video/webm"] != 1 || counts["image/jpeg"] != 3 || counts["image/gif"] != 4 {
+		t.Fatalf("unexpected types: %v", counts)
+	}
+}

+ 51 - 17
pkg/controller/cdn_handlers.go

@@ -1,12 +1,14 @@
 package controller
 
 import (
-	"fmt"
+	"bytes"
+	"errors"
 	"io"
 	"net/http"
 	"os"
 	"path"
 	"strings"
+	"time"
 
 	"git.aetherial.dev/aeth/keiji/pkg/storage"
 	"github.com/gin-gonic/gin"
@@ -17,25 +19,46 @@ import (
 // @Tags cdn
 // @Router /images/{file} [get]
 func (c *Controller) ServeImage(ctx *gin.Context) {
-	f, exist := ctx.Params.Get("file")
-	if !exist {
-		ctx.JSON(404, map[string]string{
-			"Error": "the requested file could not be found",
-		})
+	id, exists := ctx.Params.Get("file")
+	if !exists || id == "" {
+		ctx.JSON(http.StatusNotFound, gin.H{"Error": "the requested file could not be found"})
+		return
 	}
-	css := fmt.Sprintf("%s/%s", storage.GetImageStore(), f)
-	b, err := os.ReadFile(css)
-	if err != nil {
-		ctx.JSON(500, map[string]string{
-			"Error": "Could not serve the requested file",
-			"msg":   err.Error(),
-		})
+	var image storage.Image
+	var content io.ReadSeeker
+	var file storage.ReadSeekCloser
+	var err error
+	if opener, ok := c.database.(interface {
+		OpenImage(storage.Identifier) (storage.Image, storage.ReadSeekCloser, error)
+	}); ok {
+		image, file, err = opener.OpenImage(storage.Identifier(id))
+		if err == nil && file == nil {
+			err = errors.New("image stream is nil")
+		}
+		if file != nil {
+			defer file.Close()
+			content = file
+		}
+	} else {
+		// Compatibility for repositories that only implement DocumentIO.
+		image, err = c.database.GetImage(storage.Identifier(id))
+		content = bytes.NewReader(image.Data)
 	}
-	if path.Ext(css) == "webm" {
-		ctx.Data(200, "video/webm", b)
+	if err != nil {
+		if errors.Is(err, storage.ErrNotExists) || errors.Is(err, os.ErrNotExist) {
+			ctx.JSON(http.StatusNotFound, gin.H{"Error": "the requested file could not be found"})
+		} else {
+			ctx.JSON(http.StatusInternalServerError, gin.H{"Error": "Could not serve the requested file"})
+		}
 		return
 	}
-	ctx.Data(200, "image/jpeg", b)
+	mediaType := image.MediaType
+	if mediaType == "" {
+		mediaType = "application/octet-stream"
+	}
+	ctx.Header("Content-Type", mediaType)
+	ctx.Header("X-Content-Type-Options", "nosniff")
+	http.ServeContent(ctx.Writer, ctx.Request, "", time.Time{}, content)
 }
 
 // @Name ServeAsset
@@ -50,7 +73,17 @@ func (c *Controller) ServeAsset(ctx *gin.Context) {
 		})
 		return
 	}
-	assets := c.database.GetAssets()
+	var assets []storage.Asset
+	if reader, ok := c.database.(storage.DocumentListReader); ok {
+		var err error
+		assets, err = reader.GetAssetsWithError()
+		if err != nil {
+			htmlListError(ctx, err)
+			return
+		}
+	} else {
+		assets = c.database.GetAssets()
+	}
 	for i := range assets {
 		if strings.Contains(assets[i].Name, f) {
 			ctx.Data(200, "image/png", assets[i].Data)
@@ -95,6 +128,7 @@ func (c *Controller) ServeGeneric(ctx *gin.Context) {
 		})
 		return
 	}
+	defer fh.Close()
 	b, err := io.ReadAll(fh)
 	if err != nil {
 		ctx.JSON(500, map[string]string{

+ 97 - 0
pkg/controller/cdn_stream_test.go

@@ -0,0 +1,97 @@
+package controller
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"testing/fstest"
+
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+	"github.com/gin-gonic/gin"
+)
+
+type trackedImageStream struct {
+	*bytes.Reader
+	closed bool
+}
+
+func (s *trackedImageStream) Close() error { s.closed = true; return nil }
+
+type streamingRepo struct {
+	*mediaRepo
+	stream *trackedImageStream
+	err    error
+}
+
+func (r *streamingRepo) OpenImage(id storage.Identifier) (storage.Image, storage.ReadSeekCloser, error) {
+	return storage.Image{MediaType: "video/webm"}, r.stream, r.err
+}
+func TestServeImageStream(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, tc := range []struct {
+		method, rangeHeader string
+		status              int
+		body                string
+	}{
+		{"GET", "bytes=2-5", 206, "2345"}, {"HEAD", "", 200, ""}, {"GET", "bytes=20-30", 416, ""},
+	} {
+		stream := &trackedImageStream{Reader: bytes.NewReader([]byte("0123456789"))}
+		repo := &streamingRepo{mediaRepo: &mediaRepo{}, stream: stream}
+		router := gin.New()
+		c := &Controller{database: repo}
+		router.Handle(tc.method, "/images/:file", c.ServeImage)
+		req := httptest.NewRequest(tc.method, "/images/x", nil)
+		req.Header.Set("Range", tc.rangeHeader)
+		res := httptest.NewRecorder()
+		router.ServeHTTP(res, req)
+		if res.Code != tc.status || (tc.status != 416 && res.Body.String() != tc.body) || !stream.closed {
+			t.Fatalf("%s %d %q closed=%v", tc.method, res.Code, res.Body.String(), stream.closed)
+		}
+	}
+}
+
+type trackingFS struct{ file *trackingFile }
+
+func (f trackingFS) Open(name string) (fs.File, error) { return f.file, nil }
+
+type trackingFile struct {
+	fs.File
+	closed bool
+	fail   bool
+}
+
+func (f *trackingFile) Close() error { f.closed = true; return f.File.Close() }
+func (f *trackingFile) Read(b []byte) (int, error) {
+	if f.fail {
+		return 0, errors.New("read failed")
+	}
+	return f.File.Read(b)
+}
+func TestServeGenericClosesFile(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, fail := range []bool{false, true} {
+		f, err := (fstest.MapFS{"cdn/a.js": &fstest.MapFile{Data: []byte("test")}}).Open("cdn/a.js")
+		if err != nil {
+			t.Fatal(err)
+		}
+		tf := &trackingFile{File: f, fail: fail}
+		c := &Controller{FileIO: trackingFS{tf}}
+		router := gin.New()
+		router.GET("/cdn/:file", c.ServeGeneric)
+		res := httptest.NewRecorder()
+		router.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/cdn/a.js", nil))
+		want := 200
+		if fail {
+			want = 500
+		}
+		if res.Code != want || !tf.closed {
+			t.Fatalf("status %d closed %v", res.Code, tf.closed)
+		}
+	}
+}
+
+var _ io.ReadSeeker = (*trackedImageStream)(nil)

+ 77 - 10
pkg/controller/html_handlers.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"html/template"
+	"log"
 	"math/rand"
 	"net/http"
 
@@ -29,6 +30,39 @@ func MdToHTML(md []byte) []byte {
 	return markdown.Render(doc, renderer)
 }
 
+// These helpers retain compatibility with older DocumentIO implementations while
+// allowing repositories that implement DocumentListReader to report failures.
+func (c *Controller) htmlDocuments(category string) ([]storage.Document, error) {
+	if reader, ok := c.database.(storage.DocumentListReader); ok {
+		return reader.GetByCategoryWithError(category)
+	}
+	return c.database.GetByCategory(category), nil
+}
+
+func (c *Controller) htmlImages(category string) ([]storage.Image, error) {
+	if reader, ok := c.database.(storage.DocumentListReader); ok {
+		return reader.GetImagesByCategoryWithError(category)
+	}
+	return c.database.GetImagesByCategory(category), nil
+}
+
+func (c *Controller) htmlNavigation() ([]storage.NavBarItem, []storage.LinkPair, error) {
+	if reader, ok := c.database.(storage.DocumentListReader); ok {
+		headers, err := reader.GetNavBarLinksWithError()
+		if err != nil {
+			return nil, nil, err
+		}
+		menu, err := reader.GetDropdownElementsWithError()
+		return headers, menu, err
+	}
+	return c.database.GetNavBarLinks(), c.database.GetDropdownElements(), nil
+}
+
+func htmlListError(ctx *gin.Context, err error) {
+	log.Printf("list query failed: %v", err)
+	ctx.JSON(http.StatusInternalServerError, gin.H{"Error": "Could not load content"})
+}
+
 // @Name ServePost
 // @Summary serves HTML files out of the HTML directory
 // @Tags webpages
@@ -52,15 +86,20 @@ func (c *Controller) ServePost(ctx *gin.Context) {
 		ctx.Status(404)
 		return
 	}
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	ctx.HTML(http.StatusOK, "blogpost", gin.H{
 		"navigation": gin.H{
-			"headers": c.database.GetNavBarLinks(),
+			"headers": headers,
 		},
 		"Title":   doc.Title,
 		"Ident":   doc.Ident,
 		"Created": doc.Created,
 		"Body":    template.HTML(MdToHTML([]byte(doc.Body))),
-		"menu":    c.database.GetDropdownElements(),
+		"menu":    menu,
 	})
 
 }
@@ -70,7 +109,16 @@ func (c *Controller) ServePost(ctx *gin.Context) {
 // @Tags webpages
 // @Router / [get]
 func (c *Controller) ServeHome(ctx *gin.Context) {
-	home := c.database.GetByCategory(storage.HOMEPAGE)
+	home, err := c.htmlDocuments(storage.HOMEPAGE)
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	var content storage.Document
 	if len(home) == 0 {
 		content = storage.Document{
@@ -82,9 +130,9 @@ func (c *Controller) ServeHome(ctx *gin.Context) {
 	}
 	ctx.HTML(http.StatusOK, "home", gin.H{
 		"navigation": gin.H{
-			"headers": c.database.GetNavBarLinks(),
+			"headers": headers,
 		},
-		"menu":    c.database.GetDropdownElements(),
+		"menu":    menu,
 		"default": content,
 	})
 }
@@ -94,7 +142,12 @@ func (c *Controller) ServeHome(ctx *gin.Context) {
 // @Tags webpages
 // @Router /blog [get]
 func (c *Controller) ServeBlog(ctx *gin.Context) {
-	ctx.HTML(http.StatusOK, "writing", c.database.GetByCategory(storage.BLOG))
+	docs, err := c.htmlDocuments(storage.BLOG)
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	ctx.HTML(http.StatusOK, "writing", docs)
 }
 
 // @Name ServeCreative
@@ -102,7 +155,12 @@ func (c *Controller) ServeBlog(ctx *gin.Context) {
 // @Tags webpages
 // @Router /creative [get]
 func (c *Controller) ServeCreative(ctx *gin.Context) {
-	ctx.HTML(http.StatusOK, "writing", c.database.GetByCategory(storage.CREATIVE))
+	docs, err := c.htmlDocuments(storage.CREATIVE)
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	ctx.HTML(http.StatusOK, "writing", docs)
 }
 
 // @Name ServeDigitalArt
@@ -110,12 +168,21 @@ func (c *Controller) ServeCreative(ctx *gin.Context) {
 // @Tags webpages
 // @Router /digital [get]
 func (c *Controller) ServeDigitalArt(ctx *gin.Context) {
-	images := c.database.GetImagesByCategory(storage.DIGITAL_ART)
+	images, err := c.htmlImages(storage.DIGITAL_ART)
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
+	headers, menu, err := c.htmlNavigation()
+	if err != nil {
+		htmlListError(ctx, err)
+		return
+	}
 	ctx.HTML(http.StatusOK, "digital_art", gin.H{
 		"navigation": gin.H{
-			"headers": c.database.GetNavBarLinks(),
+			"headers": headers,
 		},
 		"images": images,
-		"menu":   c.database.GetDropdownElements(),
+		"menu":   menu,
 	})
 }

+ 131 - 0
pkg/controller/html_list_errors_test.go

@@ -0,0 +1,131 @@
+package controller
+
+import (
+	"errors"
+	"html/template"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+	"github.com/gin-gonic/gin"
+)
+
+type htmlLegacyRepo struct {
+	storage.DocumentIO
+	category string
+}
+
+func (r *htmlLegacyRepo) GetDocument(storage.Identifier) (storage.Document, error) {
+	return storage.Document{Title: "post"}, nil
+}
+func (r *htmlLegacyRepo) GetByCategory(category string) []storage.Document {
+	r.category = category
+	return nil
+}
+func (r *htmlLegacyRepo) GetImagesByCategory(category string) []storage.Image {
+	r.category = category
+	return nil
+}
+func (r *htmlLegacyRepo) GetNavBarLinks() []storage.NavBarItem    { return nil }
+func (r *htmlLegacyRepo) GetDropdownElements() []storage.LinkPair { return nil }
+
+type htmlErrorRepo struct {
+	*htmlLegacyRepo
+	storage.DocumentListReader
+	fail string
+}
+
+func (r *htmlErrorRepo) listError(name string) error {
+	if r.fail == name {
+		return errors.New("list failure: " + name)
+	}
+	return nil
+}
+func (r *htmlErrorRepo) GetByCategoryWithError(category string) ([]storage.Document, error) {
+	r.category = category
+	return nil, r.listError("documents")
+}
+func (r *htmlErrorRepo) GetImagesByCategoryWithError(category string) ([]storage.Image, error) {
+	r.category = category
+	return nil, r.listError("images")
+}
+func (r *htmlErrorRepo) GetNavBarLinksWithError() ([]storage.NavBarItem, error) {
+	return nil, r.listError("navbar")
+}
+func (r *htmlErrorRepo) GetDropdownElementsWithError() ([]storage.LinkPair, error) {
+	return nil, r.listError("menu")
+}
+
+func htmlTestRouter(repo storage.DocumentIO) *gin.Engine {
+	r := gin.New()
+	r.SetHTMLTemplate(template.Must(template.New("test").Parse(`{{define "home"}}{{.default.Body}}{{end}}{{define "writing"}}writing{{end}}{{define "digital_art"}}digital{{end}}{{define "blogpost"}}{{.Title}}{{end}}`)))
+	c := &Controller{database: repo}
+	r.GET("/", c.ServeHome)
+	r.GET("/blog", c.ServeBlog)
+	r.GET("/creative", c.ServeCreative)
+	r.GET("/digital", c.ServeDigitalArt)
+	r.GET("/writing/:id", c.ServePost)
+	return r
+}
+
+func TestHTMLListFailures(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, tc := range []struct{ path, failure string }{
+		{"/", "documents"}, {"/", "navbar"}, {"/", "menu"},
+		{"/blog", "documents"}, {"/creative", "documents"},
+		{"/digital", "images"}, {"/digital", "navbar"}, {"/digital", "menu"},
+		{"/writing/id", "navbar"}, {"/writing/id", "menu"},
+	} {
+		t.Run(tc.path+"/"+tc.failure, func(t *testing.T) {
+			repo := &htmlErrorRepo{htmlLegacyRepo: &htmlLegacyRepo{}, fail: tc.failure}
+			response := httptest.NewRecorder()
+			htmlTestRouter(repo).ServeHTTP(response, httptest.NewRequest(http.MethodGet, tc.path, nil))
+			if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), "Could not load content") {
+				t.Fatalf("got status %d body %q", response.Code, response.Body.String())
+			}
+		})
+	}
+}
+
+func TestHTMLListEmptyAndLegacyCompatibility(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, legacy := range []bool{false, true} {
+		for _, tc := range []struct{ path, category, body string }{
+			{"/", storage.HOMEPAGE, "Under construction. Sry :("},
+			{"/blog", storage.BLOG, "writing"}, {"/creative", storage.CREATIVE, "writing"},
+			{"/digital", storage.DIGITAL_ART, "digital"}, {"/writing/id", "", "post"},
+		} {
+			t.Run(tc.path+map[bool]string{true: "/legacy", false: "/error-reader"}[legacy], func(t *testing.T) {
+				base := &htmlLegacyRepo{}
+				var repo storage.DocumentIO = base
+				if !legacy {
+					repo = &htmlErrorRepo{htmlLegacyRepo: base}
+				}
+				response := httptest.NewRecorder()
+				htmlTestRouter(repo).ServeHTTP(response, httptest.NewRequest(http.MethodGet, tc.path, nil))
+				if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), tc.body) || base.category != tc.category {
+					t.Fatalf("got status %d body %q category %q", response.Code, response.Body.String(), base.category)
+				}
+			})
+		}
+	}
+}
+
+func (r *htmlErrorRepo) GetAssetsWithError() ([]storage.Asset, error) {
+	return nil, r.listError("assets")
+}
+func (r *htmlLegacyRepo) GetAssets() []storage.Asset { return nil }
+func TestAssetListFailureReturns500(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	repo := &htmlErrorRepo{htmlLegacyRepo: &htmlLegacyRepo{}, fail: "assets"}
+	c := &Controller{database: repo}
+	r.GET("/assets/:file", c.ServeAsset)
+	rec := httptest.NewRecorder()
+	r.ServeHTTP(rec, httptest.NewRequest("GET", "/assets/test.png", nil))
+	if rec.Code != http.StatusInternalServerError || !strings.Contains(rec.Body.String(), "Could not load content") {
+		t.Fatalf("%d %s", rec.Code, rec.Body.String())
+	}
+}

+ 256 - 0
pkg/controller/media_test.go

@@ -0,0 +1,256 @@
+package controller
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"html/template"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+	"github.com/gin-gonic/gin"
+)
+
+type mediaRepo struct {
+	storage.DocumentIO
+	image  storage.Image
+	images []storage.Image
+	err    error
+	added  bool
+}
+
+func (r *mediaRepo) AddImage(data []byte, title, desc, category, mediaType string) (storage.Identifier, error) {
+	r.added = true
+	r.image = storage.Image{Data: data, Title: title, Desc: desc, Category: category, MediaType: mediaType}
+	return "id", r.err
+}
+func (r *mediaRepo) GetImage(id storage.Identifier) (storage.Image, error) { return r.image, r.err }
+func (r *mediaRepo) GetAllImages() []storage.Image                         { return r.images }
+func (r *mediaRepo) GetDropdownElements() []storage.LinkPair               { return nil }
+func (r *mediaRepo) GetNavBarLinks() []storage.NavBarItem                  { return nil }
+func (r *mediaRepo) GetDocument(id storage.Identifier) (storage.Document, error) {
+	return storage.Document{Ident: id}, r.err
+}
+
+func testRouter(repo *mediaRepo) *gin.Engine {
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	r.SetHTMLTemplate(template.Must(template.New("test").Parse(`{{define "upload_status"}}{{.UpdateMessage}}{{end}}{{define "blogpost_editor"}}{{range .Images}}{{.Ident}}:{{.MediaType}}:{{.Title}}{{end}}{{end}}`)))
+	c := &Controller{database: repo}
+	r.POST("/upload", c.SaveFile)
+	r.GET("/images/:file", c.ServeImage)
+	r.GET("/new", c.ServeNewBlogPage)
+	r.GET("/edit/:id", c.GetBlogPostEditor)
+	return r
+}
+
+func TestDetectMediaType(t *testing.T) {
+	webm := []byte{0x1a, 0x45, 0xdf, 0xa3, 0x87, 0x42, 0x82, 0x84, 'w', 'e', 'b', 'm'}
+	cases := []struct {
+		name string
+		data []byte
+		want string
+	}{
+		{"jpeg", []byte{0xff, 0xd8, 0xff, 0xdb}, "image/jpeg"},
+		{"png", []byte("\x89PNG\r\n\x1a\n"), "image/png"},
+		{"gif", []byte("GIF89a"), "image/gif"},
+		{"webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 "), "image/webp"},
+		{"webm", webm, "video/webm"},
+		{"matroska", []byte("\x1a\x45\xdf\xa3\x8b\x42\x82\x88matroska"), ""},
+		{"fake webm", []byte("\x1a\x45\xdf\xa3\x87\x42\x83\x84webm"), ""},
+		{"truncated", webm[:len(webm)-1], ""}, {"unknown", []byte("text"), ""},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := detectMediaType(tc.data); got != tc.want {
+				t.Fatalf("got %q, want %q", got, tc.want)
+			}
+		})
+	}
+}
+
+func upload(t *testing.T, r http.Handler, data []byte, filename string) *httptest.ResponseRecorder {
+	t.Helper()
+	var b bytes.Buffer
+	w := multipart.NewWriter(&b)
+	for k, v := range map[string]string{"title": "title", "description": "desc", "category": "art"} {
+		if err := w.WriteField(k, v); err != nil {
+			t.Fatal(err)
+		}
+	}
+	part, err := w.CreateFormFile("file", filename)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err = part.Write(data); err != nil {
+		t.Fatal(err)
+	}
+	w.Close()
+	req := httptest.NewRequest("POST", "/upload", &b)
+	req.Header.Set("Content-Type", w.FormDataContentType())
+	res := httptest.NewRecorder()
+	r.ServeHTTP(res, req)
+	return res
+}
+func TestUpload(t *testing.T) {
+	r := &mediaRepo{}
+	router := testRouter(r)
+	data := []byte("GIF89ahello")
+	res := upload(t, router, data, "wrong.jpg")
+	if res.Code != 200 || !r.added || r.image.MediaType != "image/gif" || !bytes.Equal(r.image.Data, data) || r.image.Title != "title" || r.image.Desc != "desc" || r.image.Category != "art" {
+		t.Fatalf("status %d image %+v", res.Code, r.image)
+	}
+	r.added = false
+	res = upload(t, router, []byte("not an image"), "image.png")
+	if res.Code != 415 || r.added {
+		t.Fatalf("unknown: %d", res.Code)
+	}
+	res = upload(t, router, make([]byte, maxUploadSize+1), "huge.png")
+	if res.Code != 413 || r.added {
+		t.Fatalf("large: %d", res.Code)
+	}
+	r.err = errors.New("db error")
+	res = upload(t, router, data, "x.gif")
+	if res.Code != 500 {
+		t.Fatalf("db error: %d", res.Code)
+	}
+}
+func TestServeImage(t *testing.T) {
+	repo := &mediaRepo{image: storage.Image{Data: []byte("0123456789"), MediaType: "video/webm"}}
+	router := testRouter(repo)
+	req := httptest.NewRequest("GET", "/images/abc", nil)
+	req.Header.Set("Range", "bytes=2-5")
+	res := httptest.NewRecorder()
+	router.ServeHTTP(res, req)
+	if res.Code != 206 || res.Body.String() != "2345" || res.Header().Get("Content-Type") != "video/webm" || res.Header().Get("Content-Range") != "bytes 2-5/10" {
+		t.Fatalf("range: %d %s %v", res.Code, res.Body.String(), res.Header())
+	}
+	for _, tc := range []struct {
+		err    error
+		status int
+	}{{storage.ErrNotExists, 404}, {errors.New("storage failure"), 500}} {
+		repo.err = tc.err
+		res = httptest.NewRecorder()
+		router.ServeHTTP(res, httptest.NewRequest("GET", "/images/abc", nil))
+		if res.Code != tc.status {
+			t.Fatalf("%v: %d", tc.err, res.Code)
+		}
+	}
+}
+func TestEditorsIncludeImages(t *testing.T) {
+	repo := &mediaRepo{images: []storage.Image{{Ident: "abc", MediaType: "video/webm", Title: "Movie"}}}
+	router := testRouter(repo)
+	for _, path := range []string{"/new", "/edit/post"} {
+		t.Run(path, func(t *testing.T) {
+			res := httptest.NewRecorder()
+			router.ServeHTTP(res, httptest.NewRequest("GET", path, nil))
+			if res.Code != 200 || !strings.Contains(res.Body.String(), fmt.Sprintf("%s:%s:%s", "abc", "video/webm", "Movie")) {
+				t.Fatalf("%d %s", res.Code, res.Body.String())
+			}
+		})
+	}
+}
+
+func TestDigitalArtAdminHandlers(t *testing.T) {
+	repo := &digitalAdminRepo{mediaRepo: mediaRepo{image: storage.Image{Ident: "id", Category: storage.DIGITAL_ART, Title: "old"}}}
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	r.SetHTMLTemplate(template.Must(template.New("test").Parse(`{{define "admin"}}{{range .Tables.modify}}{{.DisplayName}} {{.Link}}{{end}}{{end}}{{define "digital_art_admin_list"}}{{range .Images}}{{.Title}}{{end}}{{end}}{{define "digital_art_admin_editor"}}{{.Title}}{{end}}{{define "upload_status"}}{{.UpdateMessage}}{{end}}`)))
+	c := &Controller{database: repo}
+	r.GET("/panel", c.AdminPanel)
+	r.GET("/digital", c.ListDigitalArt)
+	r.GET("/digital/:id", c.EditDigitalArt)
+	r.POST("/digital/:id", c.UpdateDigitalArt)
+	for _, path := range []string{"/panel", "/digital", "/digital/id"} {
+		res := httptest.NewRecorder()
+		r.ServeHTTP(res, httptest.NewRequest("GET", path, nil))
+		if res.Code != 200 {
+			t.Fatalf("%s: %d", path, res.Code)
+		}
+		if path == "/panel" && !strings.Contains(res.Body.String(), "/admin/digital") {
+			t.Fatal("admin entry missing")
+		}
+	}
+	req := httptest.NewRequest("POST", "/digital/id", strings.NewReader("title=New+title&description=New+description"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	res := httptest.NewRecorder()
+	r.ServeHTTP(res, req)
+	if res.Code != 200 || repo.title != "New title" || repo.desc != "New description" {
+		t.Fatalf("update: %d %q %q", res.Code, repo.title, repo.desc)
+	}
+	req = httptest.NewRequest("POST", "/digital/id", strings.NewReader("title=+"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	res = httptest.NewRecorder()
+	r.ServeHTTP(res, req)
+	if res.Code != 400 {
+		t.Fatalf("blank title: %d", res.Code)
+	}
+	repo.image.Category = storage.MISC_ASSET
+	res = httptest.NewRecorder()
+	r.ServeHTTP(res, httptest.NewRequest("GET", "/digital/id", nil))
+	if res.Code != 404 {
+		t.Fatalf("non-digital: %d", res.Code)
+	}
+	repo.updateErr = storage.ErrNotExists
+	req = httptest.NewRequest("POST", "/digital/id", strings.NewReader("title=Title"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	res = httptest.NewRecorder()
+	r.ServeHTTP(res, req)
+	if res.Code != 404 {
+		t.Fatalf("missing update: %d", res.Code)
+	}
+}
+
+type digitalAdminRepo struct {
+	mediaRepo
+	title, desc string
+	updateErr   error
+}
+
+func (r *digitalAdminRepo) GetAdminTables() storage.AdminPage {
+	return storage.AdminPage{Tables: map[string][]storage.TableData{}}
+}
+func (r *digitalAdminRepo) GetImagesByCategory(category string) []storage.Image {
+	if category == storage.DIGITAL_ART {
+		return []storage.Image{r.image}
+	}
+	return nil
+}
+func (r *digitalAdminRepo) UpdateDigitalArt(id storage.Identifier, title, desc string) error {
+	r.title = title
+	r.desc = desc
+	return r.updateErr
+}
+
+func TestRemoveDigitalArt(t *testing.T) {
+	repo := &archiveTestRepo{}
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	r.SetHTMLTemplate(template.Must(template.New("test").Parse(`{{define "upload_status"}}{{.UpdateMessage}}{{end}}`)))
+	c := &Controller{database: repo}
+	r.DELETE("/digital/:id", c.RemoveDigitalArt)
+	for _, tc := range []struct {
+		err     error
+		status  int
+		message string
+	}{{nil, 200, "Listing removed"}, {storage.ErrNotExists, 404, "Digital art not found"}, {errors.New("db failure"), 500, "Could not remove"}} {
+		repo.err = tc.err
+		rec := httptest.NewRecorder()
+		r.ServeHTTP(rec, httptest.NewRequest("DELETE", "/digital/art123", nil))
+		if rec.Code != tc.status || repo.id != "art123" || !strings.Contains(rec.Body.String(), tc.message) {
+			t.Fatalf("got %d %q id=%q", rec.Code, rec.Body.String(), repo.id)
+		}
+	}
+}
+
+type archiveTestRepo struct {
+	storage.DocumentIO
+	id  storage.Identifier
+	err error
+}
+
+func (r *archiveTestRepo) ArchiveDigitalArt(id storage.Identifier) error { r.id = id; return r.err }

+ 77 - 0
pkg/controller/media_type.go

@@ -0,0 +1,77 @@
+package controller
+
+import (
+	"bytes"
+	"net/http"
+)
+
+// detectMediaType trusts bytes rather than the filename or multipart MIME header.
+func detectMediaType(data []byte) string {
+	switch http.DetectContentType(data) {
+	case "image/jpeg", "image/png", "image/gif", "image/webp":
+		return http.DetectContentType(data)
+	}
+	if isWebM(data) {
+		return "video/webm"
+	}
+	return ""
+}
+
+// isWebM parses the EBML header and requires an explicit WebM DocType. Merely
+// finding the word "webm" or an EBML magic number also admits Matroska files.
+func isWebM(data []byte) bool {
+	if len(data) < 5 || !bytes.Equal(data[:4], []byte{0x1a, 0x45, 0xdf, 0xa3}) {
+		return false
+	}
+	size, n, ok := ebmlSize(data[4:])
+	if !ok || size > uint64(len(data)-4-n) {
+		return false
+	}
+	header := data[4+n : 4+n+int(size)]
+	found := false
+	for len(header) > 0 {
+		// EBML element IDs retain their leading marker bit.
+		_, idLen, valid := ebmlSize(header)
+		if !valid || idLen > 4 || len(header) <= idLen {
+			return false
+		}
+		id := header[:idLen]
+		length, lenLen, valid := ebmlSize(header[idLen:])
+		if !valid || length > uint64(len(header)-idLen-lenLen) {
+			return false
+		}
+		value := header[idLen+lenLen : idLen+lenLen+int(length)]
+		if bytes.Equal(id, []byte{0x42, 0x82}) {
+			if found || !bytes.Equal(value, []byte("webm")) {
+				return false
+			}
+			found = true
+		}
+		header = header[idLen+lenLen+int(length):]
+	}
+	return found
+}
+
+// ebmlSize reads a bounded EBML variable-length integer. Unknown sizes are
+// rejected because the header and its children must have finite lengths.
+func ebmlSize(b []byte) (uint64, int, bool) {
+	if len(b) == 0 || b[0] == 0 {
+		return 0, 0, false
+	}
+	mask, n := byte(0x80), 1
+	for b[0]&mask == 0 {
+		mask >>= 1
+		n++
+	}
+	if n > len(b) {
+		return 0, 0, false
+	}
+	value := uint64(b[0] & (mask - 1))
+	for _, v := range b[1:n] {
+		value = value<<8 | uint64(v)
+	}
+	if value == (uint64(1)<<(7*n))-1 {
+		return 0, 0, false
+	}
+	return value, n, true
+}

+ 125 - 6
pkg/controller/middleware.go

@@ -1,20 +1,139 @@
 package controller
 
 import (
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
 	"github.com/gin-gonic/gin"
 )
 
 func (c *Controller) IsAuthenticated(ctx *gin.Context) {
 	cookie, err := ctx.Cookie(AUTH_COOKIE_NAME)
-	if err != nil {
-		ctx.Redirect(302, "/login")
-		ctx.AbortWithStatus(401)
+	if err != nil || !c.Cache.Read(cookie) {
+		ctx.Redirect(http.StatusFound, "/login")
+		ctx.Abort()
+		return
+	}
+	ctx.Next()
+}
+
+// insecureCookies is solely for local HTTP development. Production must use HTTPS.
+func insecureCookies() bool { return os.Getenv("KEIJI_INSECURE_COOKIE") == "true" }
+
+// SameOrigin protects unsafe browser requests, including ordinary forms and HTMX.
+// Non-browser clients must supply an Origin matching the public request URL.
+func SameOrigin(ctx *gin.Context) {
+	switch ctx.Request.Method {
+	case http.MethodGet, http.MethodHead, http.MethodOptions:
+		ctx.Next()
 		return
 	}
-	if !c.Cache.Read(cookie) {
-		ctx.Redirect(302, "/login")
-		ctx.AbortWithStatus(401)
+	source := ctx.GetHeader("Origin")
+	origin := source != ""
+	if !origin {
+		source = ctx.GetHeader("Referer")
+	}
+	u, err := url.Parse(source)
+	scheme := "https"
+	if insecureCookies() {
+		scheme = "http"
+	}
+	if err != nil || u == nil || u.User != nil || u.Opaque != "" ||
+		!strings.EqualFold(u.Scheme, scheme) || !strings.EqualFold(u.Host, ctx.Request.Host) ||
+		u.Host == "" || (origin && (u.Path != "" || u.RawQuery != "" || u.Fragment != "")) {
+		ctx.AbortWithStatus(http.StatusForbidden)
 		return
 	}
 	ctx.Next()
 }
+
+type loginWindow struct {
+	count int
+	until time.Time
+}
+
+// LoginRateLimit limits all attempts, not just failures, so concurrent attempts
+// cannot race a post-authentication counter. State is local to this process.
+func LoginRateLimit() gin.HandlerFunc {
+	var mu sync.Mutex
+	windows := make(map[string]loginWindow)
+	var proxies []*net.IPNet
+	for _, value := range strings.Split(os.Getenv("KEIJI_TRUSTED_PROXIES"), ",") {
+		value = strings.TrimSpace(value)
+		if ip := net.ParseIP(value); ip != nil {
+			bits := 128
+			if ip.To4() != nil {
+				ip = ip.To4()
+				bits = 32
+			}
+			proxies = append(proxies, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
+		} else if _, network, err := net.ParseCIDR(value); err == nil {
+			proxies = append(proxies, network)
+		}
+	}
+	trusted := func(ip net.IP) bool {
+		for _, network := range proxies {
+			if network.Contains(ip) {
+				return true
+			}
+		}
+		return false
+	}
+	return func(ctx *gin.Context) {
+		peer, _, err := net.SplitHostPort(ctx.Request.RemoteAddr)
+		if err != nil {
+			peer = ctx.Request.RemoteAddr
+		}
+		ip := net.ParseIP(peer)
+		if ip != nil && trusted(ip) {
+			// Walk from the trusted peer toward the client, stopping at the first
+			// untrusted hop. Never accept forwarding headers from an untrusted peer.
+			parts := strings.Split(ctx.GetHeader("X-Forwarded-For"), ",")
+			for i := len(parts) - 1; i >= 0 && trusted(ip); i-- {
+				next := net.ParseIP(strings.TrimSpace(parts[i]))
+				if next == nil {
+					break
+				}
+				ip = next
+			}
+		}
+		key := "unknown"
+		if ip != nil {
+			key = ip.String()
+		}
+		now := time.Now()
+		mu.Lock()
+		w := windows[key]
+		if !now.Before(w.until) {
+			w = loginWindow{until: now.Add(10 * time.Minute)}
+		}
+		if w.count >= 10 {
+			mu.Unlock()
+			ctx.Header("Retry-After", "600")
+			ctx.AbortWithStatus(http.StatusTooManyRequests)
+			return
+		}
+		if _, exists := windows[key]; !exists && len(windows) >= 10000 {
+			for k, old := range windows {
+				if !now.Before(old.until) {
+					delete(windows, k)
+				}
+			}
+			if len(windows) >= 10000 {
+				mu.Unlock()
+				ctx.Header("Retry-After", "600")
+				ctx.AbortWithStatus(http.StatusTooManyRequests)
+				return
+			}
+		}
+		w.count++
+		windows[key] = w
+		mu.Unlock()
+		ctx.Next()
+	}
+}

+ 171 - 0
pkg/controller/security_test.go

@@ -0,0 +1,171 @@
+package controller
+
+import (
+	"html/template"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"git.aetherial.dev/aeth/keiji/pkg/auth"
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+	"github.com/gin-gonic/gin"
+)
+
+type securitySource struct{}
+
+func (securitySource) AdminUsername() string { return "admin" }
+func (securitySource) AdminPassword() string { return "password" }
+
+type securityRepo struct{ storage.DocumentIO }
+
+func (securityRepo) GetAdminTables() storage.AdminPage       { return storage.AdminPage{} }
+func (securityRepo) GetNavBarLinks() []storage.NavBarItem    { return nil }
+func (securityRepo) GetDropdownElements() []storage.LinkPair { return nil }
+
+func TestAuthCookieSecurity(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, insecure := range []bool{false, true} {
+		t.Run(map[bool]string{false: "production", true: "development"}[insecure], func(t *testing.T) {
+			if insecure {
+				t.Setenv("KEIJI_INSECURE_COOKIE", "true")
+			} else {
+				t.Setenv("KEIJI_INSECURE_COOKIE", "")
+			}
+			c := NewController("example.com", securityRepo{}, nil, securitySource{})
+			r := gin.New()
+			r.SetHTMLTemplate(template.Must(template.New("admin").Parse(`{{define "admin"}}ok{{end}}`)))
+			r.POST("/login", c.Auth)
+			req := httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"admin","password":"password"}`))
+			req.Header.Set("Content-Type", "application/json")
+			rec := httptest.NewRecorder()
+			r.ServeHTTP(rec, req)
+			cookies := rec.Result().Cookies()
+			if rec.Code != 200 || len(cookies) != 1 {
+				t.Fatalf("status %d cookies %v", rec.Code, cookies)
+			}
+			cookie := cookies[0]
+			if cookie.Name != AUTH_COOKIE_NAME || cookie.Domain != "" || cookie.Path != "/" || cookie.Secure == insecure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.MaxAge != 1200 || !c.Cache.Read(cookie.Value) {
+				t.Fatalf("bad cookie: %+v", cookie)
+			}
+		})
+	}
+}
+
+func TestSameOrigin(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	t.Setenv("KEIJI_INSECURE_COOKIE", "")
+	r := gin.New()
+	r.Any("/", SameOrigin, func(c *gin.Context) { c.Status(204) })
+	for _, tc := range []struct {
+		method, origin, referer string
+		want                    int
+	}{
+		{"GET", "", "", 204}, {"POST", "https://example.com", "", 204},
+		{"PATCH", "", "https://example.com/edit?q=x", 204}, {"DELETE", "https://evil.com", "https://example.com/", 403},
+		{"POST", "null", "", 403}, {"POST", "", "", 403}, {"POST", "http://example.com", "", 403},
+		{"POST", "https://example.com.evil.com", "", 403}, {"POST", "https://example.com/path", "", 403},
+		{"POST", "https://example.com:444", "", 403},
+	} {
+		req := httptest.NewRequest(tc.method, "https://example.com/", nil)
+		req.Header.Set("Origin", tc.origin)
+		req.Header.Set("Referer", tc.referer)
+		rec := httptest.NewRecorder()
+		r.ServeHTTP(rec, req)
+		if rec.Code != tc.want {
+			t.Errorf("%s %q %q: %d want %d", tc.method, tc.origin, tc.referer, rec.Code, tc.want)
+		}
+	}
+	t.Setenv("KEIJI_INSECURE_COOKIE", "true")
+	req := httptest.NewRequest("POST", "http://example.com/", nil)
+	req.Header.Set("Origin", "http://example.com")
+	rec := httptest.NewRecorder()
+	r.ServeHTTP(rec, req)
+	if rec.Code != 204 {
+		t.Fatal(rec.Code)
+	}
+}
+
+func TestLoginRateLimitProxy(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	for _, trusted := range []bool{false, true} {
+		t.Run(map[bool]string{false: "untrusted", true: "trusted"}[trusted], func(t *testing.T) {
+			if trusted {
+				t.Setenv("KEIJI_TRUSTED_PROXIES", "10.0.0.0/8")
+			} else {
+				t.Setenv("KEIJI_TRUSTED_PROXIES", "")
+			}
+			r := gin.New()
+			r.POST("/", LoginRateLimit(), func(c *gin.Context) { c.Status(204) })
+			request := func(remote, xff string) int {
+				req := httptest.NewRequest("POST", "/", nil)
+				req.RemoteAddr = remote
+				req.Header.Set("X-Forwarded-For", xff)
+				rec := httptest.NewRecorder()
+				r.ServeHTTP(rec, req)
+				return rec.Code
+			}
+			for i := 0; i < 10; i++ {
+				if got := request("10.0.0.1:123", "1.1.1.1"); got != 204 {
+					t.Fatal(got)
+				}
+			}
+			if got := request("10.0.0.1:123", "1.1.1.1"); got != 429 {
+				t.Fatal(got)
+			}
+			want := 429
+			if trusted {
+				want = 204
+			}
+			if got := request("10.0.0.1:123", "2.2.2.2"); got != want {
+				t.Fatalf("got %d want %d", got, want)
+			}
+			if trusted {
+				if got := request("10.0.0.1:123", "2.2.2.2, 1.1.1.1"); got != 429 {
+					t.Fatalf("spoofed chain: %d", got)
+				}
+			}
+		})
+	}
+}
+
+func TestAuthenticationStopsHandler(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	c := &Controller{Cache: auth.NewCache()}
+	r := gin.New()
+	r.GET("/admin", c.IsAuthenticated, func(c *gin.Context) { c.Status(204) })
+	rec := httptest.NewRecorder()
+	r.ServeHTTP(rec, httptest.NewRequest("GET", "/admin", nil))
+	if rec.Code != 302 || rec.Header().Get("Location") != "/login" {
+		t.Fatalf("%d %v", rec.Code, rec.Header())
+	}
+}
+
+func TestAuthenticatedAdminRequiresOriginForMutation(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	t.Setenv("KEIJI_INSECURE_COOKIE", "")
+	c := &Controller{Cache: auth.NewCache()}
+	session, err := auth.Authorize(&auth.Credentials{Username: "admin", Password: "password"}, c.Cache, securitySource{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	r := gin.New()
+	group := r.Group("/admin", c.IsAuthenticated, SameOrigin)
+	group.GET("/test", func(c *gin.Context) { c.Status(204) })
+	group.POST("/test", func(c *gin.Context) { c.Status(204) })
+	group.PATCH("/test", func(c *gin.Context) { c.Status(204) })
+	group.DELETE("/test", func(c *gin.Context) { c.Status(204) })
+	for _, tc := range []struct {
+		method, origin string
+		want           int
+	}{{"GET", "", 204}, {"POST", "", 403}, {"POST", "https://evil.example", 403}, {"POST", "https://example.com", 204}, {"PATCH", "https://example.com", 204}, {"DELETE", "https://example.com", 204}} {
+		req := httptest.NewRequest(tc.method, "https://example.com/admin/test", nil)
+		req.AddCookie(&http.Cookie{Name: AUTH_COOKIE_NAME, Value: session})
+		req.Header.Set("Origin", tc.origin)
+		rec := httptest.NewRecorder()
+		r.ServeHTTP(rec, req)
+		if rec.Code != tc.want {
+			t.Errorf("%s origin %q: got %d want %d", tc.method, tc.origin, rec.Code, tc.want)
+		}
+	}
+}

+ 11 - 0
pkg/env/env.go

@@ -20,8 +20,19 @@ const HOST_ADDR = "HOST_ADDR"
 const USE_SSL = "USE_SSL"
 const CHAIN = "CHAIN"
 const KEY = "KEY"
+const SQLITE_PATH = "SQLITE_PATH"
+
+// SQLitePath returns the configured database path, preserving the historical
+// working-directory default when no path is configured.
+func SQLitePath() string {
+	if p := os.Getenv(SQLITE_PATH); p != "" {
+		return p
+	}
+	return "sqlite.db"
+}
 
 var OPTION_VARS = map[string]string{
+	SQLITE_PATH: "#SQLite database path (default: sqlite.db in the working directory)",
 	IMAGE_STORE: "#the location for keiji to store the images uploaded (string)",
 	WEB_ROOT:    "#the location to pull HTML and various web assets from. Only if using 'keiji -content fs' (string)",
 	CHAIN:       "#the path to the SSL public key chain (string)",

+ 12 - 0
pkg/env/env_test.go

@@ -42,6 +42,7 @@ func TestWriteTemplate(t *testing.T) {
 		USE_SSL,
 		CHAIN,
 		KEY,
+		SQLITE_PATH,
 	}
 	for i := range want {
 
@@ -51,3 +52,14 @@ func TestWriteTemplate(t *testing.T) {
 	}
 
 }
+
+func TestSQLitePath(t *testing.T) {
+	t.Setenv(SQLITE_PATH, "")
+	if got := SQLitePath(); got != "sqlite.db" {
+		t.Fatalf("default SQLite path = %q", got)
+	}
+	t.Setenv(SQLITE_PATH, "/data/keiji.db")
+	if got := SQLitePath(); got != "/data/keiji.db" {
+		t.Fatalf("configured SQLite path = %q", got)
+	}
+}

+ 6 - 2
pkg/routes/register.go

@@ -18,7 +18,7 @@ func Register(e *gin.Engine, domain string, database storage.DocumentIO, files f
 	web.GET("/creative", c.ServeCreative)
 	web.GET("/writing/:id", c.ServePost)
 	web.GET("/login", c.ServeLogin)
-	web.POST("/login", c.Auth)
+	web.POST("/login", controller.LoginRateLimit(), controller.SameOrigin, c.Auth)
 
 	cdn := e.Group("/api/v1")
 	cdn.GET("/images/:file", c.ServeImage)
@@ -26,11 +26,15 @@ func Register(e *gin.Engine, domain string, database storage.DocumentIO, files f
 	cdn.GET("assets/:file", c.ServeAsset)
 
 	priv := e.Group("/admin")
-	priv.Use(c.IsAuthenticated)
+	priv.Use(c.IsAuthenticated, controller.SameOrigin)
 	priv.GET("/upload", c.ServeFileUpload)
 	priv.POST("/upload", c.SaveFile)
 	priv.POST("/asset", c.AddAsset)
 	priv.GET("/panel", c.AdminPanel)
+	priv.GET("/digital", c.ListDigitalArt)
+	priv.GET("/digital/:id", c.EditDigitalArt)
+	priv.POST("/digital/:id", c.UpdateDigitalArt)
+	priv.DELETE("/digital/:id", c.RemoveDigitalArt)
 	priv.POST("/panel", c.AddAdminTableEntry)
 	priv.POST("/menu", c.AddMenuItem)
 	priv.POST("/navbar", c.AddNavbarItem)

+ 34 - 0
pkg/routes/register_test.go

@@ -1,6 +1,7 @@
 package routes
 
 import (
+	"net/http/httptest"
 	"testing"
 
 	"git.aetherial.dev/aeth/keiji/pkg/auth"
@@ -13,3 +14,36 @@ func TestRegister(t *testing.T) {
 	e := gin.Default()
 	Register(e, "localhost", &storage.SQLiteRepo{}, webpages.FilesystemWebpages{}, auth.EnvAuth{})
 }
+
+func TestDigitalArtRoutesRequireAuthentication(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	e := gin.New()
+	Register(e, "localhost", &storage.SQLiteRepo{}, webpages.FilesystemWebpages{}, auth.EnvAuth{})
+	for _, tc := range []struct{ method, path string }{{"GET", "/admin/digital"}, {"GET", "/admin/digital/123"}, {"POST", "/admin/digital/123"}, {"DELETE", "/admin/digital/123"}} {
+		rec := httptest.NewRecorder()
+		e.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
+		if rec.Code != 401 && (rec.Code < 300 || rec.Code >= 400) {
+			t.Fatalf("%s %s: expected redirect or unauthorized, got %d", tc.method, tc.path, rec.Code)
+		}
+	}
+}
+
+func TestLoginSecurityMiddlewareWired(t *testing.T) {
+	t.Setenv("KEIJI_INSECURE_COOKIE", "")
+	t.Setenv("KEIJI_TRUSTED_PROXIES", "")
+	gin.SetMode(gin.TestMode)
+	e := gin.New()
+	Register(e, "localhost", &storage.SQLiteRepo{}, webpages.FilesystemWebpages{}, auth.EnvAuth{})
+	for i := 0; i < 11; i++ {
+		req := httptest.NewRequest("POST", "https://example.com/login", nil)
+		rec := httptest.NewRecorder()
+		e.ServeHTTP(rec, req)
+		want := 403
+		if i == 10 {
+			want = 429
+		}
+		if rec.Code != want {
+			t.Fatalf("attempt %d: got %d, want %d", i+1, rec.Code, want)
+		}
+	}
+}

+ 115 - 0
pkg/storage/digital_art_test.go

@@ -0,0 +1,115 @@
+package storage
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestDigitalArtMetadataUpdate(t *testing.T) {
+	dir := t.TempDir()
+	repo, db := newTestDb(dir, true)
+	defer db.Close()
+	id, err := repo.AddImage([]byte("image bytes"), "old", "old desc", DIGITAL_ART, "image/png")
+	require.NoError(t, err)
+	other, err := repo.AddImage([]byte("other"), "other", "desc", MISC_ASSET, "image/jpeg")
+	require.NoError(t, err)
+	before, err := repo.GetImage(id)
+	require.NoError(t, err)
+	require.NoError(t, repo.UpdateDigitalArt(id, "new", "new desc"))
+	after, err := repo.GetImage(id)
+	require.NoError(t, err)
+	require.Equal(t, "new", after.Title)
+	require.Equal(t, "new desc", after.Desc)
+	require.Equal(t, before.Data, after.Data)
+	require.Equal(t, before.Created, after.Created)
+	require.Equal(t, before.MediaType, after.MediaType)
+	require.Equal(t, before.Category, after.Category)
+	require.ErrorIs(t, repo.UpdateDigitalArt(other, "bad", "bad"), ErrNotExists)
+	otherAfter, err := repo.GetImage(other)
+	require.NoError(t, err)
+	require.Equal(t, "other", otherAfter.Title)
+	require.Equal(t, "desc", otherAfter.Desc)
+	require.ErrorIs(t, repo.UpdateDigitalArt("missing", "x", "y"), ErrNotExists)
+}
+
+func TestDigitalArtUpdateSafety(t *testing.T) {
+	dir := t.TempDir()
+	repo, db := newTestDb(dir, true)
+	defer db.Close()
+	id, err := repo.AddImage([]byte("preserve"), "art", "desc", DIGITAL_ART, "image/png")
+	require.NoError(t, err)
+	// A duplicate ID can share the same file, even across categories.
+	_, err = db.Exec("INSERT INTO images (id,title,desc,created,category) VALUES (?, 'other', '', '', ?)", id, MISC_ASSET)
+	require.NoError(t, err)
+	require.ErrorIs(t, repo.UpdateDigitalArt(id, "bad", "bad"), ErrNotExists)
+	data, err := os.ReadFile(filepath.Join(dir, string(id)))
+	require.NoError(t, err)
+	require.Equal(t, []byte("preserve"), data)
+	var count int
+	require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM images WHERE id = ?", id).Scan(&count))
+	require.Equal(t, 2, count)
+	for _, bad := range []Identifier{"", ".", "..", "../outside", "nested/name", `..\outside`} {
+		require.Error(t, repo.UpdateDigitalArt(bad, "x", "y"))
+	}
+}
+
+func TestArchiveDigitalArtPreservesMedia(t *testing.T) {
+	dir := t.TempDir()
+	repo, db := newTestDb(dir, true)
+	defer db.Close()
+	id, err := repo.AddImage([]byte("image bytes"), "art", "description", DIGITAL_ART, "image/png")
+	require.NoError(t, err)
+	before, err := repo.GetImage(id)
+	require.NoError(t, err)
+
+	require.NoError(t, repo.ArchiveDigitalArt(id))
+	after, err := repo.GetImage(id)
+	require.NoError(t, err)
+	before.Category = MISC_ASSET
+	require.Equal(t, before, after)
+	data, err := os.ReadFile(filepath.Join(dir, string(id)))
+	require.NoError(t, err)
+	require.Equal(t, []byte("image bytes"), data)
+	art, err := repo.GetImagesByCategoryWithError(DIGITAL_ART)
+	require.NoError(t, err)
+	require.Empty(t, art)
+	misc, err := repo.GetImagesByCategoryWithError(MISC_ASSET)
+	require.NoError(t, err)
+	require.Len(t, misc, 1)
+	require.Equal(t, id, misc[0].Ident)
+	require.ErrorIs(t, repo.ArchiveDigitalArt(id), ErrNotExists)
+	require.ErrorIs(t, repo.ArchiveDigitalArt("missing"), ErrNotExists)
+}
+
+func TestArchiveDigitalArtSafety(t *testing.T) {
+	dir := t.TempDir()
+	repo, db := newTestDb(dir, true)
+	defer db.Close()
+	other, err := repo.AddImage([]byte("other"), "other", "desc", MISC_ASSET, "image/jpeg")
+	require.NoError(t, err)
+	require.ErrorIs(t, repo.ArchiveDigitalArt(other), ErrNotExists)
+	id, err := repo.AddImage([]byte("preserve"), "art", "desc", DIGITAL_ART, "image/png")
+	require.NoError(t, err)
+	_, err = db.Exec("INSERT INTO images (id,title,desc,created,category) VALUES (?, 'other', '', '', ?)", id, MISC_ASSET)
+	require.NoError(t, err)
+	require.ErrorIs(t, repo.ArchiveDigitalArt(id), ErrNotExists)
+	var artCount, total int
+	require.NoError(t, db.QueryRow("SELECT COUNT(*), COUNT(CASE WHEN category = ? THEN 1 END) FROM images WHERE id = ?", DIGITAL_ART, id).Scan(&total, &artCount))
+	require.Equal(t, 2, total)
+	require.Equal(t, 1, artCount)
+	data, err := os.ReadFile(filepath.Join(dir, string(id)))
+	require.NoError(t, err)
+	require.Equal(t, []byte("preserve"), data)
+	for _, bad := range []Identifier{"", ".", "..", "../outside", "nested/name", `..\outside`} {
+		require.Error(t, repo.ArchiveDigitalArt(bad))
+	}
+}
+
+func TestArchiveDigitalArtDatabaseFailure(t *testing.T) {
+	repo, db := newTestDb(t.TempDir(), true)
+	require.NoError(t, db.Close())
+	require.Error(t, repo.ArchiveDigitalArt("valid-id"))
+}

+ 75 - 0
pkg/storage/list_errors_test.go

@@ -0,0 +1,75 @@
+package storage
+
+import (
+	"database/sql"
+	"testing"
+
+	_ "github.com/mattn/go-sqlite3"
+)
+
+// Every list operation must report query and scan failures without terminating
+// the process or leaking the single database connection.
+func TestListOperationsReportErrors(t *testing.T) {
+	tests := []struct {
+		name, table, schema string
+		call                func(*SQLiteRepo) error
+	}{
+		{"menu", "menu", "id INTEGER, link TEXT, text TEXT, extra TEXT", func(r *SQLiteRepo) error { _, e := r.GetDropdownElementsWithError(); return e }},
+		{"navbar", "navbar", "id INTEGER, png BLOB, link TEXT, redirect TEXT, extra TEXT", func(r *SQLiteRepo) error { _, e := r.GetNavBarLinksWithError(); return e }},
+		{"assets", "assets", "id INTEGER, name TEXT, data BLOB, extra TEXT", func(r *SQLiteRepo) error { _, e := r.GetAssetsWithError(); return e }},
+		{"admin", "admin", "id INTEGER, display_name TEXT, link TEXT, category TEXT, extra TEXT", func(r *SQLiteRepo) error { _, e := r.GetAdminTablesWithError(); return e }},
+		{"category", "posts", "row INTEGER, id TEXT, title TEXT, created TEXT, body TEXT, category TEXT, sample TEXT, extra TEXT", func(r *SQLiteRepo) error { _, e := r.GetByCategoryWithError("test"); return e }},
+		{"documents", "posts", "row INTEGER, id TEXT, title TEXT, created TEXT, body TEXT, category TEXT, sample TEXT, extra TEXT", func(r *SQLiteRepo) error { _, e := r.AllDocumentsWithError(); return e }},
+		// NULL title causes Scan to fail after a successful query.
+		{"images", "images", "id TEXT, title TEXT, desc TEXT, created TEXT, category TEXT, media_type TEXT", func(r *SQLiteRepo) error { _, e := r.GetAllImagesWithError(); return e }},
+		{"images category", "images", "id TEXT, title TEXT, desc TEXT, created TEXT, category TEXT, media_type TEXT", func(r *SQLiteRepo) error { _, e := r.GetImagesByCategoryWithError("test"); return e }},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			db, err := sql.Open("sqlite3", ":memory:")
+			if err != nil {
+				t.Fatal(err)
+			}
+			defer db.Close()
+			db.SetMaxOpenConns(1)
+			repo := NewSQLiteRepo(db, nil)
+			if err := tt.call(repo); err == nil {
+				t.Fatal("missing table: expected query error")
+			}
+			if _, err := db.Exec("CREATE TABLE " + tt.table + " (" + tt.schema + ")"); err != nil {
+				t.Fatal(err)
+			}
+			if tt.table == "posts" || tt.table == "images" {
+				_, err = db.Exec("INSERT INTO " + tt.table + " (category) VALUES ('test')")
+			} else {
+				_, err = db.Exec("INSERT INTO " + tt.table + " DEFAULT VALUES")
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			if err := tt.call(repo); err == nil {
+				t.Fatal("bad row: expected scan error")
+			}
+			if stats := db.Stats(); stats.InUse != 0 {
+				t.Fatalf("connection still in use: %+v", stats)
+			}
+		})
+	}
+}
+
+func TestLegacyListMethodsDoNotExitOnQueryError(t *testing.T) {
+	db, err := sql.Open("sqlite3", ":memory:")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+	repo := NewSQLiteRepo(db, nil)
+	repo.GetDropdownElements()
+	repo.GetNavBarLinks()
+	repo.GetAssets()
+	repo.GetAdminTables()
+	repo.GetByCategory("test")
+	repo.AllDocuments()
+	repo.GetAllImages()
+	repo.GetImagesByCategory("test")
+}

+ 75 - 0
pkg/storage/media_stream_test.go

@@ -0,0 +1,75 @@
+package storage
+
+import (
+	"errors"
+	"io"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+type legacyImageIO struct {
+	reads int
+	data  []byte
+	err   error
+}
+
+func (l *legacyImageIO) Put(b []byte, id Identifier) error { l.data = b; return nil }
+func (l *legacyImageIO) Get(id Identifier) ([]byte, error) { l.reads++; return l.data, l.err }
+
+func TestImageListsMetadataAndLegacyOpen(t *testing.T) {
+	repo, db := newTestDb(t.TempDir(), true)
+	defer db.Close()
+	legacy := &legacyImageIO{data: []byte("0123456789")}
+	repo.imageIO = legacy
+	_, err := db.Exec(`INSERT INTO images (id,title,desc,created,category,media_type) VALUES ('x','title','desc','today','digital_art','video/webm')`)
+	require.NoError(t, err)
+	all := repo.GetAllImages()
+	category := repo.GetImagesByCategory(DIGITAL_ART)
+	require.Len(t, all, 1)
+	require.Equal(t, all, category)
+	require.Nil(t, all[0].Data)
+	require.Equal(t, 0, legacy.reads)
+	img, stream, err := repo.OpenImage("x")
+	require.NoError(t, err)
+	defer stream.Close()
+	require.Equal(t, "video/webm", img.MediaType)
+	require.Nil(t, img.Data)
+	_, err = stream.Seek(2, io.SeekStart)
+	require.NoError(t, err)
+	b, err := io.ReadAll(stream)
+	require.NoError(t, err)
+	require.Equal(t, "23456789", string(b))
+	require.Equal(t, 1, legacy.reads)
+	legacy.err = errors.New("failed")
+	_, _, err = repo.OpenImage("x")
+	require.ErrorIs(t, err, legacy.err)
+	_, _, err = repo.OpenImage("missing")
+	require.ErrorIs(t, err, ErrNotExists)
+	_, _, err = repo.OpenImage("../escape")
+	require.ErrorIs(t, err, ErrNotExists)
+}
+
+func TestFilesystemImageOpen(t *testing.T) {
+	f := FilesystemImageIO{RootDir: t.TempDir()}
+	require.NoError(t, f.Put([]byte("0123456789"), "x"))
+	require.NoError(t, f.Put([]byte("abc"), "x"))
+	stream, err := f.Open("x")
+	require.NoError(t, err)
+	defer stream.Close()
+	b, err := io.ReadAll(stream)
+	require.NoError(t, err)
+	require.Equal(t, "abc", string(b))
+	_, err = f.Open("missing")
+	require.ErrorIs(t, err, os.ErrNotExist)
+	_, err = f.Open("../escape")
+	require.Error(t, err)
+}
+
+func TestImageListQueryFailure(t *testing.T) {
+	repo, db := newTestDb(t.TempDir(), true)
+	require.NoError(t, db.Close())
+	require.Nil(t, repo.GetAllImages())
+	require.Nil(t, repo.GetImagesByCategory(DIGITAL_ART))
+}

+ 148 - 0
pkg/storage/migration.go

@@ -0,0 +1,148 @@
+package storage
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"syscall"
+
+	"github.com/google/uuid"
+)
+
+// MigrationReport counts rows examined and their outcomes. Unknown includes missing,
+// unsafe, unreadable-by-format, or unsupported media, but not I/O errors.
+type MigrationReport struct{ Examined, Updated, Unknown int }
+
+// MigrateLegacyImages adds legacy image columns and backfills missing media types.
+// The caller must stop all database and media writers and create a verified backup
+// first. All database changes are committed together. Existing values are preserved.
+// category is the intended category for rows when that column is absent.
+func MigrateLegacyImages(ctx context.Context, db *sql.DB, mediaDir, category string) (MigrationReport, error) {
+	var report MigrationReport
+	if strings.TrimSpace(category) == "" {
+		return report, errors.New("legacy category must not be empty")
+	}
+	info, err := os.Stat(mediaDir)
+	if err != nil {
+		return report, fmt.Errorf("media directory: %w", err)
+	}
+	if !info.IsDir() {
+		return report, errors.New("media path is not a directory")
+	}
+	tx, err := db.BeginTx(ctx, nil)
+	if err != nil {
+		return report, err
+	}
+	defer tx.Rollback()
+	rows, err := tx.QueryContext(ctx, `PRAGMA table_info(images)`)
+	if err != nil {
+		return report, err
+	}
+	columns := map[string]bool{}
+	for rows.Next() {
+		var cid, notnull, pk int
+		var name, typ string
+		var def sql.NullString
+		if err = rows.Scan(&cid, &name, &typ, &notnull, &def, &pk); err != nil {
+			rows.Close()
+			return report, err
+		}
+		columns[strings.ToLower(name)] = true
+	}
+	err = rows.Err()
+	rows.Close()
+	if err != nil {
+		return report, err
+	}
+	if !columns["id"] {
+		return report, errors.New("images table missing or has no id column")
+	}
+	if !columns["category"] {
+		if _, err = tx.ExecContext(ctx, `ALTER TABLE images ADD COLUMN category TEXT`); err != nil {
+			return report, err
+		}
+		if _, err = tx.ExecContext(ctx, `UPDATE images SET category = ?`, category); err != nil {
+			return report, err
+		}
+	}
+	if !columns["media_type"] {
+		if _, err = tx.ExecContext(ctx, `ALTER TABLE images ADD COLUMN media_type TEXT`); err != nil {
+			return report, err
+		}
+	}
+	rows, err = tx.QueryContext(ctx, `SELECT rowid, id FROM images WHERE media_type IS NULL OR media_type = ''`)
+	if err != nil {
+		return report, err
+	}
+	type item struct {
+		row int64
+		id  sql.NullString
+	}
+	var items []item
+	for rows.Next() {
+		var i item
+		if err = rows.Scan(&i.row, &i.id); err != nil {
+			rows.Close()
+			return report, err
+		}
+		items = append(items, i)
+	}
+	err = rows.Err()
+	rows.Close()
+	if err != nil {
+		return report, err
+	}
+	for _, i := range items {
+		report.Examined++
+		mediaType, e := migrationMediaType(mediaDir, i.id.String)
+		if e != nil {
+			return MigrationReport{}, fmt.Errorf("media for row %d: %w", i.row, e)
+		}
+		if mediaType == "" {
+			report.Unknown++
+			continue
+		}
+		if _, err = tx.ExecContext(ctx, `UPDATE images SET media_type = ? WHERE rowid = ?`, mediaType, i.row); err != nil {
+			return MigrationReport{}, err
+		}
+		report.Updated++
+	}
+	if err = tx.Commit(); err != nil {
+		return MigrationReport{}, err
+	}
+	return report, nil
+}
+
+// Read only canonical UUID filenames, never follow symlinks, and bound memory.
+func migrationMediaType(dir, name string) (string, error) {
+	id, err := uuid.Parse(name)
+	if err != nil || id.String() != name {
+		return "", nil
+	}
+	fd, err := syscall.Open(filepath.Join(dir, name), syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+	if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ELOOP) {
+		return "", nil
+	}
+	if err != nil {
+		return "", err
+	}
+	f := os.NewFile(uintptr(fd), name)
+	defer f.Close()
+	info, err := f.Stat()
+	if err != nil {
+		return "", err
+	}
+	if !info.Mode().IsRegular() {
+		return "", nil
+	}
+	data, err := io.ReadAll(io.LimitReader(f, 1<<20))
+	if err != nil {
+		return "", err
+	}
+	return detectMediaType(data), nil
+}

+ 77 - 0
pkg/storage/migration_media.go

@@ -0,0 +1,77 @@
+package storage
+
+import (
+	"bytes"
+	"net/http"
+)
+
+// detectMediaType trusts bytes rather than the filename or multipart MIME header.
+func detectMediaType(data []byte) string {
+	switch http.DetectContentType(data) {
+	case "image/jpeg", "image/png", "image/gif", "image/webp":
+		return http.DetectContentType(data)
+	}
+	if isWebM(data) {
+		return "video/webm"
+	}
+	return ""
+}
+
+// isWebM parses the EBML header and requires an explicit WebM DocType. Merely
+// finding the word "webm" or an EBML magic number also admits Matroska files.
+func isWebM(data []byte) bool {
+	if len(data) < 5 || !bytes.Equal(data[:4], []byte{0x1a, 0x45, 0xdf, 0xa3}) {
+		return false
+	}
+	size, n, ok := ebmlSize(data[4:])
+	if !ok || size > uint64(len(data)-4-n) {
+		return false
+	}
+	header := data[4+n : 4+n+int(size)]
+	found := false
+	for len(header) > 0 {
+		// EBML element IDs retain their leading marker bit.
+		_, idLen, valid := ebmlSize(header)
+		if !valid || idLen > 4 || len(header) <= idLen {
+			return false
+		}
+		id := header[:idLen]
+		length, lenLen, valid := ebmlSize(header[idLen:])
+		if !valid || length > uint64(len(header)-idLen-lenLen) {
+			return false
+		}
+		value := header[idLen+lenLen : idLen+lenLen+int(length)]
+		if bytes.Equal(id, []byte{0x42, 0x82}) {
+			if found || !bytes.Equal(value, []byte("webm")) {
+				return false
+			}
+			found = true
+		}
+		header = header[idLen+lenLen+int(length):]
+	}
+	return found
+}
+
+// ebmlSize reads a bounded EBML variable-length integer. Unknown sizes are
+// rejected because the header and its children must have finite lengths.
+func ebmlSize(b []byte) (uint64, int, bool) {
+	if len(b) == 0 || b[0] == 0 {
+		return 0, 0, false
+	}
+	mask, n := byte(0x80), 1
+	for b[0]&mask == 0 {
+		mask >>= 1
+		n++
+	}
+	if n > len(b) {
+		return 0, 0, false
+	}
+	value := uint64(b[0] & (mask - 1))
+	for _, v := range b[1:n] {
+		value = value<<8 | uint64(v)
+	}
+	if value == (uint64(1)<<(7*n))-1 {
+		return 0, 0, false
+	}
+	return value, n, true
+}

+ 119 - 0
pkg/storage/migration_test.go

@@ -0,0 +1,119 @@
+package storage
+
+import (
+	"context"
+	"database/sql"
+	"os"
+	"path/filepath"
+	"testing"
+
+	_ "github.com/mattn/go-sqlite3"
+)
+
+func migrationDB(t *testing.T, schema string) *sql.DB {
+	t.Helper()
+	db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { db.Close() })
+	if _, err = db.Exec(schema); err != nil {
+		t.Fatal(err)
+	}
+	return db
+}
+func TestMigrateLegacyImages(t *testing.T) {
+	db := migrationDB(t, `CREATE TABLE images (id TEXT, title TEXT)`)
+	dir := t.TempDir()
+	ids := []string{"00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002", "00000000-0000-4000-8000-000000000003", "00000000-0000-4000-8000-000000000004", "../unsafe", "00000000-0000-4000-8000-000000000005"}
+	for _, id := range ids {
+		if _, err := db.Exec(`INSERT INTO images VALUES (?, 'title')`, id); err != nil {
+			t.Fatal(err)
+		}
+	}
+	files := [][]byte{[]byte("GIF89a\x00\x00\x00"), {0x1a, 0x45, 0xdf, 0xa3, 0x87, 0x42, 0x82, 0x84, 'w', 'e', 'b', 'm'}, {0x1a, 0x45, 0xdf, 0xa3, 0x8b, 0x42, 0x82, 0x88, 'm', 'a', 't', 'r', 'o', 's', 'k', 'a'}}
+	for i, data := range files {
+		if err := os.WriteFile(filepath.Join(dir, ids[i]), data, 0600); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := os.Symlink(filepath.Join(dir, ids[0]), filepath.Join(dir, ids[5])); err != nil {
+		t.Fatal(err)
+	}
+	report, err := MigrateLegacyImages(context.Background(), db, dir, "digital_art")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if report != (MigrationReport{Examined: 6, Updated: 2, Unknown: 4}) {
+		t.Fatalf("report: %+v", report)
+	}
+	for i, id := range ids {
+		var category string
+		var mt sql.NullString
+		if err := db.QueryRow(`SELECT category, media_type FROM images WHERE id=?`, id).Scan(&category, &mt); err != nil {
+			t.Fatal(err)
+		}
+		if category != "digital_art" {
+			t.Fatal(category)
+		}
+		want := ""
+		if i == 0 {
+			want = "image/gif"
+		}
+		if i == 1 {
+			want = "video/webm"
+		}
+		if mt.String != want || (want == "" && mt.Valid) {
+			t.Fatalf("%s: %+v", id, mt)
+		}
+	}
+	report, err = MigrateLegacyImages(context.Background(), db, dir, "other")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if report != (MigrationReport{Examined: 4, Unknown: 4}) {
+		t.Fatalf("second: %+v", report)
+	}
+}
+func TestMigrateExistingColumns(t *testing.T) {
+	db := migrationDB(t, `CREATE TABLE images (id TEXT, category TEXT, media_type TEXT NOT NULL DEFAULT '')`)
+	id := "00000000-0000-4000-8000-000000000001"
+	dir := t.TempDir()
+	os.WriteFile(filepath.Join(dir, id), []byte("GIF89a"), 0600)
+	if _, err := db.Exec(`INSERT INTO images VALUES (?, 'old', ''), (?, 'keep', 'custom/type')`, id, id); err != nil {
+		t.Fatal(err)
+	}
+	report, err := MigrateLegacyImages(context.Background(), db, dir, "new")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if report.Updated != 1 {
+		t.Fatal(report)
+	}
+	var cat, mt string
+	if err := db.QueryRow(`SELECT category, media_type FROM images WHERE category='keep'`).Scan(&cat, &mt); err != nil || mt != "custom/type" {
+		t.Fatalf("%s %s %v", cat, mt, err)
+	}
+}
+func TestMigrationRollback(t *testing.T) {
+	db := migrationDB(t, `CREATE TABLE images (id TEXT); INSERT INTO images VALUES ('00000000-0000-4000-8000-000000000001'); CREATE TRIGGER reject_update BEFORE UPDATE ON images BEGIN SELECT RAISE(ABORT,'reject'); END;`)
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "00000000-0000-4000-8000-000000000001"), []byte("GIF89a"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := MigrateLegacyImages(context.Background(), db, dir, "digital_art"); err == nil {
+		t.Fatal("expected error")
+	}
+	rows, err := db.Query(`PRAGMA table_info(images)`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer rows.Close()
+	count := 0
+	for rows.Next() {
+		count++
+	}
+	if count != 1 {
+		t.Fatalf("DDL not rolled back: %d columns", count)
+	}
+}

+ 2 - 1
pkg/storage/queries.go

@@ -18,7 +18,8 @@ const imagesTable = `
 		title TEXT NOT NULL,
 		desc TEXT NOT NULL,
 		created TEXT NOT NULL,
-		category TEXT NOT NULL
+		category TEXT NOT NULL,
+		media_type TEXT NOT NULL DEFAULT ''
 	);
 	`
 const menuItemsTable = `

+ 301 - 114
pkg/storage/storage.go

@@ -1,6 +1,7 @@
 package storage
 
 import (
+	"bytes"
 	"database/sql"
 	"errors"
 	"fmt"
@@ -104,14 +105,15 @@ func (d *Document) MakeSample() string {
 }
 
 type Image struct {
-	Ident    Identifier `json:"identifier"`
-	Location string
-	Title    string                `json:"title" form:"title"`
-	File     *multipart.FileHeader `form:"file"`
-	Desc     string                `json:"description" form:"description"`
-	Created  string
-	Category string `json:"category" form:"category"`
-	Data     []byte
+	Ident     Identifier `json:"identifier"`
+	Location  string
+	Title     string                `json:"title" form:"title"`
+	File      *multipart.FileHeader `form:"file"`
+	Desc      string                `json:"description" form:"description"`
+	Created   string
+	Category  string `json:"category" form:"category"`
+	MediaType string `json:"media_type"`
+	Data      []byte
 }
 
 type DocumentIO interface {
@@ -121,9 +123,11 @@ type DocumentIO interface {
 	GetImagesByCategory(category string) []Image
 	UpdateDocument(doc Document) error
 	DeleteDocument(id Identifier) error
+	UpdateDigitalArt(id Identifier, title, desc string) error
+	ArchiveDigitalArt(id Identifier) error
 	DeleteNavbarItem(id Identifier) error
 	AddDocument(doc Document) (Identifier, error)
-	AddImage(data []byte, title, desc, category string) (Identifier, error)
+	AddImage(data []byte, title, desc, category, mediaType string) (Identifier, error)
 	AddAsset(name string, data []byte) error
 	AddAdminTableEntry(TableData, string) error
 	AddNavbarItem(NavBarItem) error
@@ -136,6 +140,19 @@ type DocumentIO interface {
 	GetAdminTables() AdminPage
 }
 
+// DocumentListReader provides list operations that distinguish database failures
+// from empty results. Request handlers should prefer these methods.
+type DocumentListReader interface {
+	GetDropdownElementsWithError() ([]LinkPair, error)
+	GetNavBarLinksWithError() ([]NavBarItem, error)
+	GetAssetsWithError() ([]Asset, error)
+	GetAdminTablesWithError() (AdminPage, error)
+	GetByCategoryWithError(string) ([]Document, error)
+	AllDocumentsWithError() ([]Document, error)
+	GetAllImagesWithError() ([]Image, error)
+	GetImagesByCategoryWithError(string) ([]Image, error)
+}
+
 var (
 	ErrDuplicate    = errors.New("record already exists")
 	ErrNotExists    = errors.New("row not exists")
@@ -153,6 +170,18 @@ type ImageIO interface {
 	Get(Identifier) ([]byte, error)
 }
 
+// ReadSeekCloser is a seekable media stream that must be closed by its caller.
+type ReadSeekCloser interface {
+	io.Reader
+	io.Seeker
+	io.Closer
+}
+
+// ImageOpener is an optional streaming extension to ImageIO.
+type ImageOpener interface {
+	Open(Identifier) (ReadSeekCloser, error)
+}
+
 type FilesystemImageIO struct {
 	RootDir string
 }
@@ -176,7 +205,10 @@ Put a data blob on the filesystem
 	:param b: the
 */
 func (f FilesystemImageIO) Put(b []byte, id Identifier) error {
-	fh, err := os.OpenFile(path.Join(f.RootDir, string(id)), os.O_CREATE|os.O_RDWR, os.ModePerm)
+	if err := validateImageIdentifier(id); err != nil {
+		return err
+	}
+	fh, err := os.OpenFile(path.Join(f.RootDir, string(id)), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm)
 	if err != nil {
 		return err
 	}
@@ -194,10 +226,11 @@ Get a data blob from the filesystem
 	:param id: the identifier of the image to retrieve
 */
 func (f FilesystemImageIO) Get(id Identifier) ([]byte, error) {
-	fh, err := os.Open(path.Join(f.RootDir, string(id)))
+	fh, err := f.Open(id)
 	if err != nil {
 		return nil, err
 	}
+	defer fh.Close()
 	b, err := io.ReadAll(fh)
 	if err != nil {
 		return nil, err
@@ -205,6 +238,22 @@ func (f FilesystemImageIO) Get(id Identifier) ([]byte, error) {
 	return b, nil
 }
 
+// Open opens a filesystem image without reading it into memory.
+func (f FilesystemImageIO) Open(id Identifier) (ReadSeekCloser, error) {
+	if err := validateImageIdentifier(id); err != nil {
+		return nil, err
+	}
+	return os.Open(path.Join(f.RootDir, string(id)))
+}
+
+func validateImageIdentifier(id Identifier) error {
+	name := string(id)
+	if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
+		return fmt.Errorf("invalid image identifier %q", name)
+	}
+	return nil
+}
+
 // Instantiate a new SQLiteRepo struct
 func NewSQLiteRepo(db *sql.DB, imgIo ImageIO) *SQLiteRepo {
 	return &SQLiteRepo{
@@ -222,6 +271,35 @@ func (r *SQLiteRepo) Migrate(seedQueries []string) error {
 			return err
 		}
 	}
+	// CREATE TABLE IF NOT EXISTS does not update an existing images table.
+	// Check by name so migration remains safe to run repeatedly.
+	rows, err := r.db.Query("PRAGMA table_info(images)")
+	if err != nil {
+		return err
+	}
+	var hasImages, hasMediaType bool
+	for rows.Next() {
+		var cid, notNull, primaryKey int
+		var name, columnType string
+		var defaultValue sql.NullString
+		if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
+			rows.Close()
+			return err
+		}
+		hasImages = true
+		if name == "media_type" {
+			hasMediaType = true
+		}
+	}
+	err = rows.Err()
+	rows.Close()
+	if err != nil {
+		return err
+	}
+	if hasImages && !hasMediaType {
+		_, err = r.db.Exec("ALTER TABLE images ADD COLUMN media_type TEXT NOT NULL DEFAULT ''")
+		return err
+	}
 	return nil
 }
 
@@ -229,20 +307,32 @@ func (r *SQLiteRepo) Migrate(seedQueries []string) error {
 Get all dropdown menu elements. Returns a list of LinkPair structs with the text and redirect location
 */
 func (s *SQLiteRepo) GetDropdownElements() []LinkPair {
+	items, err := s.GetDropdownElementsWithError()
+	if err != nil {
+		log.Printf("get dropdown elements: %v", err)
+	}
+	return items
+}
+
+func (s *SQLiteRepo) GetDropdownElementsWithError() ([]LinkPair, error) {
 	rows, err := s.db.Query("SELECT * FROM menu")
-	var menuItems []LinkPair
+	if err != nil {
+		return nil, err
+	}
 	defer rows.Close()
+	var items []LinkPair
 	for rows.Next() {
 		var id int
 		var item LinkPair
-		err = rows.Scan(&id, &item.Link, &item.Text)
-		if err != nil {
-			log.Fatal(err)
+		if err := rows.Scan(&id, &item.Link, &item.Text); err != nil {
+			return nil, err
 		}
-		menuItems = append(menuItems, item)
+		items = append(items, item)
 	}
-	return menuItems
-
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
 }
 
 /*
@@ -323,62 +413,91 @@ Get all nav bar items. Returns a list of NavBarItem structs with the png data, t
 Get all nav bar items. Returns a list of NavBarItem structs with the png data, the file name, and the redirect location of the icon
 */
 func (s *SQLiteRepo) GetNavBarLinks() []NavBarItem {
+	items, err := s.GetNavBarLinksWithError()
+	if err != nil {
+		log.Printf("get navbar links: %v", err)
+	}
+	return items
+}
 
+func (s *SQLiteRepo) GetNavBarLinksWithError() ([]NavBarItem, error) {
 	rows, err := s.db.Query("SELECT * FROM navbar")
-	var navbarItems []NavBarItem
+	if err != nil {
+		return nil, err
+	}
 	defer rows.Close()
+	var items []NavBarItem
 	for rows.Next() {
 		var item NavBarItem
 		var id int
-		err = rows.Scan(&id, &item.Png, &item.Link, &item.Redirect)
-		if err != nil {
-			log.Fatal(err)
+		if err := rows.Scan(&id, &item.Png, &item.Link, &item.Redirect); err != nil {
+			return nil, err
 		}
-		navbarItems = append(navbarItems, item)
+		items = append(items, item)
 	}
-	return navbarItems
-
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
 }
 
-/*
-get all assets from the asset table
-*/
 func (s *SQLiteRepo) GetAssets() []Asset {
+	items, err := s.GetAssetsWithError()
+	if err != nil {
+		log.Printf("get assets: %v", err)
+	}
+	return items
+}
+
+func (s *SQLiteRepo) GetAssetsWithError() ([]Asset, error) {
 	rows, err := s.db.Query("SELECT * FROM assets")
-	var assets []Asset
+	if err != nil {
+		return nil, err
+	}
 	defer rows.Close()
+	var items []Asset
 	for rows.Next() {
 		var item Asset
 		var id int
-		err = rows.Scan(&id, &item.Name, &item.Data)
-		if err != nil {
-			log.Fatal(err)
+		if err := rows.Scan(&id, &item.Name, &item.Data); err != nil {
+			return nil, err
 		}
-		assets = append(assets, item)
+		items = append(items, item)
 	}
-	return assets
-
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
 }
 
-/*
-get all assets from the asset table
-*/
 func (s *SQLiteRepo) GetAdminTables() AdminPage {
+	page, err := s.GetAdminTablesWithError()
+	if err != nil {
+		log.Printf("get admin tables: %v", err)
+	}
+	return page
+}
+
+func (s *SQLiteRepo) GetAdminTablesWithError() (AdminPage, error) {
 	rows, err := s.db.Query("SELECT * FROM admin")
-	adminPage := AdminPage{Tables: map[string][]TableData{}}
+	if err != nil {
+		return AdminPage{}, err
+	}
 	defer rows.Close()
+	page := AdminPage{Tables: map[string][]TableData{}}
 	for rows.Next() {
 		var item TableData
 		var id int
 		var category string
-		err = rows.Scan(&id, &item.DisplayName, &item.Link, &category)
-		if err != nil {
-			log.Fatal(err)
+		if err := rows.Scan(&id, &item.DisplayName, &item.Link, &category); err != nil {
+			return AdminPage{}, err
 		}
-		adminPage.Tables[category] = append(adminPage.Tables[category], item)
+		page.Tables[category] = append(page.Tables[category], item)
 	}
-	return adminPage
-
+	if err := rows.Err(); err != nil {
+		return AdminPage{}, err
+	}
+	return page, nil
 }
 
 /*
@@ -407,26 +526,35 @@ Get all documents by category
 	:param category: the category to retrieve all docs from
 */
 func (s *SQLiteRepo) GetByCategory(category string) []Document {
-	rows, err := s.db.Query("SELECT * FROM posts WHERE category = ?", category)
+	docs, err := s.GetByCategoryWithError(category)
 	if err != nil {
-		log.Fatal(err)
+		log.Printf("get documents by category: %v", err)
+	}
+	return docs
+}
+
+func (s *SQLiteRepo) GetByCategoryWithError(category string) ([]Document, error) {
+	return s.listDocuments("SELECT * FROM posts WHERE category = ?", category)
+}
+
+func (s *SQLiteRepo) listDocuments(query string, args ...interface{}) ([]Document, error) {
+	rows, err := s.db.Query(query, args...)
+	if err != nil {
+		return nil, err
 	}
-	var docs []Document
 	defer rows.Close()
+	var docs []Document
 	for rows.Next() {
 		var doc Document
-		err := rows.Scan(&doc.Row, &doc.Ident, &doc.Title, &doc.Created, &doc.Body, &doc.Category, &doc.Sample)
-		if err != nil {
-			log.Fatal(err)
+		if err := rows.Scan(&doc.Row, &doc.Ident, &doc.Title, &doc.Created, &doc.Body, &doc.Category, &doc.Sample); err != nil {
+			return nil, err
 		}
 		docs = append(docs, doc)
 	}
-	err = rows.Err()
-	if err != nil {
-		log.Fatal(err)
+	if err := rows.Err(); err != nil {
+		return nil, err
 	}
-	return docs
-
+	return docs, nil
 }
 
 /*
@@ -434,77 +562,104 @@ get image data from the images table
 
 	:param id: the serial identifier of the post
 */
-func (s *SQLiteRepo) GetImage(id Identifier) (Image, error) {
-	row := s.db.QueryRow("SELECT * FROM images WHERE id = ?", id)
-	var rowNum int
-	var title, desc, created, category string
-	if err := row.Scan(&rowNum, &id, &title, &desc, &created, &category); err != nil {
+func (s *SQLiteRepo) imageMetadata(id Identifier) (Image, error) {
+	if err := validateImageIdentifier(id); err != nil {
+		return Image{}, ErrNotExists
+	}
+	row := s.db.QueryRow("SELECT id, title, desc, created, category, COALESCE(media_type, '') FROM images WHERE id = ?", id)
+	var img Image
+	if err := row.Scan(&img.Ident, &img.Title, &img.Desc, &img.Created, &img.Category, &img.MediaType); err != nil {
 		if errors.Is(err, sql.ErrNoRows) {
 			return Image{}, ErrNotExists
 		}
 		return Image{}, err
 	}
-	data, err := s.imageIO.Get(id)
+	return img, nil
+}
+
+func (s *SQLiteRepo) GetImage(id Identifier) (Image, error) {
+	img, err := s.imageMetadata(id)
+	if err != nil {
+		return Image{}, err
+	}
+	img.Data, err = s.imageIO.Get(id)
 	if err != nil {
 		return Image{}, err
 	}
-	return Image{Ident: id, Title: title, Desc: desc, Data: data, Created: created, Category: category}, nil
+	return img, nil
 }
 
-/*
-Get all of the images from the datastore
-*/
-func (s *SQLiteRepo) GetAllImages() []Image {
-	rows, err := s.db.Query("SELECT * FROM images")
+type memoryImage struct{ *bytes.Reader }
+
+func (memoryImage) Close() error { return nil }
+
+// OpenImage returns metadata and a seekable stream. Legacy ImageIO implementations
+// remain usable through a buffered fallback; callers must close the stream.
+func (s *SQLiteRepo) OpenImage(id Identifier) (Image, ReadSeekCloser, error) {
+	img, err := s.imageMetadata(id)
 	if err != nil {
-		log.Fatal(err)
+		return Image{}, nil, err
 	}
-	imgs := []Image{}
-	for rows.Next() {
-		var img Image
-		var rowNum int
-		err := rows.Scan(&rowNum, &img.Ident, &img.Title, &img.Desc, &img.Created, &img.Category)
-		if err != nil {
-			log.Fatal(err)
-		}
-		b, err := s.imageIO.Get(img.Ident)
+	if opener, ok := s.imageIO.(ImageOpener); ok {
+		file, err := opener.Open(id)
 		if err != nil {
-			fmt.Printf("Failed to get image: %+v\nError: %s\n", img, err.Error())
-			continue
+			return Image{}, nil, err
 		}
-		imgs = append(imgs, Image{Ident: img.Ident, Title: img.Title, Desc: img.Desc, Data: b, Created: img.Created, Category: img.Category})
+		return img, file, nil
 	}
-	err = rows.Err()
+	data, err := s.imageIO.Get(id)
+	if err != nil {
+		return Image{}, nil, err
+	}
+	return img, memoryImage{bytes.NewReader(data)}, nil
+}
+
+/*
+Get all of the images from the datastore
+*/
+func (s *SQLiteRepo) GetAllImages() []Image {
+	imgs, err := s.GetAllImagesWithError()
 	if err != nil {
-		log.Fatal(err)
+		log.Printf("get all images: %v", err)
 	}
 	return imgs
 }
 
+func (s *SQLiteRepo) GetAllImagesWithError() ([]Image, error) {
+	return s.listImages("SELECT id, title, desc, created, category, COALESCE(media_type, '') FROM images")
+}
+
 func (s *SQLiteRepo) GetImagesByCategory(category string) []Image {
-	rows, err := s.db.Query("SELECT * FROM images WHERE category = ?", category)
+	imgs, err := s.GetImagesByCategoryWithError(category)
+	if err != nil {
+		log.Printf("get images by category: %v", err)
+	}
+	return imgs
+}
+
+func (s *SQLiteRepo) GetImagesByCategoryWithError(category string) ([]Image, error) {
+	return s.listImages("SELECT id, title, desc, created, category, COALESCE(media_type, '') FROM images WHERE category = ?", category)
+}
+
+// listImages deliberately returns metadata only, even if a media file is missing.
+func (s *SQLiteRepo) listImages(query string, args ...interface{}) ([]Image, error) {
+	rows, err := s.db.Query(query, args...)
 	if err != nil {
-		log.Fatal(err)
+		return nil, err
 	}
+	defer rows.Close()
 	imgs := []Image{}
 	for rows.Next() {
 		var img Image
-		var rowNum int
-		err := rows.Scan(&rowNum, &img.Ident, &img.Title, &img.Desc, &img.Created, &img.Category)
-		if err != nil {
-			log.Fatal(err)
-		}
-		b, err := s.imageIO.Get(img.Ident)
-		if err != nil {
-			fmt.Printf("Failed to get image: %+v\nError: %s\n", img, err.Error())
+		if err := rows.Scan(&img.Ident, &img.Title, &img.Desc, &img.Created, &img.Category, &img.MediaType); err != nil {
+			return nil, err
 		}
-		imgs = append(imgs, Image{Ident: img.Ident, Title: img.Title, Desc: img.Desc, Data: b, Created: img.Created, Category: img.Category})
+		imgs = append(imgs, img)
 	}
-	err = rows.Err()
-	if err != nil {
-		log.Fatal(err)
+	if err := rows.Err(); err != nil {
+		return nil, err
 	}
-	return imgs
+	return imgs, nil
 }
 
 /*
@@ -515,19 +670,59 @@ Add an image to the database
 	:param desc: the description of the image, if any
 	:param data: the binary data for the image
 */
-func (s *SQLiteRepo) AddImage(data []byte, title string, desc string, category string) (Identifier, error) {
+func (s *SQLiteRepo) AddImage(data []byte, title, desc, category, mediaType string) (Identifier, error) {
 	id := newIdentifier()
 	err := s.imageIO.Put(data, id)
 	if err != nil {
 		return Identifier(""), err
 	}
-	_, err = s.db.Exec("INSERT INTO images (id, title, desc, created, category) VALUES (?,?,?,?,?)", string(id), title, desc, time.Now().String(), category)
+	_, err = s.db.Exec("INSERT INTO images (id, title, desc, created, category, media_type) VALUES (?,?,?,?,?,?)", string(id), title, desc, time.Now().String(), category, mediaType)
 	if err != nil {
 		return Identifier(""), err
 	}
 	return id, nil
 }
 
+// UpdateDigitalArt changes only the title and description of a digital art
+// record. Other image categories and the image file are never modified.
+func (s *SQLiteRepo) UpdateDigitalArt(id Identifier, title, desc string) error {
+	if err := validateImageIdentifier(id); err != nil {
+		return err
+	}
+	res, err := s.db.Exec("UPDATE images SET title = ?, desc = ? WHERE id = ? AND category = ? AND (SELECT COUNT(*) FROM images WHERE id = ?) = 1", title, desc, id, DIGITAL_ART, id)
+	if err != nil {
+		return err
+	}
+	affected, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if affected != 1 {
+		return ErrNotExists
+	}
+	return nil
+}
+
+// ArchiveDigitalArt removes an art listing while retaining its media and ID for
+// existing references. Ambiguous IDs are left untouched, regardless of category.
+func (s *SQLiteRepo) ArchiveDigitalArt(id Identifier) error {
+	if err := validateImageIdentifier(id); err != nil {
+		return err
+	}
+	res, err := s.db.Exec("UPDATE images SET category = ? WHERE id = ? AND category = ? AND (SELECT COUNT(*) FROM images WHERE id = ?) = 1", MISC_ASSET, id, DIGITAL_ART, id)
+	if err != nil {
+		return err
+	}
+	affected, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if affected != 1 {
+		return ErrNotExists
+	}
+	return nil
+}
+
 /*
 Updates a document in the database with the supplied. Only changes the title, the body, category. Keys off of the documents Identifier
 
@@ -742,23 +937,15 @@ func (s *SQLiteRepo) DeleteNavbarItem(id Identifier) error {
 
 // Get all Hosts from the host table
 func (s *SQLiteRepo) AllDocuments() []Document {
-	rows, err := s.db.Query("SELECT * FROM posts")
+	docs, err := s.AllDocumentsWithError()
 	if err != nil {
-		fmt.Printf("There was an issue getting all posts. %s", err.Error())
-		return nil
+		log.Printf("get all documents: %v", err)
 	}
-	defer rows.Close()
+	return docs
+}
 
-	all := []Document{}
-	for rows.Next() {
-		var post Document
-		if err := rows.Scan(&post.Row, &post.Ident, &post.Title, &post.Created, &post.Body, &post.Category, &post.Sample); err != nil {
-			fmt.Printf("There was an error getting all documents. %s", err.Error())
-			return nil
-		}
-		all = append(all, post)
-	}
-	return all
+func (s *SQLiteRepo) AllDocumentsWithError() ([]Document, error) {
+	return s.listDocuments("SELECT * FROM posts")
 }
 
 type InvalidSkipArg struct{ Skip int }

+ 73 - 1
pkg/storage/storage_test.go

@@ -367,6 +367,9 @@ func TestGetAllImages(t *testing.T) {
 			testDb.imageIO.Put(tc.seed[i].Data, tc.seed[i].Ident)
 		}
 		got := testDb.GetAllImages()
+		for i := range tc.seed {
+			tc.seed[i].Data = nil
+		}
 		assert.Equal(t, tc.seed, got)
 	}
 
@@ -533,7 +536,7 @@ func TestAddImage(t *testing.T) {
 			category: "homepage",
 		},
 	} {
-		id, err := testDb.AddImage(tc.data, tc.title, tc.desc, tc.category)
+		id, err := testDb.AddImage(tc.data, tc.title, tc.desc, tc.category, "image/jpeg")
 		if err != nil {
 			assert.Equal(t, tc.err, err)
 		} else {
@@ -794,3 +797,72 @@ func TestNewIdentifier(t *testing.T) {
 
 	// testDb, db := newTestDb(t.TempDir(), true)
 }
+
+func TestImageMediaTypeRoundTrip(t *testing.T) {
+	repo, db := newTestDb(t.TempDir(), true)
+	defer db.Close()
+
+	id, err := repo.AddImage([]byte("image bytes"), "title", "description", DIGITAL_ART, "image/webp")
+	if err != nil {
+		t.Fatal(err)
+	}
+	image, err := repo.GetImage(id)
+	if err != nil {
+		t.Fatal(err)
+	}
+	assert.Equal(t, "image/webp", image.MediaType)
+	assert.Equal(t, []byte("image bytes"), image.Data)
+	image.Data = nil
+	assert.Equal(t, []Image{image}, repo.GetAllImages())
+	assert.Equal(t, []Image{image}, repo.GetImagesByCategory(DIGITAL_ART))
+	assert.Empty(t, repo.GetImagesByCategory(MISC_ASSET))
+}
+
+func TestLegacyImagesMigration(t *testing.T) {
+	db, err := sql.Open("sqlite3", ":memory:")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+	repo := NewSQLiteRepo(db, FilesystemImageIO{RootDir: t.TempDir()})
+	// An extra column and a different physical order simulate an older database.
+	_, err = db.Exec(`CREATE TABLE images (
+		row INTEGER PRIMARY KEY AUTOINCREMENT,
+		id TEXT NOT NULL, legacy_extra TEXT, category TEXT NOT NULL,
+		title TEXT NOT NULL, desc TEXT NOT NULL, created TEXT NOT NULL
+	)`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, err = db.Exec(`INSERT INTO images (id, legacy_extra, category, title, desc, created)
+		VALUES ('old', 'unused', 'digital_art', 'old title', 'old desc', 'yesterday')`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := repo.imageIO.Put([]byte("old bytes"), "old"); err != nil {
+		t.Fatal(err)
+	}
+	for i := 0; i < 2; i++ {
+		if err := repo.Migrate(RequiredTables); err != nil {
+			t.Fatal(err)
+		}
+	}
+	old, err := repo.GetImage("old")
+	if err != nil {
+		t.Fatal(err)
+	}
+	assert.Equal(t, Image{Ident: "old", Title: "old title", Desc: "old desc", Created: "yesterday", Category: DIGITAL_ART, Data: []byte("old bytes")}, old)
+	id, err := repo.AddImage([]byte("new bytes"), "new title", "new desc", DIGITAL_ART, "image/png")
+	if err != nil {
+		t.Fatal(err)
+	}
+	newImage, err := repo.GetImage(id)
+	if err != nil {
+		t.Fatal(err)
+	}
+	assert.Equal(t, "image/png", newImage.MediaType)
+	old.Data = nil
+	newImage.Data = nil
+	assert.Equal(t, []Image{old, newImage}, repo.GetAllImages())
+	assert.Equal(t, []Image{old, newImage}, repo.GetImagesByCategory(DIGITAL_ART))
+}

+ 13 - 1
pkg/webpages/cdn/custom.css

@@ -99,7 +99,8 @@
     background: rgb(90, 90, 90);
 }
 
-.image-select .option img {
+.image-select .option img,
+.image-select .option video {
     width: 40px;
     height: 40px;
     object-fit: cover;
@@ -109,3 +110,14 @@
 .real-select {
     display: none;
 }
+
+/* The mask receives hover directly, as with images. Avoid native video
+   controls in the gallery: Firefox may paint them above HTML overlays. */
+.hover-overlay .media-card .video-highlight {
+    z-index: 1;
+    pointer-events: auto;
+}
+.hover-overlay .media-card:hover .video-highlight,
+.hover-overlay .media-card:focus-within .video-highlight {
+    opacity: 1;
+}

+ 9 - 3
pkg/webpages/cdn/image-insert.js

@@ -15,12 +15,17 @@ function insertSelectedImage() {
     const imageId = select.value;
 
     if (!imageId) {
-        alert("Please select an image first.");
+        alert("Please select media first.");
         return;
     }
 
+    const mediaType = select.selectedOptions[0].dataset.mediaType;
+    const src = `/api/v1/images/${encodeURIComponent(imageId)}`;
+    const media = mediaType === "video/webm"
+        ? `<video src="${src}" class="rounded img-fluid" controls preload="metadata"></video>`
+        : `<img src="${src}" class="rounded img-fluid" alt="">`;
     const html = `<div class="text-center">
-<img src="/api/v1/images/${imageId}" class="rounded img-fluid">
+${media}
 </div>`;
 
     const textarea = document.getElementById("myTextarea");
@@ -36,7 +41,8 @@ function toggleDropdown(el) {
     options.style.display = open ? "none" : "block";
 }
 
-function chooseImage(optionEl, id) {
+function chooseImage(optionEl) {
+    const id = optionEl.dataset.id;
     const container = optionEl.closest('.image-select');
 
     // set visible label

+ 40 - 0
pkg/webpages/digital_art_admin_test.go

@@ -0,0 +1,40 @@
+package webpages
+
+import (
+	"bytes"
+	"html/template"
+	"strings"
+	"testing"
+
+	"git.aetherial.dev/aeth/keiji/pkg/storage"
+)
+
+func TestDigitalArtAdminTemplates(t *testing.T) {
+	tmpl, err := template.ParseFS(content, "html/*.html")
+	if err != nil {
+		t.Fatal(err)
+	}
+	image := storage.Image{Ident: "abc", Title: "A <title>", Desc: "A & description", MediaType: "image/png"}
+	video := storage.Image{Ident: "vid", Title: "Video", MediaType: "video/webm"}
+	cases := []struct {
+		name string
+		data any
+		want []string
+	}{
+		{"digital_art_admin_list", map[string]any{"Images": []storage.Image{image, video}}, []string{"/admin/digital/abc", "/admin/digital/vid", "<img ", "<video ", "A &lt;title&gt;", "A &amp; description"}},
+		{"digital_art_admin_list", map[string]any{"Images": []storage.Image{}}, []string{"No digital art found."}},
+		{"digital_art_admin_editor", image, []string{"action=\"/admin/digital/abc\"", "method=\"post\"", "hx-post=\"/admin/digital/abc\"", "name=\"title\"", "name=\"description\"", "A &lt;title&gt;", "A &amp; description", "<img ", "hx-get=\"/admin/digital\"", "digital-art-response", `hx-delete="/admin/digital/abc"`, "hx-confirm", "media remains"}},
+		{"digital_art_admin_editor", video, []string{"<video ", "/api/v1/images/vid", "controls"}},
+	}
+	for _, tc := range cases {
+		var out bytes.Buffer
+		if err := tmpl.ExecuteTemplate(&out, tc.name, tc.data); err != nil {
+			t.Fatalf("%s: %v", tc.name, err)
+		}
+		for _, want := range tc.want {
+			if !strings.Contains(out.String(), want) {
+				t.Errorf("%s: expected %q in output", tc.name, want)
+			}
+		}
+	}
+}

+ 13 - 9
pkg/webpages/html/blogpost_editor.html

@@ -63,31 +63,35 @@
                                 <div class="image-select">
                                     <!-- visible dropdown button -->
                                     <div class="selected" onclick="toggleDropdown(this)">
-                                        <span>Select an image</span>
+                                        <span>Select media</span>
                                     </div>
 
                                     <!-- dropdown list -->
                                     <div class="options">
-                                        {{ range .ImageIds }}
-                                        <div class="option" onclick="chooseImage(this, '{{ . }}')">
-                                            <img src="/api/v1/images/{{ . }}">
-                                            <span>{{ . }}</span>
+                                        {{ range .Images }}
+                                        <div class="option" data-id="{{ .Ident }}" onclick="chooseImage(this)">
+                                            {{ if eq .MediaType "video/webm" }}
+                                            <video src="/api/v1/images/{{ .Ident }}" muted preload="metadata" aria-label="{{ .Title }}"></video>
+                                            {{ else }}
+                                            <img src="/api/v1/images/{{ .Ident }}" alt="{{ .Title }}" loading="lazy">
+                                            {{ end }}
+                                            <span>{{ .Ident }}</span>
                                         </div>
                                         {{ end }}
                                     </div>
 
                                     <!-- REAL select for form submission -->
                                     <select name="imageId" class="real-select" id="imageSelect">
-                                        <option value="">Select an image</option>
-                                        {{ range .ImageIds }}
-                                        <option value="{{ . }}">{{ . }}</option>
+                                        <option value="">Select media</option>
+                                        {{ range .Images }}
+                                        <option value="{{ .Ident }}" data-media-type="{{ .MediaType }}">{{ .Ident }}</option>
                                         {{ end }}
                                     </select>
                                 </div>
                                 <button
                                     type="button"
                                     hx-on:click="insertSelectedImage()">
-                                    Insert Image
+                                    Insert Media
                                 </button>
                             </div>
                             <div class="row"

+ 9 - 5
pkg/webpages/html/digital_art.html

@@ -4,11 +4,15 @@
 <html lang="en">
     <div class="row container-fluid m-0 p-0">
         <div class="col container-fluid" style="background-color: black;"></div>
-        <img src="/api/v1/images/{{ .Ident }}" loading="lazy" class="img-fluid m-0 p-0 col-auto" style="background-color: black; max-height: 70vh;">
+        {{ if eq .MediaType "video/webm" }}
+        <video src="/api/v1/images/{{ .Ident }}" autoplay muted loop playsinline preload="metadata" aria-label="{{ .Title }}" class="img-fluid m-0 p-0 col-auto" style="background-color: black; max-height: 70vh;"></video>
+        {{ else }}
+        <img src="/api/v1/images/{{ .Ident }}" alt="{{ .Title }}" loading="lazy" class="img-fluid m-0 p-0 col-auto" style="background-color: black; max-height: 70vh;">
+        {{ end }}
         <div class="col container-fluid" style="background-color: black;"></div>
     </div>
-    <a href="#">
-        <div class="mask" style="background-color: hsla(0, 0%, 98%, 0.2)">
+    {{ if ne .MediaType "video/webm" }}<a href="#">{{ end }}
+        <div class="mask{{ if eq .MediaType "video/webm" }} video-highlight{{ end }}" style="background-color: hsla(0, 0%, 98%, 0.2)">
             <div class="row align-items-center" style="font-family: monospace; color: white; min-height: 100%;">
                 <div class="col"></div>
                 <div class="col-auto">
@@ -22,7 +26,7 @@
                 <div class="col"></div>
             </div>
         </div>
-    </a>
+    {{ if ne .MediaType "video/webm" }}</a>{{ end }}
 </html>
 {{ end }}
 
@@ -48,7 +52,7 @@
             {{ range .images }}
                 <div class="col-sm hover-overlay" data-mdb-ripple-init data-mdb-ripple-color="light">
                     <div class="col-auto">
-                        <div class="row position-relative shadow-lg p-3 m-3 rounded justify-content-center"
+                        <div class="row position-relative shadow-lg p-3 m-3 rounded justify-content-center media-card"
                             style="width: 80vh; max-width: 95%; background-color: rgb(22, 22, 22);">
                                 {{ template "centered_image" . }}
                         </div>

+ 53 - 0
pkg/webpages/html/digital_art_admin.html

@@ -0,0 +1,53 @@
+{{ define "digital_art_admin_list" }}
+<div class="container-fluid p-3" style="color: white; font-family: monospace;">
+    <h1>Modify Digital Art</h1>
+    {{ range .Images }}
+    <div class="row p-3 m-2 rounded" style="background-color: rgb(22, 22, 22);">
+        <div class="col-sm-3">
+            {{ if eq .MediaType "video/webm" }}
+            <video src="/api/v1/images/{{ .Ident }}" controls muted preload="metadata" aria-label="{{ .Title }}" class="img-fluid"></video>
+            {{ else }}
+            <img src="/api/v1/images/{{ .Ident }}" alt="{{ .Title }}" loading="lazy" class="img-fluid">
+            {{ end }}
+        </div>
+        <div class="col-sm">
+            <h2>{{ .Title }}</h2>
+            <p style="white-space: pre-wrap;">{{ .Desc }}</p>
+            <a href="/admin/digital/{{ .Ident }}" hx-get="/admin/digital/{{ .Ident }}" hx-target="#main">Edit</a>
+        </div>
+    </div>
+    {{ else }}
+    <p>No digital art found.</p>
+    {{ end }}
+</div>
+{{ end }}
+
+{{ define "digital_art_admin_editor" }}
+<div class="container p-3 rounded" style="max-width: 80vw; background-color: rgb(22, 22, 22); color: white; font-family: monospace;">
+    <h1>Edit Digital Art</h1>
+    <a href="/admin/digital" hx-get="/admin/digital" hx-target="#main">Back to digital art</a>
+    <div class="my-3">
+        {{ if eq .MediaType "video/webm" }}
+        <video src="/api/v1/images/{{ .Ident }}" controls muted preload="metadata" aria-label="{{ .Title }}" class="img-fluid" style="max-height: 50vh;"></video>
+        {{ else }}
+        <img src="/api/v1/images/{{ .Ident }}" alt="{{ .Title }}" class="img-fluid" style="max-height: 50vh;">
+        {{ end }}
+    </div>
+    <form action="/admin/digital/{{ .Ident }}" method="post" hx-post="/admin/digital/{{ .Ident }}" hx-target="#digital-art-response" hx-swap="innerHTML" hx-on::before-swap="if (event.detail.xhr.status >= 400) event.detail.shouldSwap = true">
+        <div class="mb-3">
+            <label for="digital-art-title" class="form-label">Title</label>
+            <textarea id="digital-art-title" name="title" class="form-control" required>{{ .Title }}</textarea>
+        </div>
+        <div class="mb-3">
+            <label for="digital-art-description" class="form-label">Description</label>
+            <textarea id="digital-art-description" name="description" class="form-control" rows="5">{{ .Desc }}</textarea>
+        </div>
+        <button type="submit">Save changes</button>
+        <div id="digital-art-response" role="status" aria-live="polite"></div>
+    </form>
+    <div class="mt-4">
+        <button type="button" hx-delete="/admin/digital/{{ .Ident }}" hx-target="#main" hx-swap="innerHTML" hx-confirm="Remove this listing from the digital art page? The media file and links in posts will remain available." hx-on::before-swap="if (event.detail.xhr.status >= 400) event.detail.shouldSwap = true">Remove listing</button>
+        <p>Removes it from the gallery; the media remains available to posts.</p>
+    </div>
+</div>
+{{ end }}

+ 1 - 1
pkg/webpages/html/upload.html

@@ -24,7 +24,7 @@
                         <textarea name="description" wrap="soft" required="required" placeholder="What would you like to say about it?"></textarea>
                     </div>
                     <div class="row container p-2 m-2">
-                        <input type='file' name='file'>
+                        <input type='file' name='file' accept='image/*,video/webm'>
                     </div>
                     <div class="row container p-2 m-2">
                         <button>

+ 11 - 0
pkg/webpages/pages_test.go

@@ -82,3 +82,14 @@ func TestReadToString(t *testing.T) {
 	assert.Equal(t, string(data), got)
 
 }
+
+func TestVideoHighlightUsesCardHover(t *testing.T) {
+	template := ReadToString(content, "html/digital_art.html")
+	css := ReadToString(content, "cdn/custom.css")
+	for _, want := range []string{"media-card", "video-highlight", "autoplay muted loop", "{{ .Title }}", "{{ .Desc }}"} {
+		assert.Contains(t, template, want)
+	}
+	for _, want := range []string{".media-card:hover .video-highlight", ".media-card:focus-within .video-highlight", "pointer-events: auto", "opacity: 1"} {
+		assert.Contains(t, css, want)
+	}
+}