diff options
Diffstat (limited to 'AGENTS.md')
| -rw-r--r-- | AGENTS.md | 80 |
1 files changed, 58 insertions, 22 deletions
@@ -22,12 +22,14 @@ table and deployment notes. needs JS, keep it small and give it a sensible fallback where cheap (e.g. the `<noscript>` button in `posts.html`). - Server-rendered `html/template`; no SPA, no frontend toolchain. -- Postgres holds **everything**, including images (`images.data bytea`). +- Postgres holds **everything**, including uploaded files (`files.data bytea`, + `STORAGE EXTERNAL`, served in `substring()` slices so a download never + loads the whole blob). **Each blog is its own database** (`blog_<sub>`, see Key mechanics) so a blog is backed up and restored with plain `pg_dump`/`psql`; the control database (`DATABASE_URL`) holds only `users` and the `blogs` registry. - Posts are Markdown (goldmark → bluemonday). No WYSIWYG. The editor's - "Insert image" is the one scripted convenience (`partials/editor.html`); + "Insert file" is the one scripted convenience (`partials/editor.html`); it must keep its no-JS fallback (file appended on save). - Changes are live immediately — there is no draft/preview system. The UX is "save, then refresh your blog tab"; keep the "View blog ↗" links. @@ -49,14 +51,15 @@ internal/config/ env → Config; RootSubdomain = "www" internal/db/ Cluster (control pool + lazy per-blog pools, CREATE/DROP DATABASE), goose providers; migrations/control/*.sql, migrations/blog/*.sql internal/store/ Store = control DB (users, blog registry, create/delete blog); - BlogStore = one blog's DB (settings, pages, posts, images, sections, modules, menu) + BlogStore = one blog's DB (settings, pages, posts, files, sections, modules, menu) internal/auth/ bcrypt, JWT issue/parse, cookie, HMAC CSRF internal/markdown/ Render(md) → sanitized HTML internal/i18n/ languages, T/Tf (English keys → catalog), FormatDate/Month, Accept-Language Match; el.go is the Greek catalog, el_months.go the month tables internal/slug/ Make/Valid/WithSuffix (Greek transliteration included) internal/web/ server.go (host router, middleware, render helpers) - routes.go (all routes), handlers_*.go (sections = announcements, layout = modules + menu) + routes.go (all routes), handlers_*.go (sections = announcements, layout = modules + menu, + files = the upload library), filetype.go (what an upload is, how it may be served) theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping) templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/) static/ (dashboard.css "paper & ink" look — plain CSS, no variables/flex; @@ -67,7 +70,7 @@ internal/web/ server.go (host router, middleware, render helpers) - **One database per blog**: the registry row (`blogs`: id, owner, subdomain, `db_name`) lives in the control DB; everything else — a one-row `settings` - table (title, tagline, language, theme) plus pages, posts, images, sections, modules, + table (title, tagline, language, theme) plus pages, posts, files, sections, modules, menu_items — lives in `blog_<sub>` (`db.DBName`: dashes → underscores, so subdomains are capped at 58 chars). No table in a blog DB carries a blog id; the database is the scope. `store.Store` (control) hands out a @@ -103,9 +106,16 @@ internal/web/ server.go (host router, middleware, render helpers) 7 days). Claims carry `uid` + `ver` (= `users.token_version`); the session middleware re-loads the user every request and drops the session if disabled or version mismatch. Password change / reset / disable bump - `token_version`. Every POST behind `requireAuth` must include + `token_version`. Every management POST must include `<input type="hidden" name="_csrf" value="{{.CSRF}}">`; the check runs in - `requireAuth`, which also caps the body at `MAX_UPLOAD_MB + 1 MB`. + `guardPOST`, which first caps the body at the limit it is given + 1 MB + and parses the form. `requireLogin` only redirects anonymous users; + `requireAuth` = login + `guardPOST(0)` (dashboard, password, admin: no + uploads); `withBlog` = login → `resolveBlog` → owner-or-superadmin → + `guardPOST(blog.UploadLimit(cfg))`, and `withBlogFiles(n, …)` allows n + limits for the Files page's multi-upload (`maxUploadFiles` = 10). Errors + from `guardPOST` go through `s.fail`, which answers JSON when the request + has `Accept: application/json` (the upload scripts). - **Templates**: each page file is parsed together with its layout (`layouts/dashboard.html` or `layouts/blog.html` for `blog/*`) and all `partials/*.html`. Page files define `content` (and optionally `title`). @@ -126,7 +136,8 @@ internal/web/ server.go (host router, middleware, render helpers) Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come from a colour input paired with a `<name>_custom` checkbox, shown/hidden with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields - use the `imagepick` partial's radios: `none` clears, a uuid selects, no + use the `imagepick` partial's radios (the library's `kind = image` files, + `ListFiles(ctx, "image", "", 0, 0)`): `none` clears, a uuid selects, no value keeps (`pickImage`); an upload in `<name>_file` wins. `Presets()`/`WithPreset` are the colour schemes (`POST /b/{sub}/design/preset`, colours only); `POST /b/{sub}/design/reset` stores `DefaultTheme()` and @@ -166,20 +177,44 @@ internal/web/ server.go (host router, middleware, render helpers) blog layout links instead. `GET /favicon.ico` on every host (`handleFavicon`) serves the png or redirects to the blog's image, so icon-probing browsers never hit the 404 page; `favicon.ico` is reserved. -- **Images**: `/media/{uuid}` served on every host with immutable cache - headers, from the host's blog database only (the root domain serves the - root blog's images). Dashboard previews for another blog on the root host - therefore use `GET /b/{sub}/media/{id}` (`withBlog`): `dashboard/images.html` - and the `imagepick` partial (`sub` arg). Markdown keeps the relative - `/media/…` form because post bodies render on the blog host. - Uploads are content-sniffed (png/jpeg/gif/webp/ico). Deleting an - image clears theme references to it. `POST /b/{sub}/images/upload` - answers JSON (`{id, filename, markdown}` / `{error}`) when the request - has `Accept: application/json`; that is what the editor script calls. +- **Files** (`files` table, `store/files.go`, `handlers_files.go`, + `filetype.go`, `/b/{sub}/files…`): the upload library, any type. A row is + `id, filename, content_type, kind, size, data`; `kind` (image, document, + audio, video, archive, other) is decided at upload by `fileType`, which + trusts `http.DetectContentType` first and lets the extension refine only a + generic sniff (text/plain, octet-stream) to a type on the allowlists in + `filetype.go`; anything unknown is stored as `application/octet-stream`. + `/media/{uuid}` is served on every host with immutable cache headers, from + the host's blog database only (the root domain serves the root blog's + files); dashboard previews for another blog on the root host use + `GET /b/{sub}/media/{id}` (`withBlog`). **The root domain carries the + session cookie, so `servedAs` renders inline only images, PDF, plain text, + audio and video; everything else (HTML, SVG, XML, JS, archives, binaries) + goes out as `application/octet-stream` + `Content-Disposition: attachment`.** + `?download` forces attachment. SVG is therefore never an image (download + only, and refused by the design page's `readUpload(…, imagesOnly)`); ICO + is. The bytes are streamed by `BlogStore.FileReader` (a `chunkReader` over + `substring()`, 512 KiB per query, Range requests included), which is why + the per-blog limit is capped at 1024 MB (`maxUploadMB`: int4 offsets). + Ids are immutable, so a renamed file keeps its old download name in + browsers that cached it. Markdown keeps the relative `/media/…` form + because post bodies render on the blog host; `fileMarkdown` writes + `` for images and `[name](…)` for the rest. Deleting a file + clears theme references to it. `POST /b/{sub}/files/upload` takes several + `file` parts (the no-JS `<input multiple>`), or answers JSON + (`{id, filename, kind, size, markdown}` / `{error}`) for one file when the + request has `Accept: application/json` — what the editor and the Files + page scripts call. `dashboard/files.html` lists by `?kind=&q=&p=` + (`ListFiles`, 50 per page, `pageBounds` clamps), shows `FileUsage` and the + blog's limit; rename/delete return to the `back` field. The per-blog limit + is `blogs.max_upload_bytes` in the control DB (NULL = `MAX_UPLOAD_MB`, now + 10), set at `POST /admin/blogs/{id}/upload-limit` from `admin/index.html`; + `Blog.UploadLimit(cfg)` resolves it. - **Editor** (`partials/editor.html`, args via `dict`: name, value, rows, - tall, upload, csrf): textarea + "Insert image" + cheat-sheet. Forms using - it must be `multipart/form-data` and their save handler must call - `s.readUpload(r, "inline_image")` + `appendImageMD` (the no-JS path). + tall, upload, csrf): textarea + "Insert file" + cheat-sheet; paste and + drop take any file. Forms using it must be `multipart/form-data` and + their save handler must call `s.readUpload(r, "inline_file", false)` + + `appendFileMD` (the no-JS path). - **Announcements** (`sections` table, `store/sections.go`, `handlers_sections.go`, `/b/{sub}/announcements…`): per-blog notices with `placement` (`<column>-<position>`: left|main|right × top|bottom, split by @@ -253,7 +288,8 @@ superadmin password to `admin`. Production refuses both. (runs in every blog database — it must not reference `users`/`blogs`) and `internal/db/migrations/control/` for users and the registry; goose `-- +goose Up/Down` sections; they run automatically at startup. Never - edit an applied migration. Blog chain so far: `00001_init`, `00002_language`. Both chains were re-baselined at 00001 after + edit an applied migration. Blog chain so far: `00001_init`, `00002_language`, + `00003_files`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after the move to per-blog databases; deployments from before it have `goose_db_version` rows 2–7 in the control DB that must be deleted once (README "Upgrading from a single database") or the next control migration |
