diff options
39 files changed, 1373 insertions, 392 deletions
diff --git a/.env.example b/.env.example index bc29fa2..ee6d25f 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,6 @@ JWT_SECRET=change-me-to-a-long-random-string SUPERADMIN_USERNAME=admin SUPERADMIN_PASSWORD=change-me POSTGRES_PASSWORD=change-me-too -MAX_UPLOAD_MB=5 +MAX_UPLOAD_MB=10 # Only when running the dashboard on a non-standard port (dev): appended to generated blog links. #PUBLIC_PORT=8080 @@ -22,12 +22,14 @@ table and deployment notes. needs JS, keep it small and give it a sensible fallback where cheap (e.g. the `<noscript>` button in `posts.html`). - Server-rendered `html/template`; no SPA, no frontend toolchain. -- Postgres holds **everything**, including images (`images.data bytea`). +- Postgres holds **everything**, including uploaded files (`files.data bytea`, + `STORAGE EXTERNAL`, served in `substring()` slices so a download never + loads the whole blob). **Each blog is its own database** (`blog_<sub>`, see Key mechanics) so a blog is backed up and restored with plain `pg_dump`/`psql`; the control database (`DATABASE_URL`) holds only `users` and the `blogs` registry. - Posts are Markdown (goldmark → bluemonday). No WYSIWYG. The editor's - "Insert image" is the one scripted convenience (`partials/editor.html`); + "Insert file" is the one scripted convenience (`partials/editor.html`); it must keep its no-JS fallback (file appended on save). - Changes are live immediately — there is no draft/preview system. The UX is "save, then refresh your blog tab"; keep the "View blog ↗" links. @@ -49,14 +51,15 @@ internal/config/ env → Config; RootSubdomain = "www" internal/db/ Cluster (control pool + lazy per-blog pools, CREATE/DROP DATABASE), goose providers; migrations/control/*.sql, migrations/blog/*.sql internal/store/ Store = control DB (users, blog registry, create/delete blog); - BlogStore = one blog's DB (settings, pages, posts, images, sections, modules, menu) + BlogStore = one blog's DB (settings, pages, posts, files, sections, modules, menu) internal/auth/ bcrypt, JWT issue/parse, cookie, HMAC CSRF internal/markdown/ Render(md) → sanitized HTML internal/i18n/ languages, T/Tf (English keys → catalog), FormatDate/Month, Accept-Language Match; el.go is the Greek catalog, el_months.go the month tables internal/slug/ Make/Valid/WithSuffix (Greek transliteration included) internal/web/ server.go (host router, middleware, render helpers) - routes.go (all routes), handlers_*.go (sections = announcements, layout = modules + menu) + routes.go (all routes), handlers_*.go (sections = announcements, layout = modules + menu, + files = the upload library), filetype.go (what an upload is, how it may be served) theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping) templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/) static/ (dashboard.css "paper & ink" look — plain CSS, no variables/flex; @@ -67,7 +70,7 @@ internal/web/ server.go (host router, middleware, render helpers) - **One database per blog**: the registry row (`blogs`: id, owner, subdomain, `db_name`) lives in the control DB; everything else — a one-row `settings` - table (title, tagline, language, theme) plus pages, posts, images, sections, modules, + table (title, tagline, language, theme) plus pages, posts, files, sections, modules, menu_items — lives in `blog_<sub>` (`db.DBName`: dashes → underscores, so subdomains are capped at 58 chars). No table in a blog DB carries a blog id; the database is the scope. `store.Store` (control) hands out a @@ -103,9 +106,16 @@ internal/web/ server.go (host router, middleware, render helpers) 7 days). Claims carry `uid` + `ver` (= `users.token_version`); the session middleware re-loads the user every request and drops the session if disabled or version mismatch. Password change / reset / disable bump - `token_version`. Every POST behind `requireAuth` must include + `token_version`. Every management POST must include `<input type="hidden" name="_csrf" value="{{.CSRF}}">`; the check runs in - `requireAuth`, which also caps the body at `MAX_UPLOAD_MB + 1 MB`. + `guardPOST`, which first caps the body at the limit it is given + 1 MB + and parses the form. `requireLogin` only redirects anonymous users; + `requireAuth` = login + `guardPOST(0)` (dashboard, password, admin: no + uploads); `withBlog` = login → `resolveBlog` → owner-or-superadmin → + `guardPOST(blog.UploadLimit(cfg))`, and `withBlogFiles(n, …)` allows n + limits for the Files page's multi-upload (`maxUploadFiles` = 10). Errors + from `guardPOST` go through `s.fail`, which answers JSON when the request + has `Accept: application/json` (the upload scripts). - **Templates**: each page file is parsed together with its layout (`layouts/dashboard.html` or `layouts/blog.html` for `blog/*`) and all `partials/*.html`. Page files define `content` (and optionally `title`). @@ -126,7 +136,8 @@ internal/web/ server.go (host router, middleware, render helpers) Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come from a colour input paired with a `<name>_custom` checkbox, shown/hidden with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields - use the `imagepick` partial's radios: `none` clears, a uuid selects, no + use the `imagepick` partial's radios (the library's `kind = image` files, + `ListFiles(ctx, "image", "", 0, 0)`): `none` clears, a uuid selects, no value keeps (`pickImage`); an upload in `<name>_file` wins. `Presets()`/`WithPreset` are the colour schemes (`POST /b/{sub}/design/preset`, colours only); `POST /b/{sub}/design/reset` stores `DefaultTheme()` and @@ -166,20 +177,44 @@ internal/web/ server.go (host router, middleware, render helpers) blog layout links instead. `GET /favicon.ico` on every host (`handleFavicon`) serves the png or redirects to the blog's image, so icon-probing browsers never hit the 404 page; `favicon.ico` is reserved. -- **Images**: `/media/{uuid}` served on every host with immutable cache - headers, from the host's blog database only (the root domain serves the - root blog's images). Dashboard previews for another blog on the root host - therefore use `GET /b/{sub}/media/{id}` (`withBlog`): `dashboard/images.html` - and the `imagepick` partial (`sub` arg). Markdown keeps the relative - `/media/…` form because post bodies render on the blog host. - Uploads are content-sniffed (png/jpeg/gif/webp/ico). Deleting an - image clears theme references to it. `POST /b/{sub}/images/upload` - answers JSON (`{id, filename, markdown}` / `{error}`) when the request - has `Accept: application/json`; that is what the editor script calls. +- **Files** (`files` table, `store/files.go`, `handlers_files.go`, + `filetype.go`, `/b/{sub}/files…`): the upload library, any type. A row is + `id, filename, content_type, kind, size, data`; `kind` (image, document, + audio, video, archive, other) is decided at upload by `fileType`, which + trusts `http.DetectContentType` first and lets the extension refine only a + generic sniff (text/plain, octet-stream) to a type on the allowlists in + `filetype.go`; anything unknown is stored as `application/octet-stream`. + `/media/{uuid}` is served on every host with immutable cache headers, from + the host's blog database only (the root domain serves the root blog's + files); dashboard previews for another blog on the root host use + `GET /b/{sub}/media/{id}` (`withBlog`). **The root domain carries the + session cookie, so `servedAs` renders inline only images, PDF, plain text, + audio and video; everything else (HTML, SVG, XML, JS, archives, binaries) + goes out as `application/octet-stream` + `Content-Disposition: attachment`.** + `?download` forces attachment. SVG is therefore never an image (download + only, and refused by the design page's `readUpload(…, imagesOnly)`); ICO + is. The bytes are streamed by `BlogStore.FileReader` (a `chunkReader` over + `substring()`, 512 KiB per query, Range requests included), which is why + the per-blog limit is capped at 1024 MB (`maxUploadMB`: int4 offsets). + Ids are immutable, so a renamed file keeps its old download name in + browsers that cached it. Markdown keeps the relative `/media/…` form + because post bodies render on the blog host; `fileMarkdown` writes + `` for images and `[name](…)` for the rest. Deleting a file + clears theme references to it. `POST /b/{sub}/files/upload` takes several + `file` parts (the no-JS `<input multiple>`), or answers JSON + (`{id, filename, kind, size, markdown}` / `{error}`) for one file when the + request has `Accept: application/json` — what the editor and the Files + page scripts call. `dashboard/files.html` lists by `?kind=&q=&p=` + (`ListFiles`, 50 per page, `pageBounds` clamps), shows `FileUsage` and the + blog's limit; rename/delete return to the `back` field. The per-blog limit + is `blogs.max_upload_bytes` in the control DB (NULL = `MAX_UPLOAD_MB`, now + 10), set at `POST /admin/blogs/{id}/upload-limit` from `admin/index.html`; + `Blog.UploadLimit(cfg)` resolves it. - **Editor** (`partials/editor.html`, args via `dict`: name, value, rows, - tall, upload, csrf): textarea + "Insert image" + cheat-sheet. Forms using - it must be `multipart/form-data` and their save handler must call - `s.readUpload(r, "inline_image")` + `appendImageMD` (the no-JS path). + tall, upload, csrf): textarea + "Insert file" + cheat-sheet; paste and + drop take any file. Forms using it must be `multipart/form-data` and + their save handler must call `s.readUpload(r, "inline_file", false)` + + `appendFileMD` (the no-JS path). - **Announcements** (`sections` table, `store/sections.go`, `handlers_sections.go`, `/b/{sub}/announcements…`): per-blog notices with `placement` (`<column>-<position>`: left|main|right × top|bottom, split by @@ -253,7 +288,8 @@ superadmin password to `admin`. Production refuses both. (runs in every blog database — it must not reference `users`/`blogs`) and `internal/db/migrations/control/` for users and the registry; goose `-- +goose Up/Down` sections; they run automatically at startup. Never - edit an applied migration. Blog chain so far: `00001_init`, `00002_language`. Both chains were re-baselined at 00001 after + edit an applied migration. Blog chain so far: `00001_init`, `00002_language`, + `00003_files`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after the move to per-blog databases; deployments from before it have `goose_db_version` rows 2–7 in the control DB that must be deleted once (README "Upgrading from a single database") or the next control migration @@ -4,9 +4,12 @@ A small multi-tenant blog host. One Go binary + Postgres. Bloggers log in at `example.com` to manage their blog; each blog is served at `<name>.example.com`. Server-rendered HTML, no JavaScript required, works on old browsers and phones. -- Posts and page intros are written in **Markdown** (sanitized on save). Images can be - inserted straight from the editor (file picker, paste or drag-and-drop; with JavaScript - off the file is appended on save). +- Posts and page intros are written in **Markdown** (sanitized on save). Files of any + kind — images, PDFs, archives, audio… — can be inserted straight from the editor (file + picker, paste or drag-and-drop; with JavaScript off the file is appended on save): + images are shown, everything else becomes a download link. The **Files** tab lists + them by kind with search, rename and delete. Uploads are 10 MB per file by default; + the superadmin can set a different limit per blog. - **Announcements**: blog-wide notices (next meeting, this month's book, a closure) shown at the top or bottom of the main content or of a side column; each can be hidden without deleting. - Every blog has **pages** (Home, About, News, …); each page holds posts. @@ -22,7 +25,7 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. the 404 page. What the blogger writes is never translated. - A **superadmin** creates bloggers, resets passwords, disables or deletes accounts. - The **root domain is itself a blog**, owned by the superadmin and managed like any other. -- **Each blog is its own Postgres database** (`blog_<name>`), images included, so one +- **Each blog is its own Postgres database** (`blog_<name>`), uploaded files included, so one `pg_dump` is a complete backup of a blog and one `psql` restores it. A small control database holds the users and the list of blogs. @@ -64,7 +67,7 @@ Go changes need a restart. | `DATABASE_URL` | local dev DSN | Connection string of the **control** database; blog databases are created next to it by the same role | | `JWT_SECRET` | — | **Required** outside dev; long random string (`openssl rand -hex 32`) | | `SUPERADMIN_USERNAME` / `SUPERADMIN_PASSWORD` | `admin` / — | Created on first start if no superadmin exists | -| `MAX_UPLOAD_MB` | `5` | Image upload limit | +| `MAX_UPLOAD_MB` | `10` | Per-file upload limit; the superadmin can override it per blog in `/admin/` | | `DEV` | `false` | Hot-reload templates, allow missing secrets | Migrations run automatically at startup, for the control database and for every blog database. @@ -87,7 +90,7 @@ nginx example: server { listen 443 ssl; server_name example.com *.example.com; # wildcard certificate - client_max_body_size 8m; # >= MAX_UPLOAD_MB + client_max_body_size 101m; # the Files page sends up to 10 files per request: >= 10 x the largest blog limit + 1 MB location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; @@ -104,7 +107,7 @@ certificates need the DNS challenge). Every blog lives in its own database, `blog_<name>` (dashes become underscores: `my-blog` → `blog_my_blog`). The control database (`blogspace`) holds the users -and the blog registry. Images are in the blog database, so one dump is the whole blog. +and the blog registry. Uploaded files are in the blog database, so one dump is the whole blog. ```sh # one blog @@ -136,7 +139,8 @@ the release that contains the split once (it moves each blog into its own database at start-up), then clear the old migration history so later migrations apply: `docker compose exec db psql -U blogspace -c "DELETE FROM goose_db_version WHERE version_id > 1"`. Current releases no longer carry the -split code. Blog pools are small (4 connections each, closed when +split code, and the first control migration after the split (the per-blog +upload limit) is refused as "missing" until that history is cleared. Blog pools are small (4 connections each, closed when idle); with many blogs busy at once, raise `max_connections` on the `db` service. ## Layout diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go index f69a774..05c54f8 100644 --- a/cmd/blogspace/seed.go +++ b/cmd/blogspace/seed.go @@ -77,7 +77,12 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { if err := png.Encode(&buf, img); err != nil { return err } - bg, err := bs.CreateImage(ctx, "gradient.png", "image/png", buf.Bytes()) + bg, err := bs.CreateFile(ctx, "gradient.png", "image/png", "image", buf.Bytes()) + if err != nil { + return err + } + // and a plain text file, so the Files tab shows more than images + notes, err := bs.CreateFile(ctx, "notes.txt", "text/plain", "document", []byte("Reading list:\n- The Go Programming Language\n- Practical Vim\n")) if err != nil { return err } @@ -107,7 +112,7 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { } } posts := []struct{ page, title, body string }{ - {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n + ")\n\nThat image above was uploaded through the dashboard."}, + {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n + ")\n\nThat image above was uploaded through the dashboard, and here is a file to download: [notes.txt](/media/" + notes.ID.String() + ")."}, {"home", "Why I still like plain HTML", "No frameworks, no build step. Just `<p>` tags and a stylesheet.\n\n> Simplicity is prerequisite for reliability. — Dijkstra\n\n```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```"}, {"news", "Site is up", "The blog is live. Expect sporadic updates."}, {"news", "Tomato season", "First ripe tomato of the year! Table for the record:\n\n| Variety | Days |\n|---|---|\n| Cherry | 62 |\n| Beefsteak | 85 |"}, diff --git a/internal/config/config.go b/internal/config/config.go index 7a3a79c..a239aa2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,8 +17,8 @@ type Config struct { // Bootstrap superadmin created on first start if no superadmin exists. SuperadminUsername string SuperadminPassword string - MaxUploadBytes int64 - Dev bool // reload templates/static from disk + MaxUploadBytes int64 // default per-file limit; the superadmin can override it per blog + Dev bool // reload templates/static from disk } func Load() (*Config, error) { @@ -32,7 +32,7 @@ func Load() (*Config, error) { SuperadminPassword: env("SUPERADMIN_PASSWORD", ""), Dev: envBool("DEV", false), } - mb, err := strconv.Atoi(env("MAX_UPLOAD_MB", "5")) + mb, err := strconv.Atoi(env("MAX_UPLOAD_MB", "10")) if err != nil || mb <= 0 { return nil, fmt.Errorf("MAX_UPLOAD_MB must be a positive integer") } diff --git a/internal/db/migrations/blog/00003_files.sql b/internal/db/migrations/blog/00003_files.sql new file mode 100644 index 0000000..2ed8820 --- /dev/null +++ b/internal/db/migrations/blog/00003_files.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- The image library becomes a file library: any type is accepted and files are +-- served in slices (substring), so a download never loads the whole blob. +ALTER TABLE images RENAME TO files; +ALTER TABLE files RENAME CONSTRAINT images_pkey TO files_pkey; +ALTER INDEX images_created RENAME TO files_created; +-- Out of line and uncompressed, so substring() reads only the chunks it needs +-- (uploads are mostly jpeg/zip/pdf, which pglz would not shrink anyway). +ALTER TABLE files ALTER COLUMN data SET STORAGE EXTERNAL; +ALTER TABLE files + ADD COLUMN kind text NOT NULL DEFAULT 'image' + CHECK (kind IN ('image', 'document', 'audio', 'video', 'archive', 'other')), + ALTER COLUMN size TYPE bigint; +ALTER TABLE files ALTER COLUMN kind DROP DEFAULT; -- everything so far was an image +CREATE INDEX files_kind_created ON files (kind, created_at DESC); +-- SET STORAGE only applies to values written from now on; re-store the existing +-- rows (`data = data` would keep the old TOAST pointer, the concatenation does not). +UPDATE files SET data = data || ''::bytea; + +-- +goose Down +DROP INDEX files_kind_created; +ALTER TABLE files DROP COLUMN kind, ALTER COLUMN size TYPE integer, ALTER COLUMN data SET STORAGE EXTENDED; +ALTER INDEX files_created RENAME TO images_created; +ALTER TABLE files RENAME CONSTRAINT files_pkey TO images_pkey; +ALTER TABLE files RENAME TO images; diff --git a/internal/db/migrations/control/00002_upload_limit.sql b/internal/db/migrations/control/00002_upload_limit.sql new file mode 100644 index 0000000..9a1ec90 --- /dev/null +++ b/internal/db/migrations/control/00002_upload_limit.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- Per-blog upload limit set by the superadmin; NULL means the server default (MAX_UPLOAD_MB). +ALTER TABLE blogs ADD COLUMN max_upload_bytes bigint CHECK (max_upload_bytes IS NULL OR max_upload_bytes > 0); + +-- +goose Down +ALTER TABLE blogs DROP COLUMN max_upload_bytes; diff --git a/internal/i18n/el.go b/internal/i18n/el.go index eaaab5f..1fe5860 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -18,7 +18,6 @@ var el = map[string]string{ "Announcements": "Ανακοινώσεις", "Layout": "Διάταξη", "Design": "Σχεδίαση", - "Images": "Εικόνες", "Settings": "Ρυθμίσεις", "View blog": "Προβολή ιστολογίου", "View page": "Προβολή σελίδας", @@ -70,7 +69,6 @@ var el = map[string]string{ "Post an announcement": "Νέα ανακοίνωση", "Arrange the layout": "Ρύθμιση διάταξης", "Change the look": "Αλλαγή εμφάνισης", - "Upload images": "Μεταφόρτωση εικόνων", "%d posts": "%d δημοσιεύσεις", "hidden from menu": "εκτός μενού", "Latest posts": "Τελευταίες δημοσιεύσεις", @@ -95,7 +93,6 @@ var el = map[string]string{ "Enter the date as YYYY-MM-DD HH:MM.": "Γράψτε την ημερομηνία ως ΕΕΕΕ-ΜΜ-ΗΗ ΩΩ:ΛΛ.", "A post with that slug already exists on this page; choose another slug.": "Υπάρχει ήδη δημοσίευση με αυτή τη διεύθυνση στη σελίδα· διαλέξτε άλλη.", "Saved. Refresh your blog to see it.": "Αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να το δείτε.", - "Image not added:": "Η εικόνα δεν προστέθηκε:", // ---- pages ---- "New page": "Νέα σελίδα", @@ -156,23 +153,55 @@ var el = map[string]string{ "Write some text for the announcement.": "Γράψτε κάποιο κείμενο για την ανακοίνωση.", "Announcement is too long (20 KB max).": "Η ανακοίνωση είναι πολύ μεγάλη (έως 20 KB).", - // ---- images ---- - "Images are stored with your blog. Put one in a post with": "Οι εικόνες αποθηκεύονται μαζί με το ιστολόγιό σας. Βάλτε μία σε δημοσίευση με το", - "in the editor, or copy its line from below.": "στον επεξεργαστή κειμένου, ή αντιγράψτε τη γραμμή της από παρακάτω.", - "Upload an image": "Μεταφόρτωση εικόνας", - "PNG, JPEG, GIF, WebP or ICO": "PNG, JPEG, GIF, WebP ή ICO", - "No images yet.": "Δεν υπάρχουν εικόνες ακόμα.", - "Uploaded %s.": "Η εικόνα %s μεταφορτώθηκε.", - "Image deleted.": "Η εικόνα διαγράφηκε.", - "Choose a file first.": "Επιλέξτε πρώτα ένα αρχείο.", - "Upload too large or malformed form.": "Η μεταφόρτωση είναι πολύ μεγάλη ή η φόρμα ελαττωματική.", - "Image is too large (max %s).": "Η εικόνα είναι πολύ μεγάλη (έως %s).", - "Only PNG, JPEG, GIF, WebP and ICO images are accepted.": "Γίνονται δεκτές μόνο εικόνες PNG, JPEG, GIF, WebP και ICO.", + // ---- files ---- + "Files": "Αρχεία", + "Upload files": "Μεταφόρτωση αρχείων", + "Files are stored with your blog. Put one in a post with": "Τα αρχεία αποθηκεύονται μαζί με το ιστολόγιό σας. Βάλτε ένα σε δημοσίευση με το", + "in the editor, or copy its line from the table.": "στον επεξεργαστή κειμένου, ή αντιγράψτε τη γραμμή του από τον πίνακα.", + "%d files, %s in use.": "%d αρχεία, %s σε χρήση.", + "any type, up to %s each": "κάθε τύπου, έως %s το καθένα", + "…or drop files anywhere on this box.": "…ή σύρετε αρχεία οπουδήποτε σε αυτό το πλαίσιο.", + "uploading…": "μεταφόρτωση…", + "uploaded": "μεταφορτώθηκε", + "All": "Όλα", + "Images": "Εικόνες", + "Documents": "Έγγραφα", + "Audio": "Ήχος", + "Video": "Βίντεο", + "Archives": "Συμπιεσμένα", + "Other": "Άλλα", + "Search files": "Αναζήτηση αρχείων", + "Search": "Αναζήτηση", + "Name": "Όνομα", + "Kind": "Είδος", + "Size": "Μέγεθος", + "rename": "μετονομασία", + "download": "λήψη", + "No files yet.": "Δεν υπάρχουν αρχεία ακόμα.", + "No files match.": "Κανένα αρχείο δεν ταιριάζει.", + "Uploaded %s.": "Το αρχείο %s μεταφορτώθηκε.", + "Uploaded %d files.": "Μεταφορτώθηκαν %d αρχεία.", + "Renamed to %s.": "Μετονομάστηκε σε %s.", + "File deleted.": "Το αρχείο διαγράφηκε.", + "Choose a file first.": "Επιλέξτε πρώτα ένα αρχείο.", + "At most %d files at a time.": "Έως %d αρχεία τη φορά.", + "File is too large (max %s).": "Το αρχείο είναι πολύ μεγάλο (έως %s).", + "File is empty.": "Το αρχείο είναι κενό.", + "Only PNG, JPEG, GIF, WebP and ICO images can be used here.": "Εδώ μπορούν να χρησιμοποιηθούν μόνο εικόνες PNG, JPEG, GIF, WebP και ICO.", + "Upload limit": "Όριο μεταφόρτωσης", + "default": "προεπιλογή", + "change": "αλλαγή", + "blank = default": "κενό = προεπιλογή", + "Enter a whole number of MB (1-%d), or leave blank for the default.": "Δώστε ακέραιο αριθμό MB (1-%d), ή αφήστε κενό για την προεπιλογή.", + "Upload limit for %s set to %d MB.": "Το όριο μεταφόρτωσης για το %s ορίστηκε στα %d MB.", + "Upload limit for %s reset to the default.": "Το όριο μεταφόρτωσης για το %s επανήλθε στην προεπιλογή.", // ---- editor ---- - "Insert image": "Εισαγωγή εικόνας", - "…or paste / drop an image into the text.": "…ή επικολλήστε / σύρετε μια εικόνα μέσα στο κείμενο.", - "The image is added at the end of the text when you save.": "Η εικόνα προστίθεται στο τέλος του κειμένου όταν αποθηκεύσετε.", + "Insert file": "Εισαγωγή αρχείου", + "…or paste / drop a file into the text. Images are shown, other files linked.": "…ή επικολλήστε / σύρετε ένα αρχείο μέσα στο κείμενο. Οι εικόνες εμφανίζονται, τα άλλα αρχεία γίνονται σύνδεσμοι.", + "The file is added at the end of the text when you save.": "Το αρχείο προστίθεται στο τέλος του κειμένου όταν αποθηκεύσετε.", + "File not added:": "Το αρχείο δεν προστέθηκε:", + "file name": "όνομα αρχείου", "Uploading": "Μεταφόρτωση", "upload failed": "η μεταφόρτωση απέτυχε", "Inserted": "Προστέθηκε η", @@ -398,7 +427,7 @@ var el = map[string]string{ "Delete user": "Διαγραφή χρήστη", "Delete %s?": "Διαγραφή του χρήστη %s;", "This permanently deletes the user": "Διαγράφεται οριστικά ο χρήστης", - ", their blog, and every page, post and image in it.": ", το ιστολόγιό του και κάθε σελίδα, δημοσίευση και εικόνα σε αυτό.", + ", their blog, and every page, post and file in it.": ", το ιστολόγιό του και κάθε σελίδα, δημοσίευση και αρχείο σε αυτό.", "Yes, delete everything": "Ναι, διαγραφή όλων", "Username: 2-40 letters, digits, dots, dashes or underscores.": "Όνομα χρήστη: 2-40 λατινικά γράμματα, ψηφία, τελείες, παύλες ή κάτω παύλες.", "Password must be at least 8 characters.": "Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες.", diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go index 7240afc..6bc7e48 100644 --- a/internal/markdown/render_test.go +++ b/internal/markdown/render_test.go @@ -6,13 +6,13 @@ import ( ) func TestRenderSanitizes(t *testing.T) { - out := Render("# Hi\n\n**bold** <script>alert(1)</script> <a href=\"javascript:alert(1)\">x</a> <img src=\"/media/abc\" onerror=\"x()\">") + out := Render("# Hi\n\n**bold** <script>alert(1)</script> <a href=\"javascript:alert(1)\">x</a> <img src=\"/media/abc\" onerror=\"x()\"> [notes.pdf](/media/abc)") for _, bad := range []string{"<script", "javascript:", "onerror"} { if strings.Contains(out, bad) { t.Errorf("output contains %q: %s", bad, out) } } - for _, good := range []string{"<h1", "<strong>bold</strong>", `<img src="/media/abc"`} { + for _, good := range []string{"<h1", "<strong>bold</strong>", `<img src="/media/abc"`, `<a href="/media/abc"`} { if !strings.Contains(out, good) { t.Errorf("output missing %q: %s", good, out) } diff --git a/internal/store/blogs.go b/internal/store/blogs.go index 3117154..579d73d 100644 --- a/internal/store/blogs.go +++ b/internal/store/blogs.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/gramanas/blogspace/internal/config" "github.com/gramanas/blogspace/internal/db" "github.com/jackc/pgx/v5" ) @@ -19,6 +20,8 @@ type Blog struct { Subdomain string DBName string CreatedAt time.Time + // Per-file upload limit set by the superadmin; 0 means the server default. + MaxUploadBytes int64 // from the blog database Title string Tagline string @@ -27,17 +30,25 @@ type Blog struct { UpdatedAt time.Time } -const blogCols = `id, owner_id, subdomain, db_name, created_at` +const blogCols = `id, owner_id, subdomain, db_name, created_at, COALESCE(max_upload_bytes, 0)` func scanBlog(row interface{ Scan(...any) error }) (*Blog, error) { var b Blog - err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.DBName, &b.CreatedAt) + err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.DBName, &b.CreatedAt, &b.MaxUploadBytes) if err != nil { return nil, wrap(err) } return &b, nil } +// UploadLimit is the per-file limit that applies to this blog. +func (b *Blog) UploadLimit(cfg *config.Config) int64 { + if b.MaxUploadBytes > 0 { + return b.MaxUploadBytes + } + return cfg.MaxUploadBytes +} + func (bs *BlogStore) loadSettings(ctx context.Context, b *Blog) error { err := bs.db.QueryRow(ctx, `SELECT title, tagline, language, theme, updated_at FROM settings`).Scan(&b.Title, &b.Tagline, &b.Language, &b.ThemeJSON, &b.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { // registered, but the database is empty: not a 404 @@ -174,6 +185,12 @@ func (s *Store) DeleteBlog(ctx context.Context, b *Blog) error { return s.cluster.DropBlogDB(ctx, b.DBName) } +// SetBlogUploadLimit overrides the upload limit of one blog; 0 restores the server default. +func (s *Store) SetBlogUploadLimit(ctx context.Context, id int64, bytes int64) error { + _, err := s.db.Exec(ctx, `UPDATE blogs SET max_upload_bytes = NULLIF($2, 0) WHERE id=$1`, id, bytes) + return err +} + func (bs *BlogStore) UpdateSettings(ctx context.Context, title, tagline, language string) error { _, err := bs.db.Exec(ctx, `UPDATE settings SET title=$1, tagline=$2, language=$3, updated_at=now()`, title, tagline, language) return err diff --git a/internal/store/files.go b/internal/store/files.go new file mode 100644 index 0000000..287b4d7 --- /dev/null +++ b/internal/store/files.go @@ -0,0 +1,174 @@ +package store + +import ( + "context" + "errors" + "io" + "path" + "strings" + "time" + + "github.com/google/uuid" +) + +// File is an upload in the blog's library: an image, a document, an archive… +// The bytes are never loaded with it; FileReader streams them. +type File struct { + ID uuid.UUID + Filename string + ContentType string + Kind string // image, document, audio, video, archive, other (CHECK in the schema) + Size int64 + CreatedAt time.Time +} + +// Badge is the short label the dashboard shows instead of a thumbnail: the +// extension in capitals, or FILE when there is none. +func (f File) Badge() string { + ext := strings.ToUpper(strings.TrimPrefix(path.Ext(f.Filename), ".")) + if ext == "" || len([]rune(ext)) > 5 { + return "FILE" + } + return ext +} + +const fileCols = `id, filename, content_type, kind, size, created_at` + +func scanFile(row interface{ Scan(...any) error }) (*File, error) { + var f File + if err := row.Scan(&f.ID, &f.Filename, &f.ContentType, &f.Kind, &f.Size, &f.CreatedAt); err != nil { + return nil, wrap(err) + } + return &f, nil +} + +func (bs *BlogStore) CreateFile(ctx context.Context, filename, contentType, kind string, data []byte) (*File, error) { + f := &File{ID: uuid.New(), Filename: filename, ContentType: contentType, Kind: kind, Size: int64(len(data))} + err := bs.db.QueryRow(ctx, `INSERT INTO files (id, filename, content_type, kind, size, data) VALUES ($1,$2,$3,$4,$5,$6) RETURNING created_at`, + f.ID, filename, contentType, kind, f.Size, data).Scan(&f.CreatedAt) + return f, err +} + +// likeEscape makes user text safe inside an ILIKE pattern. +var likeEscape = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + +// ListFiles is one page of the library, newest first, with the total that +// matches the same filter. kind "" is every kind, q "" no filename filter and +// limit 0 no limit (the design page's image picker). +func (bs *BlogStore) ListFiles(ctx context.Context, kind, q string, limit, offset int) ([]File, int, error) { + const where = ` WHERE ($1 = '' OR kind = $1) AND ($2 = '' OR filename ILIKE '%' || $2 || '%')` + q = likeEscape.Replace(q) + var total int + if err := bs.db.QueryRow(ctx, `SELECT count(*) FROM files`+where, kind, q).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := bs.db.Query(ctx, `SELECT `+fileCols+` FROM files`+where+` ORDER BY created_at DESC, id DESC LIMIT NULLIF($3, 0) OFFSET $4`, + kind, q, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []File + for rows.Next() { + f, err := scanFile(rows) + if err != nil { + return nil, 0, err + } + out = append(out, *f) + } + return out, total, rows.Err() +} + +// FileMeta loads a file without its bytes. +func (bs *BlogStore) FileMeta(ctx context.Context, id uuid.UUID) (*File, error) { + return scanFile(bs.db.QueryRow(ctx, `SELECT `+fileCols+` FROM files WHERE id=$1`, id)) +} + +// FileReader streams a file's bytes in slices, so serving it never holds the +// whole blob in memory (the column is STORAGE EXTERNAL: substring is cheap). +func (bs *BlogStore) FileReader(ctx context.Context, f *File) io.ReadSeeker { + return newChunkReader(f.Size, fileChunk, func(off, n int64) ([]byte, error) { + var b []byte + // substring is 1-based and its arguments are int4: the upload limit is capped so offsets fit. + err := bs.db.QueryRow(ctx, `SELECT substring(data FROM $2 FOR $3) FROM files WHERE id=$1`, f.ID, int32(off+1), int32(n)).Scan(&b) + return b, wrap(err) + }) +} + +func (bs *BlogStore) RenameFile(ctx context.Context, id uuid.UUID, filename string) error { + tag, err := bs.db.Exec(ctx, `UPDATE files SET filename=$2 WHERE id=$1`, id, filename) + if err == nil && tag.RowsAffected() == 0 { + return ErrNotFound + } + return err +} + +func (bs *BlogStore) DeleteFile(ctx context.Context, id uuid.UUID) error { + _, err := bs.db.Exec(ctx, `DELETE FROM files WHERE id=$1`, id) + return err +} + +// FileUsage is what the library holds: how many files and their bytes. +func (bs *BlogStore) FileUsage(ctx context.Context) (count int, bytes int64, err error) { + err = bs.db.QueryRow(ctx, `SELECT count(*), COALESCE(sum(size), 0) FROM files`).Scan(&count, &bytes) + return count, bytes, err +} + +// fileChunk is how much of a file one query fetches: 20 round trips for a +// 10 MB download, and a bounded buffer per request whatever the file size. +const fileChunk = 512 << 10 + +// chunkReader is an io.ReadSeeker over bytes fetched in slices, which is what +// http.ServeContent needs to stream a file and honour Range requests. fetch +// returns data[off:off+n]. Seek never fetches: ServeContent seeks to the end +// and back to learn the size before reading anything. +type chunkReader struct { + size, off int64 + chunk int64 + fetch func(off, n int64) ([]byte, error) + buf []byte // data[bufOff : bufOff+len(buf)] + bufOff int64 +} + +func newChunkReader(size, chunk int64, fetch func(off, n int64) ([]byte, error)) *chunkReader { + return &chunkReader{size: size, chunk: chunk, fetch: fetch} +} + +func (r *chunkReader) Read(p []byte) (int, error) { + if r.off >= r.size { + return 0, io.EOF + } + if r.buf == nil || r.off < r.bufOff || r.off >= r.bufOff+int64(len(r.buf)) { + n := min(r.chunk, r.size-r.off) + b, err := r.fetch(r.off, n) + if err != nil { + return 0, err + } + if int64(len(b)) < n { // shorter than the row said: the file changed underneath us + return 0, io.ErrUnexpectedEOF + } + r.buf, r.bufOff = b, r.off + } + n := copy(p, r.buf[r.off-r.bufOff:]) + r.off += int64(n) + return n, nil +} + +func (r *chunkReader) Seek(offset int64, whence int) (int64, error) { + var abs int64 + switch whence { + case io.SeekStart: + abs = offset + case io.SeekCurrent: + abs = r.off + offset + case io.SeekEnd: + abs = r.size + offset + default: + return 0, errors.New("chunkReader: invalid whence") + } + if abs < 0 { + return 0, errors.New("chunkReader: negative position") + } + r.off = abs + return abs, nil +} diff --git a/internal/store/files_test.go b/internal/store/files_test.go new file mode 100644 index 0000000..dfe7d55 --- /dev/null +++ b/internal/store/files_test.go @@ -0,0 +1,69 @@ +package store + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// A reader over an in-memory slice, counting the fetches a query would cost. +func testReader(data []byte, chunk int64) (*chunkReader, *int) { + calls := 0 + return newChunkReader(int64(len(data)), chunk, func(off, n int64) ([]byte, error) { + calls++ + return data[off : off+n], nil + }), &calls +} + +func TestChunkReader(t *testing.T) { + data := make([]byte, 3<<20+7) + for i := range data { + data[i] = byte(i * 31) + } + r, calls := testReader(data, 1000) + got, err := io.ReadAll(r) + if err != nil || !bytes.Equal(got, data) { + t.Fatalf("ReadAll: err %v, %d bytes", err, len(got)) + } + if want := (len(data) + 999) / 1000; *calls != want { + t.Errorf("%d fetches, want %d", *calls, want) + } + if n, _ := r.Seek(0, io.SeekEnd); n != int64(len(data)) { + t.Errorf("SeekEnd = %d", n) + } + if _, err := r.Seek(-1, io.SeekStart); err == nil { + t.Error("negative seek should fail") + } + r.Seek(1500, io.SeekStart) + buf := make([]byte, 1200) + if _, err := io.ReadFull(r, buf); err != nil || !bytes.Equal(buf, data[1500:2700]) { + t.Errorf("read after seek: %v", err) + } + if _, err := r.Read(buf); r.off != 2700 && err != nil { + t.Errorf("continuing read: %v", err) + } +} + +func TestChunkReaderServeContent(t *testing.T) { + data := bytes.Repeat([]byte("0123456789"), 500) + r, calls := testReader(data, 512) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/media/x", nil) + req.Header.Set("Range", "bytes=1500-2499") + http.ServeContent(w, req, "x.bin", time.Now(), r) + if w.Code != http.StatusPartialContent || !bytes.Equal(w.Body.Bytes(), data[1500:2500]) || w.Header().Get("Content-Range") != "bytes 1500-2499/5000" { + t.Errorf("range: code %d, %d bytes, %s", w.Code, w.Body.Len(), w.Header().Get("Content-Range")) + } + if *calls != 2 { + t.Errorf("a 1000-byte range cost %d fetches, want 2", *calls) + } + r, calls = testReader(data, 512) + w = httptest.NewRecorder() + http.ServeContent(w, httptest.NewRequest("HEAD", "/media/x", nil), "x.bin", time.Now(), r) + if w.Code != http.StatusOK || w.Header().Get("Content-Length") != "5000" || *calls != 0 { + t.Errorf("HEAD: code %d, length %s, %d fetches", w.Code, w.Header().Get("Content-Length"), *calls) + } +} diff --git a/internal/store/images.go b/internal/store/images.go deleted file mode 100644 index b222468..0000000 --- a/internal/store/images.go +++ /dev/null @@ -1,57 +0,0 @@ -package store - -import ( - "context" - "time" - - "github.com/google/uuid" -) - -type Image struct { - ID uuid.UUID - Filename string - ContentType string - Size int - Data []byte // only populated by ImageData - CreatedAt time.Time -} - -func (bs *BlogStore) CreateImage(ctx context.Context, filename, contentType string, data []byte) (*Image, error) { - img := &Image{ID: uuid.New(), Filename: filename, ContentType: contentType, Size: len(data)} - err := bs.db.QueryRow(ctx, `INSERT INTO images (id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5) RETURNING created_at`, - img.ID, filename, contentType, len(data), data).Scan(&img.CreatedAt) - return img, err -} - -func (bs *BlogStore) ListImages(ctx context.Context) ([]Image, error) { - rows, err := bs.db.Query(ctx, `SELECT id, filename, content_type, size, created_at FROM images ORDER BY created_at DESC`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []Image - for rows.Next() { - var i Image - if err := rows.Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil { - return nil, err - } - out = append(out, i) - } - return out, rows.Err() -} - -// ImageData loads an image including its bytes. -func (bs *BlogStore) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) { - var i Image - err := bs.db.QueryRow(ctx, `SELECT id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id). - Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt) - if err != nil { - return nil, wrap(err) - } - return &i, nil -} - -func (bs *BlogStore) DeleteImage(ctx context.Context, id uuid.UUID) error { - _, err := bs.db.Exec(ctx, `DELETE FROM images WHERE id=$1`, id) - return err -} diff --git a/internal/store/users.go b/internal/store/users.go index 0c47c24..3edc7a9 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -90,13 +90,14 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error { // UserWithBlog is a row for the admin overview. type UserWithBlog struct { User - BlogID *int64 - Subdomain *string + BlogID *int64 + Subdomain *string + MaxUploadBytes int64 // 0 = server default } func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) { rows, err := s.db.Query(ctx, `SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.token_version, u.created_at, - b.id, b.subdomain + b.id, b.subdomain, COALESCE(b.max_upload_bytes, 0) FROM users u LEFT JOIN blogs b ON b.owner_id = u.id ORDER BY u.role, u.username`) if err != nil { @@ -107,7 +108,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) { for rows.Next() { var r UserWithBlog if err := rows.Scan(&r.ID, &r.Username, &r.PasswordHash, &r.Role, &r.Disabled, &r.TokenVersion, &r.CreatedAt, - &r.BlogID, &r.Subdomain); err != nil { + &r.BlogID, &r.Subdomain, &r.MaxUploadBytes); err != nil { return nil, err } out = append(out, r) diff --git a/internal/web/filetype.go b/internal/web/filetype.go new file mode 100644 index 0000000..9c0f590 --- /dev/null +++ b/internal/web/filetype.go @@ -0,0 +1,196 @@ +package web + +import ( + "mime" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "unicode" +) + +// Uploads: what a file is and how it may be served. +// +// The root domain carries the session cookie and serves every blog's files +// (/b/{sub}/media previews, the root blog's own /media), so an uploaded HTML, +// SVG or XML page must never render there. The rules below make that a +// property of the stored content type: the sniffer is trusted first, a +// filename extension may only refine a generic sniff to a type on an +// allowlist, and anything not on the inline list is a download. + +// maxUploadFiles is how many files one Files-page request may carry; the body +// cap of that route is this many upload limits. +const maxUploadFiles = 10 + +// maxUploadMB caps the superadmin's per-blog override: substring() takes int4 +// offsets, and the whole upload sits in memory while it is stored. +const maxUploadMB = 1024 + +var fileKinds = []string{"image", "document", "audio", "video", "archive", "other"} + +// fileKindNames are the tab labels (translated where used, like moduleNames). +var fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "archive": "Archives", "other": "Other"} + +var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true, "image/avif": true, "image/bmp": true} + +var documentTypes = map[string]bool{ + "application/pdf": true, "text/plain": true, "text/csv": true, "application/rtf": true, "application/epub+zip": true, + "application/msword": true, "application/vnd.ms-excel": true, "application/vnd.ms-powerpoint": true, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, + "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, + "application/vnd.oasis.opendocument.text": true, + "application/vnd.oasis.opendocument.spreadsheet": true, + "application/vnd.oasis.opendocument.presentation": true, +} + +var archiveTypes = map[string]bool{ + "application/zip": true, "application/gzip": true, "application/x-gzip": true, "application/x-7z-compressed": true, + "application/x-tar": true, "application/x-bzip2": true, "application/x-xz": true, "application/x-rar-compressed": true, "application/vnd.rar": true, +} + +var mediaTypes = map[string]bool{ + "audio/mpeg": true, "audio/mp4": true, "audio/ogg": true, "audio/flac": true, "audio/wav": true, "audio/wave": true, "audio/x-wav": true, "audio/webm": true, "audio/aac": true, + "video/mp4": true, "video/ogg": true, "video/webm": true, "video/x-matroska": true, "video/quicktime": true, +} + +// extAllowed is what an extension may turn a generic sniff into: nothing a +// browser would run. Images are deliberately absent — a real image sniffs. +func extAllowed(ct string) bool { + return documentTypes[ct] || archiveTypes[ct] || mediaTypes[ct] +} + +// fileType decides what an upload is from its first bytes and its name. The +// client's declared type is never consulted, and the result is always one of +// the known types above or application/octet-stream, so a stored content +// type is safe to serve by construction. +func fileType(head []byte, filename string) (contentType, kind string) { + ct := mediaType(http.DetectContentType(head)) + ext := strings.ToLower(path.Ext(filename)) + // The sniffer only knows containers for these. + switch { + case ct == "application/ogg": + ct = "audio/ogg" + if ext == ".ogv" { + ct = "video/ogg" + } + case ct == "video/mp4" && ext == ".m4a": + ct = "audio/mp4" + case ct == "image/vnd.microsoft.icon": + ct = "image/x-icon" + } + if ct == "text/plain" || ct == "application/octet-stream" { + switch e := mediaType(mime.TypeByExtension(ext)); { + case e == "": // unknown extension: the bytes are all we have + case extAllowed(e): + ct = e + case ct == "text/plain" && strings.HasPrefix(e, "text/") && !scriptTypes[e]: + // .md, .log, .ini…: text is text (the tables differ between machines, so the name only confirms) + default: // a name we would not serve inline (.html, .svg, .js, .exe…), whatever the bytes look like + ct = "application/octet-stream" + } + } + if !imageTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") { + ct = "application/octet-stream" // sniffed HTML/XML, fonts, and everything else we do not name + } + return ct, kindOf(ct) +} + +// scriptTypes are text types a browser would execute or interpret as markup. +var scriptTypes = map[string]bool{"text/html": true, "text/javascript": true, "text/xml": true, "text/css": true} + +// mediaType drops the parameters ("; charset=utf-8") from a content type. +func mediaType(ct string) string { + if ct == "" { + return "" + } + mt, _, err := mime.ParseMediaType(ct) + if err != nil { + return "" + } + return mt +} + +// kindOf buckets a content type for the Files page tabs. +func kindOf(ct string) string { + switch { + case imageTypes[ct]: + return "image" + case documentTypes[ct]: + return "document" + case strings.HasPrefix(ct, "audio/"): + return "audio" + case strings.HasPrefix(ct, "video/"): + return "video" + case archiveTypes[ct]: + return "archive" + } + return "other" +} + +// inlineOK says whether a browser may render the type in place. +func inlineOK(ct string) bool { + return imageTypes[ct] || ct == "application/pdf" || ct == "text/plain" || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "video/") +} + +// servedAs is the Content-Type and disposition /media answers with. +func servedAs(ct string, download bool) (ctype, disposition string) { + if !inlineOK(ct) { + return "application/octet-stream", "attachment" + } + if ct == "text/plain" { + ct = "text/plain; charset=utf-8" + } + if download { + return ct, "attachment" + } + return ct, "inline" +} + +// contentDisposition carries the filename in both the plain form (ASCII only, +// for old browsers) and the RFC 5987 one (Greek names survive). +func contentDisposition(disposition, name string) string { + ascii := strings.Map(func(r rune) rune { + if r < 0x20 || r > 0x7e || r == '"' || r == '\\' { + return '_' + } + return r + }, name) + return disposition + `; filename="` + ascii + `"; filename*=utf-8''` + url.PathEscape(name) +} + +// cleanFilename keeps only the base name a browser sent (Windows paths +// included), without control characters or quotes, at most 120 runes. +func cleanFilename(name string) string { + name = path.Base(strings.ReplaceAll(name, `\`, "/")) + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '"' || r == '\'' { + return -1 + } + return r + }, name) + name = strings.TrimSpace(name) + if r := []rune(name); len(r) > 120 { + name = string(r[:120]) + } + if name == "" || name == "." || name == "/" || name == ".." { + return "file" + } + return name +} + +// humanSize prints a byte count the way the dashboard shows it. +func humanSize(n int64) string { + if n < 1<<20 { + return strconv.FormatInt(max(1, n>>10), 10) + " KB" + } + return strings.TrimSuffix(strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64), ".0") + " MB" +} + +// pageBounds clamps a 1-based page number to the list and gives the SQL offset. +func pageBounds(total, per, n int) (offset, page, last int) { + last = max(1, (total+per-1)/per) + page = min(max(1, n), last) + return (page - 1) * per, page, last +} diff --git a/internal/web/filetype_test.go b/internal/web/filetype_test.go new file mode 100644 index 0000000..1f1d89e --- /dev/null +++ b/internal/web/filetype_test.go @@ -0,0 +1,192 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/store" +) + +func TestFileType(t *testing.T) { + png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 16)) + cases := []struct { + head string + name string + ct, kind string + }{ + {string(png), "photo.png", "image/png", "image"}, + {string(png), "evil.html", "image/png", "image"}, // bytes win over the name + {"\x00\x00\x01\x00\x01\x00", "icon.ico", "image/x-icon", "image"}, + {"%PDF-1.4 ...", "paper.pdf", "application/pdf", "document"}, + {"PK\x03\x04junk", "site.zip", "application/zip", "archive"}, + {"\x1f\x8b\x08junk", "site.tar.gz", "application/x-gzip", "archive"}, + {"ID3\x03\x00\x00\x00", "song.mp3", "audio/mpeg", "audio"}, + {"OggS\x00\x02", "song.ogg", "audio/ogg", "audio"}, + {"OggS\x00\x02", "clip.ogv", "video/ogg", "video"}, + {"\x1a\x45\xdf\xa3junk", "clip.webm", "video/webm", "video"}, + {"hello world", "notes.txt", "text/plain", "document"}, + {"hello world", "server.log", "text/plain", "document"}, // text/* names only confirm the sniff + {"hello world", "notes.md", "text/plain", "document"}, + {"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have + {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"}, + {"hello world", "data.csv", "text/csv", "document"}, + {"hello world", "evil.html", "application/octet-stream", "other"}, + {"<svg xmlns='http://www.w3.org/2000/svg'><script>1</script></svg>", "evil.svg", "application/octet-stream", "other"}, + {"<?xml version='1.0'?><svg/>", "pic.svg", "application/octet-stream", "other"}, + {"<!DOCTYPE html><html>", "page.html", "application/octet-stream", "other"}, + {"alert(1)", "x.js", "application/octet-stream", "other"}, + {"\x00\x01\x02\x03\xff\xfe", "song.mp3", "audio/mpeg", "audio"}, + {"\x00\x01\x02\x03\xff\xfe", "tool.exe", "application/octet-stream", "other"}, + {"\x00\x01\x02\x03\xff\xfe", "book.epub", "application/epub+zip", "document"}, + {"\x00\x01\x02\x03\xff\xfe", "noext", "application/octet-stream", "other"}, + } + for _, c := range cases { + ct, kind := fileType([]byte(c.head), c.name) + if ct != c.ct || kind != c.kind { + t.Errorf("fileType(%q, %q) = %s, %s; want %s, %s", c.head[:min(8, len(c.head))], c.name, ct, kind, c.ct, c.kind) + } + } +} + +func TestServedAs(t *testing.T) { + cases := []struct { + ct string + download bool + ctype string + disp string + }{ + {"image/png", false, "image/png", "inline"}, + {"image/png", true, "image/png", "attachment"}, + {"application/pdf", false, "application/pdf", "inline"}, + {"text/plain", false, "text/plain; charset=utf-8", "inline"}, + {"audio/mpeg", false, "audio/mpeg", "inline"}, + {"video/mp4", false, "video/mp4", "inline"}, + {"application/zip", false, "application/octet-stream", "attachment"}, + {"text/html", false, "application/octet-stream", "attachment"}, + {"image/svg+xml", false, "application/octet-stream", "attachment"}, + {"application/javascript", true, "application/octet-stream", "attachment"}, + } + for _, c := range cases { + ctype, disp := servedAs(c.ct, c.download) + if ctype != c.ctype || disp != c.disp { + t.Errorf("servedAs(%s, %v) = %s, %s; want %s, %s", c.ct, c.download, ctype, disp, c.ctype, c.disp) + } + } +} + +func TestContentDisposition(t *testing.T) { + if got := contentDisposition("inline", "a.pdf"); got != `inline; filename="a.pdf"; filename*=utf-8''a.pdf` { + t.Errorf("ascii: %s", got) + } + got := contentDisposition("attachment", `έγγρα"φο.pdf`) + if !strings.HasPrefix(got, `attachment; filename="________.pdf"; filename*=utf-8''%CE%AD`) { + t.Errorf("greek: %s", got) + } +} + +func TestCleanFilename(t *testing.T) { + cases := map[string]string{ + `C:\Users\me\photo.png`: "photo.png", + "../../etc/passwd": "passwd", + " spaced .txt ": "spaced .txt", + "": "file", + ".": "file", + "/": "file", + "a\"b'c\x00d.txt": "abcd.txt", + "φωτογραφία.jpg": "φωτογραφία.jpg", + } + for in, want := range cases { + if got := cleanFilename(in); got != want { + t.Errorf("cleanFilename(%q) = %q, want %q", in, got, want) + } + } + if got := cleanFilename(strings.Repeat("α", 200)); len([]rune(got)) != 120 { + t.Errorf("long name not capped: %d runes", len([]rune(got))) + } +} + +func TestHumanSize(t *testing.T) { + cases := map[int64]string{0: "1 KB", 100: "1 KB", 512 << 10: "512 KB", 1 << 20: "1 MB", 1536 << 10: "1.5 MB", 10 << 20: "10 MB"} + for in, want := range cases { + if got := humanSize(in); got != want { + t.Errorf("humanSize(%d) = %q, want %q", in, got, want) + } + } +} + +func TestPageBounds(t *testing.T) { + cases := []struct{ total, per, n, offset, page, last int }{ + {0, 50, 1, 0, 1, 1}, {0, 50, 7, 0, 1, 1}, {50, 50, 2, 0, 1, 1}, + {120, 50, 3, 100, 3, 3}, {120, 50, 9, 100, 3, 3}, {120, 50, 0, 0, 1, 3}, {120, 50, 2, 50, 2, 3}, + } + for _, c := range cases { + o, p, l := pageBounds(c.total, c.per, c.n) + if o != c.offset || p != c.page || l != c.last { + t.Errorf("pageBounds(%d,%d,%d) = %d,%d,%d; want %d,%d,%d", c.total, c.per, c.n, o, p, l, c.offset, c.page, c.last) + } + } +} + +func TestFileBadge(t *testing.T) { + for name, want := range map[string]string{"a.pdf": "PDF", "site.tar.gz": "GZ", "README": "FILE", "x.verylongext": "FILE", "α.ΈΓΓΡΑΦΟ": "FILE", "n.txt": "TXT"} { + if got := (store.File{Filename: name}).Badge(); got != want { + t.Errorf("Badge(%q) = %q, want %q", name, got, want) + } + } +} + +// guardPOST runs before any store access, so it can be exercised with a nil store. +func TestGuardPOST(t *testing.T) { + secret := []byte("test-secret") + s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: secret, MaxUploadBytes: 1 << 20}, nil) + u := &store.User{ID: 1} + post := func(body string, accept string) (*httptest.ResponseRecorder, *http.Request) { + r := httptest.NewRequest("POST", "/b/alice/files/upload", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if accept != "" { + r.Header.Set("Accept", accept) + } + r = withLang(r.WithContext(context.WithValue(r.Context(), ctxUser, u)), "en") + return httptest.NewRecorder(), r + } + token := auth.CSRFToken(secret, u.ID, u.TokenVersion) + + // over the cap (limit + 1 MB overhead) + w, r := post("x="+strings.Repeat("a", 2<<20+10), "") + if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || !strings.Contains(w.Body.String(), "1 MB") { + t.Errorf("too big: code %d body %q", w.Code, w.Body.String()) + } + w, r = post("x="+strings.Repeat("a", 2<<20+10), "application/json") + var j map[string]string + if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || json.NewDecoder(w.Body).Decode(&j) != nil || j["error"] == "" { + t.Errorf("too big (json): code %d", w.Code) + } + // a bigger limit lets the same body through (CSRF aside) + w, r = post("x="+strings.Repeat("a", 2<<20+10)+"&_csrf="+url.QueryEscape(token), "") + if !s.guardPOST(w, r, 4<<20) { + t.Errorf("under a 4 MB limit: code %d", w.Code) + } + // missing / valid token + w, r = post("x=1", "") + if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusForbidden { + t.Errorf("missing csrf: code %d", w.Code) + } + w, r = post("x=1&_csrf="+url.QueryEscape(token), "") + if !s.guardPOST(w, r, 1<<20) || r.FormValue("x") != "1" { + t.Errorf("valid token: code %d", w.Code) + } + // GET is never touched + r = httptest.NewRequest("GET", "/b/alice/files", nil) + if !s.guardPOST(httptest.NewRecorder(), r, 0) { + t.Error("GET should pass") + } + _ = bytes.MinRead +} diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index eb040c1..bc28fd8 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -24,7 +24,7 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - s.render(w, r, "admin/index.html", map[string]any{"users": users, "cfg": s.cfg}) + s.render(w, r, "admin/index.html", map[string]any{"users": users, "cfg": s.cfg, "defaultLimit": s.cfg.MaxUploadBytes}) } func (s *Server) handleAdminNewUserForm(w http.ResponseWriter, r *http.Request) { @@ -142,6 +142,38 @@ func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc { } } +// handleAdminUploadLimit sets a blog's per-file upload limit; blank restores the default. +func (s *Server) handleAdminUploadLimit(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + b, err := s.st.BlogByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return + } + field := strings.TrimSpace(r.FormValue("mb")) + mb := 0 + if field != "" { + mb, err = strconv.Atoi(field) + if err != nil || mb < 1 || mb > maxUploadMB { + s.plainError(w, http.StatusBadRequest, s.trf(r, "Enter a whole number of MB (1-%d), or leave blank for the default.", maxUploadMB)) + return + } + } + if err := s.st.SetBlogUploadLimit(r.Context(), b.ID, int64(mb)<<20); err != nil { + s.serverError(w, err) + return + } + if mb == 0 { + redirectOK(w, r, "/admin/", s.trf(r, "Upload limit for %s reset to the default.", b.Subdomain)) + return + } + redirectOK(w, r, "/admin/", s.trf(r, "Upload limit for %s set to %d MB.", b.Subdomain, mb)) +} + func (s *Server) handleAdminDeleteUserConfirm(w http.ResponseWriter, r *http.Request) { u := s.adminTargetUser(w, r) if u == nil { diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index 7424eca..3e9e600 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -1,27 +1,15 @@ package web import ( - "bytes" - "encoding/json" - "errors" - "io" - "mime/multipart" "net/http" - "path/filepath" - "strconv" - "strings" "time" - "github.com/google/uuid" "github.com/gramanas/blogspace/internal/store" ) -// ICO is here for site icons; it is harmless anywhere else an image can go. -var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true} - func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) - images, err := blogStore(r).ListImages(r.Context()) + images, _, err := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0) if err != nil { s.serverError(w, err) return @@ -30,22 +18,18 @@ func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { } // designData is what design.html shows; "today" is the sample for the date formats. -func designData(theme Theme, images []store.Image) map[string]any { +func designData(theme Theme, images []store.File) map[string]any { return map[string]any{"theme": theme, "images": images, "presets": Presets(), "today": time.Now()} } func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) - if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { - s.plainError(w, http.StatusBadRequest, s.tr(r, "Upload too large or malformed form.")) - return - } theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) // Optional direct uploads from the design form. for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "logo_file": &theme.Logo, "favicon_file": &theme.Favicon} { - img, err := s.readUpload(r, field) + img, err := s.readUpload(r, field, true) if err != nil { - images, _ := blogStore(r).ListImages(r.Context()) + images, _, _ := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0) d := designData(theme, images) d["error"] = err.Error() s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d) @@ -93,153 +77,3 @@ func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) { } redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design and layout reset to the defaults.")) } - -// readUpload stores the file from a multipart field, returning nil if the field is empty. -func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error) { - if r.MultipartForm == nil { - return nil, nil - } - fhs := r.MultipartForm.File[field] - if len(fhs) == 0 { - return nil, nil - } - return s.storeUpload(r, fhs[0]) -} - -func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) { - tooBig := errors.New(s.trf(r, "Image is too large (max %s).", kbString(s.cfg.MaxUploadBytes))) - if fh.Size > s.cfg.MaxUploadBytes { - return nil, tooBig - } - f, err := fh.Open() - if err != nil { - return nil, err - } - defer f.Close() - var buf bytes.Buffer - if _, err := io.CopyN(&buf, f, s.cfg.MaxUploadBytes+1); err != nil && !errors.Is(err, io.EOF) { - return nil, err - } - if int64(buf.Len()) > s.cfg.MaxUploadBytes { - return nil, tooBig - } - ct := http.DetectContentType(buf.Bytes()) - if !allowedImageTypes[ct] { - return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images are accepted.")) - } - name := filepath.Base(fh.Filename) - if name == "" || name == "." || len(name) > 120 { - name = "image" - } - return blogStore(r).CreateImage(r.Context(), name, ct, buf.Bytes()) -} - -// imageMarkdown is the line the editor inserts for an uploaded image. -func imageMarkdown(img *store.Image) string { - return "![" + strings.NewReplacer("]", "", "\n", " ").Replace(img.Filename) + "](/media/" + img.ID.String() + ")" -} - -// appendImageMD is the no-JavaScript path of "Insert image": the file arrives -// with the form itself and is appended to the end of the text on save. -func appendImageMD(md string, img *store.Image) string { - if img == nil { - return md - } - md = strings.TrimRight(md, "\n") - if md != "" { - md += "\n\n" - } - return md + imageMarkdown(img) + "\n" -} - -func kbString(n int64) string { - if n >= 1<<20 { - return strconv.FormatInt(n>>20, 10) + " MB" - } - return strconv.FormatInt(n>>10, 10) + " KB" -} - -// ---- image library --------------------------------------------------------- - -func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) { - images, err := blogStore(r).ListImages(r.Context()) - if err != nil { - s.serverError(w, err) - return - } - s.render(w, r, "dashboard/images.html", map[string]any{"images": images}) -} - -// handleImageUpload serves the Images page form and, when the client asks for -// JSON, the editor's "Insert image" script. -func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json") - fail := func(msg string) { - if wantJSON { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) - return - } - s.plainError(w, http.StatusBadRequest, msg) - } - if err := r.ParseMultipartForm(1 << 20); err != nil { - fail(s.tr(r, "Upload too large or malformed form.")) - return - } - img, err := s.readUpload(r, "file") - if err != nil { - fail(err.Error()) - return - } - if img == nil { - fail(s.tr(r, "Choose a file first.")) - return - } - if wantJSON { - writeJSON(w, http.StatusOK, map[string]string{"id": img.ID.String(), "filename": img.Filename, "markdown": imageMarkdown(img)}) - return - } - redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.trf(r, "Uploaded %s.", img.Filename)) -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - http.NotFound(w, r) - return - } - if err := blogStore(r).DeleteImage(r.Context(), id); err != nil { - s.serverError(w, err) - return - } - // Drop dangling references from the theme. - theme := ParseTheme(blog.ThemeJSON) - changed := false - if theme.BgImage == id.String() { - theme.BgImage, changed = "", true - } - if theme.HeaderImage == id.String() { - theme.HeaderImage, changed = "", true - } - if theme.Favicon == id.String() { - theme.Favicon, changed = "", true - } - if theme.Logo == id.String() { - theme.Logo, changed = "", true - } - if changed { - if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { - s.serverError(w, err) - return - } - } - redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.tr(r, "Image deleted.")) -} diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go new file mode 100644 index 0000000..1d90b91 --- /dev/null +++ b/internal/web/handlers_files.go @@ -0,0 +1,264 @@ +package web + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/gramanas/blogspace/internal/store" +) + +const filesPerPage = 50 + +// readUpload stores the file from a multipart field, returning nil if the +// field is empty. imagesOnly is for the design page, whose fields become theme +// image ids. +func (s *Server) readUpload(r *http.Request, field string, imagesOnly bool) (*store.File, error) { + if r.MultipartForm == nil { + return nil, nil + } + fhs := r.MultipartForm.File[field] + if len(fhs) == 0 { + return nil, nil + } + return s.storeUpload(r, fhs[0], imagesOnly) +} + +func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOnly bool) (*store.File, error) { + limit := currentBlog(r).UploadLimit(s.cfg) + tooBig := errors.New(s.trf(r, "File is too large (max %s).", humanSize(limit))) + if fh.Size > limit { + return nil, tooBig + } + f, err := fh.Open() + if err != nil { + return nil, err + } + defer f.Close() + var buf bytes.Buffer + if _, err := io.CopyN(&buf, f, limit+1); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if int64(buf.Len()) > limit { + return nil, tooBig + } + if buf.Len() == 0 { + return nil, errors.New(s.tr(r, "File is empty.")) + } + name := cleanFilename(fh.Filename) + ct, kind := fileType(buf.Bytes(), name) + if imagesOnly && kind != "image" { + return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images can be used here.")) + } + return blogStore(r).CreateFile(r.Context(), name, ct, kind, buf.Bytes()) +} + +// fileMarkdown is the line the editor inserts: an image for images, a link +// for everything else. +func fileMarkdown(f *store.File) string { + text := strings.NewReplacer("]", "", "\n", " ").Replace(f.Filename) + open := "[" + if f.Kind == "image" { + open = " + ")" +} + +// appendFileMD is the no-JavaScript path of "Insert file": the file arrives +// with the form itself and is appended to the end of the text on save. +func appendFileMD(md string, f *store.File) string { + if f == nil { + return md + } + md = strings.TrimRight(md, "\n") + if md != "" { + md += "\n\n" + } + return md + fileMarkdown(f) + "\n" +} + +// ---- file library ---------------------------------------------------------- + +func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + kind := q.Get("kind") + if _, ok := fileKindNames[kind]; !ok { + kind = "" + } + search := strings.TrimSpace(q.Get("q")) + if rs := []rune(search); len(rs) > 100 { + search = string(rs[:100]) + } + n, _ := strconv.Atoi(q.Get("p")) + d, err := s.filesData(r, kind, search, n) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/files.html", d) +} + +// filesData is what files.html shows: the page of files matching the filter, +// the pager links and the library's usage. +func (s *Server) filesData(r *http.Request, kind, search string, n int) (map[string]any, error) { + bs := blogStore(r) + count, bytes, err := bs.FileUsage(r.Context()) + if err != nil { + return nil, err + } + offset, page, _ := pageBounds(1<<30, filesPerPage, n) + files, total, err := bs.ListFiles(r.Context(), kind, search, filesPerPage, offset) + if err != nil { + return nil, err + } + // A page past the end (stale link, last file deleted) shows the last page instead. + if o, p, _ := pageBounds(total, filesPerPage, n); p != page { + offset, page = o, p + if files, _, err = bs.ListFiles(r.Context(), kind, search, filesPerPage, offset); err != nil { + return nil, err + } + } + _, _, last := pageBounds(total, filesPerPage, page) + link := func(p int) string { + v := url.Values{} + if kind != "" { + v.Set("kind", kind) + } + if search != "" { + v.Set("q", search) + } + if p > 1 { + v.Set("p", strconv.Itoa(p)) + } + if len(v) == 0 { + return "/b/" + currentBlog(r).Subdomain + "/files" + } + return "/b/" + currentBlog(r).Subdomain + "/files?" + v.Encode() + } + return map[string]any{ + "files": files, "kind": kind, "q": search, "kinds": fileKinds, "kindNames": fileKindNames, + "pageNum": page, "lastPage": last, "prevURL": link(page - 1), "nextURL": link(page + 1), "self": link(page), + "count": count, "bytes": bytes, "limit": humanSize(currentBlog(r).UploadLimit(s.cfg)), + }, nil +} + +// handleFileUpload serves the Files page form (several files at once) and, +// when the client asks for JSON, the upload scripts (one file per request). +func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + var fhs []*multipart.FileHeader + if r.MultipartForm != nil { + fhs = r.MultipartForm.File["file"] // browsers repeat the field for <input multiple> + } + var msg string + var files []*store.File + switch { + case len(fhs) == 0: + msg = s.tr(r, "Choose a file first.") + case len(fhs) > maxUploadFiles: + msg = s.trf(r, "At most %d files at a time.", maxUploadFiles) + } + for _, fh := range fhs { + if msg != "" { + break + } + f, err := s.storeUpload(r, fh, false) + if err != nil { + msg = err.Error() + break + } + files = append(files, f) + } + if wantsJSON(r) { + if msg != "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + f := files[0] + writeJSON(w, http.StatusOK, map[string]any{"id": f.ID.String(), "filename": f.Filename, "kind": f.Kind, "size": f.Size, "markdown": fileMarkdown(f)}) + return + } + if msg != "" { + d, err := s.filesData(r, "", "", 1) + if err != nil { + s.serverError(w, err) + return + } + d["error"] = msg + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/files.html", d) + return + } + if len(files) == 1 { + redirectOK(w, r, "/b/"+blog.Subdomain+"/files", s.trf(r, "Uploaded %s.", files[0].Filename)) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/files", s.trf(r, "Uploaded %d files.", len(files))) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// filesBack is where rename/delete return to: the list page the form was on. +func filesBack(r *http.Request) string { + if back := safeNext(r.FormValue("back")); back != "" { + return back + } + return "/b/" + currentBlog(r).Subdomain + "/files" +} + +func (s *Server) handleFileRename(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + name := cleanFilename(r.FormValue("filename")) + if err := blogStore(r).RenameFile(r.Context(), id, name); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, filesBack(r), s.trf(r, "Renamed to %s.", name)) +} + +func (s *Server) handleFileDelete(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + if err := blogStore(r).DeleteFile(r.Context(), id); err != nil { + s.serverError(w, err) + return + } + // Drop dangling references from the theme. + theme := ParseTheme(blog.ThemeJSON) + changed := false + for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo} { + if *ref == id.String() { + *ref, changed = "", true + } + } + if changed { + if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { + s.serverError(w, err) + return + } + } + redirectOK(w, r, filesBack(r), s.tr(r, "File deleted.")) +} diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go index 4e237fa..7c4b739 100644 --- a/internal/web/handlers_media.go +++ b/internal/web/handlers_media.go @@ -1,7 +1,6 @@ package web import ( - "bytes" "errors" "io/fs" "net/http" @@ -10,7 +9,9 @@ import ( "github.com/gramanas/blogspace/internal/store" ) -// handleMedia serves an uploaded image. Ids are immutable, so clients may cache forever. +// handleMedia serves an uploaded file. Ids are immutable, so clients may cache +// forever (a renamed file keeps its old download name in caches; acceptable). +// What may render inline is decided by servedAs, see filetype.go. func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { @@ -22,7 +23,7 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotModified) return } - img, err := blogStore(r).ImageData(r.Context(), id) + f, err := blogStore(r).FileMeta(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) @@ -31,11 +32,13 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - w.Header().Set("Content-Type", img.ContentType) + ctype, disposition := servedAs(f.ContentType, r.URL.Query().Has("download")) + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", contentDisposition(disposition, f.Filename)) w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("ETag", etag) w.Header().Set("X-Content-Type-Options", "nosniff") - http.ServeContent(w, r, img.Filename, img.CreatedAt, bytes.NewReader(img.Data)) + http.ServeContent(w, r, f.Filename, f.CreatedAt, blogStore(r).FileReader(r.Context(), f)) } // handleFavicon answers the browsers that ask for /favicon.ico regardless of diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index fdd1bc8..3b128e0 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -65,11 +65,11 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { if autoSlug { p.Slug = slug.Make(p.Title) } - img, err := s.readUpload(r, "inline_image") + img, err := s.readUpload(r, "inline_file", false) var msg string switch { case err != nil: - msg = s.tr(r, "Image not added:") + " " + err.Error() + msg = s.tr(r, "File not added:") + " " + err.Error() case p.Title == "" || len(p.Title) > 120: msg = s.tr(r, "Title is required (max 120 characters).") case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]: @@ -79,7 +79,7 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg}) return } - p.IntroMD = appendImageMD(p.IntroMD, img) + p.IntroMD = appendFileMD(p.IntroMD, img) p.IntroHTML = markdown.Render(p.IntroMD) base := p.Slug for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index 8327912..2ec2b2e 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -92,12 +92,12 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { return } } - img, err := s.readUpload(r, "inline_image") + img, err := s.readUpload(r, "inline_file", false) if err != nil { - fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error()) + fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error()) return } - p.BodyMD = appendImageMD(p.BodyMD, img) + p.BodyMD = appendFileMD(p.BodyMD, img) pageOK := false for _, pg := range pages { if pg.ID == p.PageID { diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 325192e..eb45e1c 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -73,10 +73,10 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { fail := func(msg string) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/section_form.html", map[string]any{"section": sec, "error": msg}) } - img, err := s.readUpload(r, "inline_image") + img, err := s.readUpload(r, "inline_file", false) switch { case err != nil: - fail(s.tr(r, "Image not added:") + " " + err.Error()) + fail(s.tr(r, "File not added:") + " " + err.Error()) return case len(sec.Title) > 120: fail(s.tr(r, "Title is too long (max 120 characters).")) @@ -88,7 +88,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { fail(s.tr(r, "Announcement is too long (20 KB max).")) return } - sec.BodyMD = appendImageMD(sec.BodyMD, img) + sec.BodyMD = appendFileMD(sec.BodyMD, img) sec.BodyHTML = markdown.Render(sec.BodyMD) if sec.ID == 0 { sec, err = blogStore(r).CreateSection(r.Context(), sec) diff --git a/internal/web/routes.go b/internal/web/routes.go index 36ae892..273a832 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -63,10 +63,11 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) m.HandleFunc("POST /b/{sub}/design/preset", s.withBlog(s.handleDesignPreset)) m.HandleFunc("POST /b/{sub}/design/reset", s.withBlog(s.handleDesignReset)) - m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages)) - m.HandleFunc("POST /b/{sub}/images/upload", s.withBlog(s.handleImageUpload)) - m.HandleFunc("POST /b/{sub}/images/{id}/delete", s.withBlog(s.handleImageDelete)) - // Images live in the blog's database, so dashboard previews on this host go through /b/. + m.HandleFunc("GET /b/{sub}/files", s.withBlog(s.handleFiles)) + m.HandleFunc("POST /b/{sub}/files/upload", s.withBlogFiles(maxUploadFiles, s.handleFileUpload)) + m.HandleFunc("POST /b/{sub}/files/{id}/rename", s.withBlog(s.handleFileRename)) + m.HandleFunc("POST /b/{sub}/files/{id}/delete", s.withBlog(s.handleFileDelete)) + // Files live in the blog's database, so dashboard previews on this host go through /b/. m.HandleFunc("GET /b/{sub}/media/{id}", s.withBlog(s.handleMedia)) // superadmin @@ -78,6 +79,7 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /admin/users/{id}/enable", s.requireAdmin(s.handleAdminSetDisabled(false))) m.HandleFunc("GET /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUserConfirm)) m.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUser)) + m.HandleFunc("POST /admin/blogs/{id}/upload-limit", s.requireAdmin(s.handleAdminUploadLimit)) m.HandleFunc("GET /media/{id}", s.hostBlog(s.handleMedia)) m.Handle("GET /static/{file}", s.staticHandler()) diff --git a/internal/web/server.go b/internal/web/server.go index 4bb408e..f005b76 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -187,36 +187,57 @@ func (s *Server) session(next http.Handler) http.Handler { }) } -// requireAuth redirects anonymous users to the login page. -func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { +// requireLogin sends anonymous users to the login page and nothing else; the +// body cap and the CSRF check come in guardPOST, once the upload limit is known. +func (s *Server) requireLogin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - u := currentUser(r) - if u == nil { + if currentUser(r) == nil { http.Redirect(w, r, "/webadmin?next="+r.URL.Path, http.StatusSeeOther) return } - if r.Method == http.MethodPost { - // Cap the request body before any form parsing (uploads included). - r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+1<<20) - if err := parseForm(r); err != nil { - var tooBig *http.MaxBytesError - if errors.As(err, &tooBig) { - s.plainError(w, http.StatusRequestEntityTooLarge, s.trf(r, "Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20)) - return - } - s.plainError(w, http.StatusBadRequest, s.tr(r, "Could not read the form.")) - return - } - if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { - s.plainError(w, http.StatusForbidden, s.tr(r, "Form expired or invalid. Go back, reload the page and try again.")) - return - } - } next(w, r) } } +// guardPOST caps a POST body at limit plus 1 MB of form overhead, parses it and +// checks the CSRF token; false means an error response was written. Other +// methods pass straight through. +func (s *Server) guardPOST(w http.ResponseWriter, r *http.Request, limit int64) bool { + if r.Method != http.MethodPost { + return true + } + u := currentUser(r) + // Cap the request body before any form parsing (uploads included). + r.Body = http.MaxBytesReader(w, r.Body, limit+1<<20) + if err := parseForm(r); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + s.fail(w, r, http.StatusRequestEntityTooLarge, s.trf(r, "Upload too large: the limit is %d MB.", limit>>20)) + return false + } + s.fail(w, r, http.StatusBadRequest, s.tr(r, "Could not read the form.")) + return false + } + if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { + s.fail(w, r, http.StatusForbidden, s.tr(r, "Form expired or invalid. Go back, reload the page and try again.")) + return false + } + return true +} + +// requireAuth is for management pages outside a blog (dashboard, password, +// admin): logged in, small forms only. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return s.requireLogin(func(w http.ResponseWriter, r *http.Request) { + if s.guardPOST(w, r, 0) { + next(w, r) + } + }) +} + // parseForm parses urlencoded or multipart bodies, surfacing size errors. +// Multipart parts beyond 1 MB in total spill to temp files, which net/http +// removes once the handler returns. func parseForm(r *http.Request) error { ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "multipart/form-data") { @@ -225,6 +246,20 @@ func parseForm(r *http.Request) error { return r.ParseForm() } +// wantsJSON is how the upload scripts ask for answers they can parse. +func wantsJSON(r *http.Request) bool { + return strings.Contains(r.Header.Get("Accept"), "application/json") +} + +// fail answers an error as JSON when the client asked for it, else as the plain page. +func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg string) { + if wantsJSON(r) { + writeJSON(w, status, map[string]string{"error": msg}) + return + } + s.plainError(w, status, msg) +} + func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { if !currentUser(r).IsSuperadmin() { @@ -235,9 +270,16 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { }) } -// withBlog resolves /b/{sub}/... and enforces owner-or-superadmin. +// withBlog resolves /b/{sub}/..., enforces owner-or-superadmin and caps a POST +// at the blog's own upload limit. func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { - return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + return s.withBlogFiles(1, next) +} + +// withBlogFiles is withBlog for a form that may carry up to n files at once +// (the Files page's multi-upload): the body cap is n limits. +func (s *Server) withBlogFiles(n int, next http.HandlerFunc) http.HandlerFunc { + return s.requireLogin(func(w http.ResponseWriter, r *http.Request) { u := currentUser(r) r, err := s.resolveBlog(r, r.PathValue("sub")) if err != nil { @@ -257,7 +299,10 @@ func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { if blog.OwnerID != u.ID { lang = s.userLang(r, u) } - next(w, withLang(r, lang)) + r = withLang(r, lang) + if s.guardPOST(w, r, int64(n)*blog.UploadLimit(s.cfg)) { + next(w, r) + } }) } diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 5c766e7..8fe5f8a 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -118,12 +118,23 @@ details.help[open] summary { margin-bottom: 0.5em; } .editor-tools .upload input { display: inline-block; width: auto; max-width: 14em; margin: 0 0 0 0.4em; padding: 0; border: 0; background: none; font-size: 0.9em; } .editor-tools .upload-status { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; margin-right: 0.6em; } -/* ---- images -------------------------------------------------------------- */ -.gallery { overflow: hidden; } -.thumb { float: left; width: 200px; margin: 0 1.2em 1.6em 0; padding: 0.6em; text-align: center; } -.thumb img { max-width: 100%; max-height: 140px; border: 1px solid #ddd6c7; } -.thumb .meta { font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 0.4em; } -.thumb .copy { font-size: 0.75em; margin: 0.4em 0; padding: 0.3em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; } +/* ---- files --------------------------------------------------------------- */ +.dropzone.over { border-style: dashed; background: #ebe5d6; } +.progress { list-style: none; margin: 0.6em 0 0; padding: 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; } +.progress:empty { display: none; } +.files-filter { overflow: hidden; } +.files-filter input[type=search] { width: auto; margin: 0 0.3em 0.5em 0; padding: 0.3em 0.5em; vertical-align: middle; } +.kinds { display: inline-block; margin: 0 1em 0.5em 0; vertical-align: middle; } +.kinds a { display: inline-block; padding: 0.2em 0.7em; margin: 0 0.3em 0.3em 0; border: 2px solid #1d1a17; color: #1d1a17; text-decoration: none; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; } +.kinds a:hover { background: #ebe5d6; } +.kinds a.active { background: #1d1a17; color: #f4efe4; } +table.files td.icon { width: 56px; } +table.files .thumb { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid #ddd6c7; background: #f4efe4; } +table.files .badge { display: block; width: 48px; padding: 0.9em 0; border: 1px solid #1d1a17; background: #fffdf8; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.65em; letter-spacing: 0.05em; overflow: hidden; } +table.files td.name { word-break: break-all; } +table.files .copy { width: 100%; min-width: 11em; padding: 0.3em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; } +.pager { margin: 0 4px 1.6em 0; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; } +.pager a, .pager span { margin: 0 0.8em; } .imagepick { margin-top: 0.5em; padding-top: 0.5em; border-top: 2px dashed #ddd6c7; } .imagepick > label { margin-bottom: 0.3em; } .imagepick .picks { overflow: hidden; margin: 0.4em 0 0; } @@ -184,7 +195,7 @@ details.reset { margin-top: 1.6em; } .login-brand { text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; text-transform: uppercase; letter-spacing: 0.2em; margin: 2em 0 1em; } @media (max-width: 700px) { - .cols .card, .row label, .thumb { float: none; width: auto; margin-right: 4px; } + .cols .card, .row label { float: none; width: auto; margin-right: 4px; } .imagepick .pick { width: 46%; margin-right: 4%; } .presets .preset { width: 46%; margin-right: 4%; } .addrow .add { float: none; width: auto; margin-right: 0; } diff --git a/internal/web/templates.go b/internal/web/templates.go index 7f71304..c2f9847 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -110,9 +110,12 @@ var funcs = template.FuncMap{ "rfc": func(t time.Time) string { return t.Format(time.RFC1123Z) }, "html": func(s string) template.HTML { return template.HTML(s) }, "css": func(s string) template.CSS { return template.CSS(s) }, - "kb": func(n int) string { return fmt.Sprintf("%.0f KB", float64(n)/1024) }, - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, + "size": humanSize, + "mb": func(n int64) int64 { return n >> 20 }, + // filemd is the Markdown line for a library file, what "Insert file" writes + "filemd": fileMarkdown, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, "deref": func(p *string) string { if p == nil { return "" diff --git a/internal/web/templates/admin/delete_user.html b/internal/web/templates/admin/delete_user.html index 886ff1a..79bf2fc 100644 --- a/internal/web/templates/admin/delete_user.html +++ b/internal/web/templates/admin/delete_user.html @@ -2,7 +2,7 @@ {{define "content"}} <div class="card narrow"> <h1>{{tf "Delete %s?" .Data.target.Username}}</h1> - <p>{{t "This permanently deletes the user"}} <strong>{{.Data.target.Username}}</strong>{{t ", their blog, and every page, post and image in it."}}</p> + <p>{{t "This permanently deletes the user"}} <strong>{{.Data.target.Username}}</strong>{{t ", their blog, and every page, post and file in it."}}</p> <form method="post" action="/admin/users/{{.Data.target.ID}}/delete"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> <p class="actionrow"><button type="submit" class="danger">{{t "Yes, delete everything"}}</button> diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index 0e49e62..055671e 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -6,11 +6,18 @@ </div> <div class="card"> <table> - <tr><th>{{t "User"}}</th><th>{{t "Role"}}</th><th>{{t "Blog"}}</th><th>{{t "Since"}}</th><th>{{t "Actions"}}</th></tr> + <tr><th>{{t "User"}}</th><th>{{t "Role"}}</th><th>{{t "Blog"}}</th><th>{{t "Upload limit"}}</th><th>{{t "Since"}}</th><th>{{t "Actions"}}</th></tr> {{range .Data.users}}<tr{{if .Disabled}} class="disabled"{{end}}> <td>{{.Username}}{{if .Disabled}} <span class="tag">{{t "disabled"}}</span>{{end}}</td> <td>{{t .Role}}</td> <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">{{t "view"}} ↗</a>{{else}}<span class="muted">—</span>{{end}}</td> + <td class="nowrap">{{if .BlogID}}{{if .MaxUploadBytes}}{{size .MaxUploadBytes}}{{else}}{{size $.Data.defaultLimit}} <span class="muted">({{t "default"}})</span>{{end}} + <details class="inline"><summary class="mini">{{t "change"}}</summary> + <form method="post" action="/admin/blogs/{{deref64 .BlogID}}/upload-limit" class="inline"> + <input type="hidden" name="_csrf" value="{{$.CSRF}}"> + <input type="number" name="mb" min="1" max="1024" placeholder="MB" size="5"{{if .MaxUploadBytes}} value="{{mb .MaxUploadBytes}}"{{end}}> + <button class="mini">{{t "Set"}}</button> <span class="muted small">{{t "blank = default"}}</span> + </form></details>{{else}}<span class="muted">—</span>{{end}}</td> <td class="nowrap">{{date .CreatedAt}}</td> <td class="nowrap"> {{if ne .ID $.User.ID}} diff --git a/internal/web/templates/dashboard/files.html b/internal/web/templates/dashboard/files.html new file mode 100644 index 0000000..b42cec2 --- /dev/null +++ b/internal/web/templates/dashboard/files.html @@ -0,0 +1,95 @@ +{{define "title"}}{{t "Files"}} · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$b := .Blog.Subdomain}} +<div class="pagehead"> + <h1>{{t "Files"}}</h1> + <p class="lead muted">{{t "Files are stored with your blog. Put one in a post with"}} <em>{{t "Insert file"}}</em> {{t "in the editor, or copy its line from the table."}} {{tf "%d files, %s in use." .Data.count (size .Data.bytes)}}</p> +</div> +<div class="card dropzone" id="dropzone"> + <form method="post" action="/b/{{$b}}/files/upload" enctype="multipart/form-data" id="upload-form"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>{{t "Upload files"}} <span class="muted">({{tf "any type, up to %s each" .Data.limit}})</span><br><input type="file" name="file" id="upload-input" multiple required></label> + <p class="actionrow"><button type="submit">{{t "Upload"}}</button> <span class="muted small" id="upload-hint" hidden>{{t "…or drop files anywhere on this box."}}</span></p> + <ul class="progress" id="upload-progress"></ul> + </form> +</div> +<form method="get" class="filter files-filter"> + <span class="kinds"> + <a href="/b/{{$b}}/files{{if .Data.q}}?q={{.Data.q}}{{end}}"{{if not .Data.kind}} class="active"{{end}}>{{t "All"}}</a> + {{range .Data.kinds}}<a href="/b/{{$b}}/files?kind={{.}}{{if $.Data.q}}&q={{$.Data.q}}{{end}}"{{if eq . $.Data.kind}} class="active"{{end}}>{{t (index $.Data.kindNames .)}}</a>{{end}} + </span> + {{if .Data.kind}}<input type="hidden" name="kind" value="{{.Data.kind}}">{{end}} + <input type="search" name="q" value="{{.Data.q}}" placeholder="{{t "Search files"}}" size="18"> <button type="submit" class="small">{{t "Search"}}</button> +</form> +<div class="card"> +{{if .Data.files}} +<table class="files"> + <tr><th></th><th>{{t "Name"}}</th><th>{{t "Kind"}}</th><th>{{t "Size"}}</th><th>{{t "Date"}}</th><th>Markdown</th><th></th></tr> + {{range .Data.files}}<tr> + <td class="icon">{{if eq .Kind "image"}}<a href="/b/{{$b}}/media/{{.ID}}" target="_blank"><img class="thumb" src="/b/{{$b}}/media/{{.ID}}" alt=""></a>{{else}}<span class="badge">{{.Badge}}</span>{{end}}</td> + <td class="name"><a href="/b/{{$b}}/media/{{.ID}}" target="_blank">{{.Filename}}</a> + <details class="inline"><summary class="mini">{{t "rename"}}</summary> + <form method="post" action="/b/{{$b}}/files/{{.ID}}/rename" class="inline"> + <input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="back" value="{{$.Data.self}}"> + <input type="text" name="filename" value="{{.Filename}}" maxlength="120" required size="18"> + <button class="mini">{{t "Save"}}</button> + </form></details></td> + <td class="nowrap">{{t (index $.Data.kindNames .Kind)}}</td> + <td class="nowrap">{{size .Size}}</td> + <td class="nowrap">{{date .CreatedAt}}</td> + <td><input class="copy" readonly value="{{filemd .}}" onclick="this.select()"></td> + <td class="nowrap"><a href="/b/{{$b}}/media/{{.ID}}?download">{{t "download"}}</a> · + <form method="post" action="/b/{{$b}}/files/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="back" value="{{$.Data.self}}"><button class="mini danger">{{t "delete"}}</button></form></td> + </tr>{{end}} +</table> +{{else if or .Data.q .Data.kind}}<p class="muted">{{t "No files match."}}</p> +{{else}}<p class="muted">{{t "No files yet."}}</p>{{end}} +</div> +{{if gt .Data.lastPage 1}} +<div class="pager"> + {{if gt .Data.pageNum 1}}<a href="{{.Data.prevURL}}">← {{t "Newer"}}</a>{{end}} + <span>{{tf "Page %d of %d" .Data.pageNum .Data.lastPage}}</span> + {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.nextURL}}">{{t "Older"}} →</a>{{end}} +</div> +{{end}} +<script> +(function () { + var zone = document.getElementById("dropzone"), form = document.getElementById("upload-form"), inp = document.getElementById("upload-input"), list = document.getElementById("upload-progress"); + if (!window.fetch || !window.FormData || !zone || !inp) return; // old browser: the plain form still works + document.getElementById("upload-hint").hidden = false; + var busy = false; + function line(text) { var li = document.createElement("li"); li.textContent = text; list.appendChild(li); return li; } + function upload(files) { + if (busy || !files.length) return; + busy = true; + var done = 0, i = 0; + function next() { + if (i >= files.length) { + busy = false; + if (done) location.href = {{.Data.self}} + ({{.Data.self}}.indexOf("?") < 0 ? "?ok=" : "&ok=") + encodeURIComponent({{t "Uploaded %d files."}}.replace("%d", done)); + return; + } + var f = files[i++], li = line(f.name + " — " + {{t "uploading…"}}); + var fd = new FormData(); + fd.append("_csrf", {{.CSRF}}); + fd.append("file", f); + fetch(form.action, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } }) + .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); }) + .then(function () { li.textContent = f.name + " — " + {{t "uploaded"}}; done++; }) + .catch(function (err) { li.textContent = f.name + " — " + err.message; }) + .then(next); + } + next(); + } + form.addEventListener("submit", function (ev) { if (inp.files && inp.files.length) { ev.preventDefault(); upload(inp.files); } }); + inp.addEventListener("change", function () { if (inp.files && inp.files.length) { var fs = Array.prototype.slice.call(inp.files); inp.value = ""; upload(fs); } }); + zone.addEventListener("dragover", function (ev) { ev.preventDefault(); zone.className = "card dropzone over"; }); + zone.addEventListener("dragleave", function () { zone.className = "card dropzone"; }); + zone.addEventListener("drop", function (ev) { + ev.preventDefault(); + zone.className = "card dropzone"; + if (ev.dataTransfer && ev.dataTransfer.files) upload(ev.dataTransfer.files); + }); +})(); +</script> +{{end}} diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html deleted file mode 100644 index 8b9faa3..0000000 --- a/internal/web/templates/dashboard/images.html +++ /dev/null @@ -1,24 +0,0 @@ -{{define "title"}}{{t "Images"}} · {{.Blog.Title}}{{end}} -{{define "content"}} -<div class="pagehead"> - <h1>{{t "Images"}}</h1> - <p class="lead muted">{{t "Images are stored with your blog. Put one in a post with"}} <em>{{t "Insert image"}}</em> {{t "in the editor, or copy its line from below."}}</p> -</div> -<div class="card"> - <form method="post" action="/b/{{.Blog.Subdomain}}/images/upload" enctype="multipart/form-data"> - <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>{{t "Upload an image"}} <span class="muted">({{t "PNG, JPEG, GIF, WebP or ICO"}})</span><br><input type="file" name="file" accept="image/*" required></label> - <p class="actionrow"><button type="submit">{{t "Upload"}}</button></p> - </form> -</div> -<div class="gallery"> -{{range .Data.images}} - <div class="card thumb"> - <a href="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" target="_blank"><img src="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" alt="{{.Filename}}"></a> - <div class="meta">{{.Filename}} <span class="muted">({{kb .Size}})</span></div> - <input class="copy" readonly value="" onclick="this.select()"> - <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">{{t "delete"}}</button></form> - </div> -{{else}}<p class="muted">{{t "No images yet."}}</p>{{end}} -</div> -{{end}} diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html index e021495..0f611cd 100644 --- a/internal/web/templates/dashboard/overview.html +++ b/internal/web/templates/dashboard/overview.html @@ -7,7 +7,7 @@ <div class="card"> <h2>{{t "Quick actions"}}</h2> <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">{{t "Write a new post"}}</a></p> - <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> · <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> · <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> · <a href="/b/{{.Blog.Subdomain}}/images">{{t "Upload images"}}</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> · <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> · <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> · <a href="/b/{{.Blog.Subdomain}}/files">{{t "Upload files"}}</a></p> </div> <div class="card"> <h2>{{t "Pages"}}</h2> diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html index 08c4a43..18d1119 100644 --- a/internal/web/templates/dashboard/page_form.html +++ b/internal/web/templates/dashboard/page_form.html @@ -8,7 +8,7 @@ <label>{{t "Address"}} <span class="muted">({{t "leave empty to make one from the title; e.g."}} <code>about</code> → {{.BlogURL}}/about)</span><br> <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> <label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "Markdown, shown above the posts; optional"}})</span></label> - {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index 8069a9a..d12d15f 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -15,7 +15,7 @@ <input type="datetime-local" name="posted_at" value="{{.Data.post.CreatedAt.Format "2006-01-02T15:04"}}" placeholder="YYYY-MM-DD HH:MM"></label> </div> <label for="ed-body">{{t "Content"}} <span class="muted">(Markdown)</span></label> - {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index bc3ba34..b401d9c 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -24,7 +24,7 @@ </div> <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p> <label for="ed-body">{{t "Text"}} <span class="muted">(Markdown)</span></label> - {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html index 65b0ed5..8b44e55 100644 --- a/internal/web/templates/partials/dashnav.html +++ b/internal/web/templates/partials/dashnav.html @@ -23,7 +23,7 @@ <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>{{t "Announcements"}}</a> <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>{{t "Layout"}}</a> <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a> - <a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>{{t "Images"}}</a> + <a href="{{$b}}files"{{if hasPrefix .Path (print $b "files")}} class="active"{{end}}>{{t "Files"}}</a> <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a> </span> <a class="view" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html index eb2e315..4371624 100644 --- a/internal/web/templates/partials/editor.html +++ b/internal/web/templates/partials/editor.html @@ -1,9 +1,9 @@ {{define "editor"}}<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="editor"{{end}}>{{.value}}</textarea> <div class="editor-tools"> - <label class="upload">{{t "Insert image"}} <input type="file" name="inline_image" id="ed-{{.name}}-file" accept="image/*"></label> + <label class="upload">{{t "Insert file"}} <input type="file" name="inline_file" id="ed-{{.name}}-file"></label> <span class="upload-status" id="ed-{{.name}}-status"></span> - <span class="muted small">{{t "…or paste / drop an image into the text."}}</span> - <noscript><span class="muted small">{{t "The image is added at the end of the text when you save."}}</span></noscript> + <span class="muted small">{{t "…or paste / drop a file into the text. Images are shown, other files linked."}}</span> + <noscript><span class="muted small">{{t "The file is added at the end of the text when you save."}}</span></noscript> </div> {{template "mdhelp"}} <script> @@ -22,23 +22,27 @@ var fd = new FormData(); fd.append("_csrf", {{.csrf}}); fd.append("file", file); - fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } }) + return fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } }) .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); }) .then(function (j) { insert(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; }) .catch(function (err) { st.textContent = {{t "Could not upload:"}} + " " + err.message; }); } + function uploadAll(files) { // one after the other, so each lands where the caret is + var i = 0; + (function next() { if (i < files.length) upload(files[i++]).then(next); })(); + } inp.addEventListener("change", function () { if (inp.files && inp.files[0]) upload(inp.files[0]); }); ta.addEventListener("paste", function (ev) { var items = ev.clipboardData && ev.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { - if (items[i].kind === "file" && /^image\//.test(items[i].type)) { ev.preventDefault(); upload(items[i].getAsFile()); return; } + if (items[i].kind === "file") { ev.preventDefault(); upload(items[i].getAsFile()); return; } } }); ta.addEventListener("dragover", function (ev) { ev.preventDefault(); }); ta.addEventListener("drop", function (ev) { - var f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; - if (f && /^image\//.test(f.type)) { ev.preventDefault(); upload(f); } + var fs = ev.dataTransfer && ev.dataTransfer.files; + if (fs && fs.length) { ev.preventDefault(); uploadAll(Array.prototype.slice.call(fs)); } }); })(); </script>{{end}} diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html index 748452e..5489510 100644 --- a/internal/web/templates/partials/mdhelp.html +++ b/internal/web/templates/partials/mdhelp.html @@ -2,7 +2,7 @@ <summary>{{t "Formatting cheat-sheet"}}</summary> <table class="cheat"> <tr><td><code># {{t "Heading"}}</code>, <code>## {{t "Smaller heading"}}</code></td><td><code>**{{t "bold"}}**</code>, <code>*{{t "italic"}}*</code></td></tr> - <tr><td><code>[{{t "link text"}}](https://example.org)</code></td><td><code></code> — <em>{{t "Insert image"}}</em> {{t "writes this for you"}}</td></tr> + <tr><td><code>[{{t "link text"}}](https://example.org)</code></td><td><code></code>, <code>[{{t "file name"}}](/media/…)</code> — <em>{{t "Insert file"}}</em> {{t "writes this for you"}}</td></tr> <tr><td><code>- {{t "list item"}}</code> / <code>1. {{t "numbered"}}</code></td><td><code>> {{t "quote"}}</code>, <code>`{{t "code"}}`</code>, <code>---</code> {{t "for a line"}}</td></tr> </table> <p class="muted small">{{t "Blank line = new paragraph. Press Enter once for a line break."}}</p> diff --git a/internal/web/web_test.go b/internal/web/web_test.go index d7de43a..e2bd335 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -63,7 +63,7 @@ func TestSubdomainLength(t *testing.T) { func TestRootRoutePrecedence(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) - for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/b/alice/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8": 303, "/account/password": 303} { + for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/b/alice/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8": 303, "/b/alice/files": 303, "/account/password": 303} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", path, nil) req.Host = "example.com" @@ -320,7 +320,7 @@ func TestPresets(t *testing.T) { func TestAllTemplatesParse(t *testing.T) { tpl := newTemplates(false) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", - "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", + "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html", "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/404.html"} { for _, l := range i18n.Languages() { @@ -365,7 +365,7 @@ func TestGreekCatalogComplete(t *testing.T) { add(goKey, string(b)) } // Keys that reach t/tr through a variable rather than a literal. - for _, m := range []map[string]string{moduleNames, areaNames} { + for _, m := range []map[string]string{moduleNames, areaNames, fileKindNames} { for _, v := range m { used[v] = true } @@ -422,17 +422,25 @@ func TestSubdomainWebadminRedirect(t *testing.T) { } } -func TestAppendImageMD(t *testing.T) { - img := &store.Image{ID: uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), Filename: "cat].png"} +func TestFileMarkdown(t *testing.T) { + id := uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + img := &store.File{ID: id, Filename: "cat].png", Kind: "image"} + doc := &store.File{ID: id, Filename: "notes\nv2.pdf", Kind: "document"} line := "" - if got := appendImageMD("", img); got != line+"\n" { + if got := fileMarkdown(img); got != line { + t.Errorf("image: %q", got) + } + if got := fileMarkdown(doc); got != "[notes v2.pdf](/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8)" { + t.Errorf("document should be a link: %q", got) + } + if got := appendFileMD("", img); got != line+"\n" { t.Errorf("empty body: %q", got) } - if got := appendImageMD("hello\n", img); got != "hello\n\n"+line+"\n" { - t.Errorf("with body: %q", got) + if got := appendFileMD("hello\n", img); got != "hello\n\n"+line+"\n" { + t.Errorf("appended: %q", got) } - if got := appendImageMD("hello", nil); got != "hello" { - t.Errorf("nil image should not change the body: %q", got) + if got := appendFileMD("hello", nil); got != "hello" { + t.Errorf("nil file should not change the body: %q", got) } } |
