Add helper functions to display human-friendly Norwegian dates and times in the publishing dashboard and editor. Add documentation for the publiser tool and update security checklists.
126 lines
14 KiB
Markdown
126 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. Set the login with:
|
|
|
|
```bash
|
|
php custom/tools/set-publiser-password.php <username> <password>
|
|
```
|
|
|
|
This writes `content/publiser/.htpasswd` (gitignored, `0644` - see comment in the script for why world-readable is fine here).
|
|
|
|
## 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.
|