innhold/docs/publiser-tool.md
Ruben 747e730366 Format dates in publiser tool
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.
2026-08-30 22:08:02 +02:00

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 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:

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.