Add support for multiple users in the password utility. New features allow listing existing users and removing specific users from the `.htpasswd` file without overwriting others.
128 lines
14 KiB
Markdown
128 lines
14 KiB
Markdown
# Publiser Tool Reference
|
|
|
|
For LLM agents working on the `/publiser` publishing tool. Read when modifying anything under `content/publiser/`, `custom/plugins/publiser-lib.php`, or `custom/plugins/global/scheduled-publisher.php`.
|
|
|
|
## Overview
|
|
|
|
Standalone admin app for authoring, managing, and scheduling `content/nyheter/` and `content/artikler/` posts without hand-editing files - built for non-technical editors. Password-protected (Basic Auth), served entirely outside the CMS routing/templating pipeline. Located in `content/publiser/`.
|
|
|
|
- `content/publiser/index.php` - the whole app: routing, auth, views, POST actions, image upload/serve, and the bounded Markdown<->HTML conversion used by the block editor.
|
|
- `custom/plugins/publiser-lib.php` - shared logic (paths, metadata I/O, block/file operations, publish/unpublish/trash/restore, scheduling). Required by both `index.php` and the scheduler plugin, so there is exactly one implementation of "what publishing a post means."
|
|
- `custom/plugins/global/scheduled-publisher.php` - auto-publishes due scheduled posts via a `Hook::CONTEXT_READY` callback (fires on every normal site page load).
|
|
- `content/publiser/publiser.css` / `publiser.js` - editor UI and the client-side half of the Markdown serialization.
|
|
- `custom/tools/set-publiser-password.php` - CLI to set/reset the login.
|
|
|
|
## Why It's Not Routed Through the CMS
|
|
|
|
`content/publiser/index.php` is served directly by Apache, not via `app/router.php`. It has no `Context`, no templates, no site chrome - it only reuses the pure, hook-free helpers from `app/helpers.php` (`findCoverImage`, `getSubdirectories`, `extractRawDateFromFolder`). This keeps the admin tool decoupled from `app/`'s request-routing internals (a separate git repo - see main README) while still sharing its file-format conventions.
|
|
|
|
## Routing & Auth
|
|
|
|
`content/.htaccess.base` (synced to `content/.htaccess` - see `docs/security-cpanel.md`) has two `/publiser`-specific additions:
|
|
|
|
1. A `<FilesMatch>` exception so `.php`/`.html`/`.md`/`.ini` files under `/publiser` aren't blocked by the CMS's "no direct file access" rule.
|
|
2. A `RewriteRule` dispatching everything under `/publiser` (except `.css`/`.js`/image requests) to `content/publiser/index.php`, bypassing the CMS's own catch-all-to-`/index.php` rule.
|
|
|
|
`content/publiser/.htaccess` sets `DirectorySlash On` (the site-wide default is `Off` - see Critical section below) and forwards the `Authorization` header via `RewriteRule ... [E=HTTP_AUTHORIZATION:...]` for SAPIs (LSAPI/CGI/FastCGI, used on cPanel) that strip it by default.
|
|
|
|
Auth itself is checked in PHP (`publiserRequireAuth()` in `index.php`), not via Apache's `AuthUserFile` - that directive needs an absolute path that differs between the podman dev container and cPanel, so a bcrypt `.htpasswd` (same format `mod_authn_file` would use) is read and verified manually instead. Manage logins with:
|
|
|
|
```bash
|
|
php custom/tools/set-publiser-password.php <username> <password> # add, or update if it exists
|
|
php custom/tools/set-publiser-password.php --list
|
|
php custom/tools/set-publiser-password.php --remove <username>
|
|
```
|
|
|
|
Supports multiple users - adding/updating one username preserves everyone else's entry in `content/publiser/.htpasswd` (gitignored, `0644` - see comment in the script for why world-readable is fine here). There's no per-user attribution anywhere in the tool (all edits are just "whoever is logged in"); this only lets more than one person log in.
|
|
|
|
## Content Model
|
|
|
|
Drafts and trash are staged **outside** the public content tree, mirroring the exact folder shape the CMS reads (so "publish" is just a directory move):
|
|
|
|
```
|
|
custom/data/publiser/ (gitignored - runtime state, not source content)
|
|
drafts/
|
|
nyheter/<slug>/ # metadata.ini, 10-*.md, 20-*.html, cover.*, images...
|
|
artikler/<slug>/
|
|
trash/
|
|
nyheter/<slug>~<unix-timestamp>/
|
|
artikler/<slug>~<unix-timestamp>/
|
|
```
|
|
|
|
- **Folder naming**: draft/trash folders are always just the slug (no date prefix), for both sections. Live `nyheter` folders get a `YYYY-MM-DD-` prefix at publish time; live `artikler` folders don't (matches the pre-existing convention - `nyheter` posts are dated, `artikler` aren't).
|
|
- **Blocks**: each content file (`.md`/`.html`/`.php`) in a folder is one editable "block" (`publiserListBlocks()`). `.md` is edited as WYSIWYG rich text; `.html`/`.php` as raw code (never parsed/rendered in the editor). Order is filename natural-sort; the tool renumbers to a step-10 prefix (`10-`, `20-`...) whenever blocks are reordered or added, leaving room to insert between existing ones.
|
|
- **Non-block files** (subfolders, PDFs, `.en.*` translations) are listed read-only in the editor (`publiserListExtraFiles()`) with a note to edit them directly - the tool doesn't try to manage arbitrary file types or translations.
|
|
- **Metadata**: `metadata.ini` is read with `INI_SCANNER_TYPED` (not the CMS's default scanner mode) so bare `true`/`false` round-trip as real booleans instead of PHP's normal-mode "1"/"" quirk - see Critical section.
|
|
|
|
## Editing Model: the H1/Title Convention
|
|
|
|
The site has no metadata-driven page heading - `page.php` just outputs `$content` directly, so the visible `<h1>` comes entirely from a leading `# Title` line in the first content file (same convention `extractTitle()` in `app/helpers.php` relies on). The editor's "Tittel" field owns that line:
|
|
|
|
- On load, `publiserStripLeadingH1()` (in `index.php`) strips a leading `# ...` line from the **first richtext block only**, so it doesn't show up duplicated inside the editable body.
|
|
- On save, `publiserApplyFormSave()` always re-prepends `# {title}\n\n` to that same block, keeping the file's real H1 in sync with the title field - even if the block's body wasn't otherwise touched.
|
|
|
|
## Markdown <-> HTML (Bounded, Not General-Purpose)
|
|
|
|
No markdown library is used for the editor - a small, closed-world parser handles only the constructs the toolbar can produce: paragraphs, `##`/`###`, bold/italic, links, images, `>` blockquotes, `-`/`1.` lists, and raw HTML blocks (see below). This is deliberate: it doesn't need to handle arbitrary CommonMark, only what this tool itself writes.
|
|
|
|
- **Server -> client** (`publiserBlockMdToHtml()` in `index.php`): runs once, when populating the `contenteditable` div on page load.
|
|
- **Client -> server** (`serializeNode()`/`serializeChildren()` in `publiser.js`): runs on save, walking the edited DOM back to Markdown.
|
|
- **Untouched blocks are never rewritten**: each block has a hidden `.p-block-hidden` textarea (default value = the block's raw, unmodified content) and a `.p-dirty-flag` (default `0`). JS only overwrites the hidden field and flips the flag when the user actually edits that specific block. The backend skips saving any block whose dirty flag is `0`. This protects legacy content with markdown constructs the bounded parser doesn't understand from being silently flattened/corrupted by a save the user never intended to touch that block.
|
|
- **Raw HTML embedded in markdown** (e.g. an `<iframe>` embed, common on older posts): rendered as a live, `contenteditable="false"` island with the exact source stashed in `data-raw-html`, so it displays correctly (not as escaped literal text) and round-trips byte-for-byte on save without needing DOM reconstruction. Prefer restructuring such content into its own `.html` block instead when editing an existing post (see Riksrevisjonen/SPLEIS posts for the pattern) - the raw-HTML island is a safety net, not the intended authoring path.
|
|
|
|
## i18n
|
|
|
|
The site supports translations via paired files (`article.md` + `article.en.md`, `[en]` metadata sections). The tool only edits the base (Norwegian) language:
|
|
|
|
- `publiserIsLanguageVariant()` excludes `*.en.{md,html,php}` from `publiserListBlocks()` - they're a different language's version of the same content, not another sequential block.
|
|
- `publiserExtractTitle()` (title fallback for posts with no explicit `metadata.ini` title) mirrors `app/helpers.php`'s `extractTitle()` but operates on the filtered block list - the site's own `extractTitle()` would otherwise pick `article.en.md` over `article.md` by alphabetical accident (`"en" < "md"`), surfacing an English title in the Norwegian-only dashboard.
|
|
- Translation files are surfaced as a read-only "🌐 (engelsk oversettelse)" entry in the editor instead of being hidden entirely.
|
|
|
|
There is no UI for editing or creating translations - that's still a manual, hand-edit-the-files task.
|
|
|
|
## Scheduling
|
|
|
|
`custom/plugins/global/scheduled-publisher.php` is registered in `custom/config.ini`'s `[plugins] enabled` list. It hooks `Hook::CONTEXT_READY`, which fires on every site page load (via `app/config.php`'s `createContext()`), and calls `publiserPublishDueScheduledItems()`: scans `custom/data/publiser/drafts/*/*` for `status = "scheduled"` items whose `publish_at` has passed, and publishes them through the exact same `publiserPublish()` path the admin UI uses.
|
|
|
|
No cron job - publishing happens on the next real visitor request after the scheduled time. Accepted tradeoff for a low-traffic nonprofit site: no hosting-level cron setup required, at the cost of not being to-the-second.
|
|
|
|
## Key Functions (`custom/plugins/publiser-lib.php`)
|
|
|
|
| Function | Purpose |
|
|
|---|---|
|
|
| `publiserContentRoot()` | `content/` dir, resolved via `$_SERVER['DOCUMENT_ROOT']` - see Critical section |
|
|
| `publiserLiveDir/DraftDir/TrashDir()` | Path resolvers for the three storage locations |
|
|
| `publiserSlugify()` / `publiserUniqueSlug()` | Norwegian-char-aware slug generation with collision avoidance |
|
|
| `publiserReadMetadata()` / `publiserWriteMetadataFile()` | `metadata.ini` I/O (typed scanner on read; hand-rolled INI serializer on write) |
|
|
| `publiserLockedRename()` / `publiserMoveDir()` | `flock()`-guarded directory move, with a copy+delete fallback for cross-filesystem moves |
|
|
| `publiserListBlocks()` / `publiserAddBlock()` / `publiserReorderBlock()` / `publiserDeleteBlock()` | Block (content file) management |
|
|
| `publiserExtractTitle()` | Language-aware title fallback (see i18n section) |
|
|
| `publiserSaveUploadedImage()` / `publiserSaveCoverImage()` | GD resize (max 1600px wide, quality 82) + save |
|
|
| `publiserCreateDraft()` / `publiserPublish()` / `publiserUnpublish()` / `publiserTrash()` / `publiserRestore()` | The five state transitions; all soft (nothing is ever `unlink()`-ed except superseded cover images) |
|
|
| `publiserSetSchedule()` / `publiserPublishDueScheduledItems()` | Scheduling |
|
|
| `publiserListLive()` / `publiserListStaged()` / `publiserSummarize()` | Dashboard listing |
|
|
|
|
## Critical: Do Not Break
|
|
|
|
1. **`publiserContentRoot()` must use `DOCUMENT_ROOT`, not a path relative to this file.** The dev container mounts `content/` at `/var/www/html` (named to match Apache's default docroot) while `custom/` and `app/` keep their real names - a fixed relative path would only be correct in production, where `content`/`custom`/`app` are plain siblings. Matches the same approach `app/config.php`'s `createContext()` already uses.
|
|
2. **`DirectorySlash On` in `content/publiser/.htaccess`.** The site sets `DirectorySlash Off` globally. Without a local override, a bare `/publiser` (no trailing slash) request never reaches any rewrite rule at all - Apache falls through to `mod_autoindex` and 403s, since `/publiser` is a real directory. This is scoped to just this one directory; don't "fix" it by changing the site-wide setting.
|
|
3. **`publiserMoveDir()`'s copy+delete fallback is load-bearing, not defensive fluff.** `rename()` fails with `EXDEV` across bind-mount boundaries - `content/` and `custom/` are separate mounts in the dev container even though they're the same host filesystem. Don't simplify this back to a bare `rename()` call.
|
|
4. **`INI_SCANNER_TYPED` in `publiserReadMetadata()`.** The CMS's own `loadMetadata()` uses the default scanner, where bare `true`/`false` come back as `"1"`/`""`. Reading typed and writing bare booleans back out (via `publiserIniValue()`'s `is_bool()` check) is what keeps something like `hide_list = true` from being silently rewritten as `hide_list = "1"` the first time a post is saved through the tool.
|
|
5. **Untouched blocks must stay byte-identical.** Don't change the dirty-flag/hidden-field mechanism (see Markdown section) without preserving the guarantee that a block the user never focused is never rewritten - that's what makes it safe to open legacy posts with hand-crafted Markdown the bounded parser can't fully round-trip.
|
|
6. **`.htpasswd` must stay world-readable (`0644`).** It's regenerated by `set-publiser-password.php` with that mode deliberately - the web server user (`www-data`, uid varies by host/container) needs read access, and direct HTTP access is already denied by `<Files ".htpasswd">` in the `.htaccess`. A stricter mode (e.g. `0640`) breaks auth silently if the file's owning group doesn't include the web server user.
|
|
7. **SELinux labels on Fedora/podman hosts.** If you ever manipulate files under `content/publiser/` via host-side shell tools (not through the app itself), watch for context drift (e.g. picking up `user_tmp_t` after being routed through `/tmp`) - the container needs `container_file_t` to read them. Symptom: Apache logs "Server unable to read htaccess file" or similar despite correct Unix permissions. Fix: `chcon -t container_file_t <path>` (or `restorecon`).
|
|
8. **One publish code path.** `publiserPublish()` is called both by the admin UI's "Publiser" button and by the scheduler. Don't duplicate publish logic elsewhere - both callers must go through this function so scheduled and manual publishing can never disagree about what "published" means.
|
|
|
|
## Known Limitations (By Design, Not Oversights)
|
|
|
|
- No English/translation editing - read-only awareness only (see i18n).
|
|
- No permanent-delete UI - trash is soft-forever; cleanup is a manual filesystem task on the server.
|
|
- No per-editor accounts/audit trail - one shared Basic Auth login.
|
|
- Editing an existing post with multiple content files works (each is a block), but files outside the block model (subfolders, PDFs) must still be managed by hand.
|
|
- Scheduling fires on next page view, not at an exact time (see Scheduling section).
|
|
|
|
## Local Dev Notes
|
|
|
|
- GD extension (image resize) requires the `Containerfile` addition (`docker-php-ext-install gd`) - not present in the stock `php:8.4-apache` image.
|
|
- `compose.yaml`'s startup `chown` was extended to also cover `content/nyheter` and `content/artikler` - nothing previously needed to write into the (bind-mounted, host-owned) content tree, so the container's `www-data` had no write access there before this tool existed.
|
|
- Test login for local dev is whatever you last set with `set-publiser-password.php`; it's gitignored and won't carry over to a fresh checkout or to production.
|