aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.env.example2
-rw-r--r--AGENTS.md80
-rw-r--r--README.md20
-rw-r--r--cmd/blogspace/seed.go9
-rw-r--r--internal/config/config.go6
-rw-r--r--internal/db/migrations/blog/00003_files.sql25
-rw-r--r--internal/db/migrations/control/00002_upload_limit.sql6
-rw-r--r--internal/i18n/el.go67
-rw-r--r--internal/markdown/render_test.go4
-rw-r--r--internal/store/blogs.go21
-rw-r--r--internal/store/files.go174
-rw-r--r--internal/store/files_test.go69
-rw-r--r--internal/store/images.go57
-rw-r--r--internal/store/users.go9
-rw-r--r--internal/web/filetype.go196
-rw-r--r--internal/web/filetype_test.go192
-rw-r--r--internal/web/handlers_admin.go34
-rw-r--r--internal/web/handlers_design.go174
-rw-r--r--internal/web/handlers_files.go264
-rw-r--r--internal/web/handlers_media.go13
-rw-r--r--internal/web/handlers_pages.go6
-rw-r--r--internal/web/handlers_posts.go6
-rw-r--r--internal/web/handlers_sections.go6
-rw-r--r--internal/web/routes.go10
-rw-r--r--internal/web/server.go93
-rw-r--r--internal/web/static/dashboard.css25
-rw-r--r--internal/web/templates.go9
-rw-r--r--internal/web/templates/admin/delete_user.html2
-rw-r--r--internal/web/templates/admin/index.html9
-rw-r--r--internal/web/templates/dashboard/files.html95
-rw-r--r--internal/web/templates/dashboard/images.html24
-rw-r--r--internal/web/templates/dashboard/overview.html2
-rw-r--r--internal/web/templates/dashboard/page_form.html2
-rw-r--r--internal/web/templates/dashboard/post_form.html2
-rw-r--r--internal/web/templates/dashboard/section_form.html2
-rw-r--r--internal/web/templates/partials/dashnav.html2
-rw-r--r--internal/web/templates/partials/editor.html18
-rw-r--r--internal/web/templates/partials/mdhelp.html2
-rw-r--r--internal/web/web_test.go28
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
diff --git a/AGENTS.md b/AGENTS.md
index 09d9b7d..4a04077 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
+ `![name](…)` 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
diff --git a/README.md b/README.md
index 6af352a..f0b3fd8 100644
--- a/README.md
+++ b/README.md
@@ -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![gradient](/media/" + bg.ID.String() + ")\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![gradient](/media/" + bg.ID.String() + ")\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 = "!["
+ }
+ return open + text + "](/media/" + f.ID.String() + ")"
+}
+
+// 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"}} &#8599;</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}}&amp;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> &middot;
+ <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}}">&larr; {{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"}} &rarr;</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="![{{.Filename}}](/media/{{.ID}})" 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> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">{{t "Upload images"}}</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> &middot; <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"}} &#8599;</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>![{{t "description"}}](/media/…)</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>![{{t "description"}}](/media/…)</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>&gt; {{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 := "![cat.png](/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8)"
- 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)
}
}