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.
14 KiB
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 bothindex.phpand 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 aHook::CONTEXT_READYcallback (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:
- A
<FilesMatch>exception so.php/.html/.md/.inifiles under/publiseraren't blocked by the CMS's "no direct file access" rule. - A
RewriteRuledispatching everything under/publiser(except.css/.js/image requests) tocontent/publiser/index.php, bypassing the CMS's own catch-all-to-/index.phprule.
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:
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
nyheterfolders get aYYYY-MM-DD-prefix at publish time; liveartiklerfolders don't (matches the pre-existing convention -nyheterposts are dated,artikleraren't). - Blocks: each content file (
.md/.html/.php) in a folder is one editable "block" (publiserListBlocks())..mdis edited as WYSIWYG rich text;.html/.phpas 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.iniis read withINI_SCANNER_TYPED(not the CMS's default scanner mode) so baretrue/falseround-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()(inindex.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\nto 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()inindex.php): runs once, when populating thecontenteditablediv on page load. - Client -> server (
serializeNode()/serializeChildren()inpubliser.js): runs on save, walking the edited DOM back to Markdown. - Untouched blocks are never rewritten: each block has a hidden
.p-block-hiddentextarea (default value = the block's raw, unmodified content) and a.p-dirty-flag(default0). 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 is0. 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 indata-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.htmlblock 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}frompubliserListBlocks()- they're a different language's version of the same content, not another sequential block.publiserExtractTitle()(title fallback for posts with no explicitmetadata.inititle) mirrorsapp/helpers.php'sextractTitle()but operates on the filtered block list - the site's ownextractTitle()would otherwise pickarticle.en.mdoverarticle.mdby 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
publiserContentRoot()must useDOCUMENT_ROOT, not a path relative to this file. The dev container mountscontent/at/var/www/html(named to match Apache's default docroot) whilecustom/andapp/keep their real names - a fixed relative path would only be correct in production, wherecontent/custom/appare plain siblings. Matches the same approachapp/config.php'screateContext()already uses.DirectorySlash Onincontent/publiser/.htaccess. The site setsDirectorySlash Offglobally. Without a local override, a bare/publiser(no trailing slash) request never reaches any rewrite rule at all - Apache falls through tomod_autoindexand 403s, since/publiseris a real directory. This is scoped to just this one directory; don't "fix" it by changing the site-wide setting.publiserMoveDir()'s copy+delete fallback is load-bearing, not defensive fluff.rename()fails withEXDEVacross bind-mount boundaries -content/andcustom/are separate mounts in the dev container even though they're the same host filesystem. Don't simplify this back to a barerename()call.INI_SCANNER_TYPEDinpubliserReadMetadata(). The CMS's ownloadMetadata()uses the default scanner, where baretrue/falsecome back as"1"/"". Reading typed and writing bare booleans back out (viapubliserIniValue()'sis_bool()check) is what keeps something likehide_list = truefrom being silently rewritten ashide_list = "1"the first time a post is saved through the tool.- 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.
.htpasswdmust stay world-readable (0644). It's regenerated byset-publiser-password.phpwith 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.- 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 upuser_tmp_tafter being routed through/tmp) - the container needscontainer_file_tto read them. Symptom: Apache logs "Server unable to read htaccess file" or similar despite correct Unix permissions. Fix:chcon -t container_file_t <path>(orrestorecon). - 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
Containerfileaddition (docker-php-ext-install gd) - not present in the stockphp:8.4-apacheimage. compose.yaml's startupchownwas extended to also covercontent/nyheterandcontent/artikler- nothing previously needed to write into the (bind-mounted, host-owned) content tree, so the container'swww-datahad 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.