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.
This commit is contained in:
Ruben 2026-08-30 22:08:02 +02:00
parent b609c8596c
commit 747e730366
3 changed files with 161 additions and 3 deletions

View file

@ -87,6 +87,36 @@ function publiserPublicUrl(string $section, string $folder): string {
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/';
}
/**
* Human-friendly display of a stored publish_at value (e.g. "2026-08-30T14:30",
* the raw <input type=datetime-local> format). Mirrors the day/month/year
* order app/plugins/global/languages.php's formatDate() uses for regular
* post dates, extended with a time-of-day. Only for display - never use
* this for the datetime-local input's own value attribute, which must stay
* in machine format.
*/
function publiserNorwegianDate(int $ts): string {
static $months = ['januar', 'februar', 'mars', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'desember'];
$day = (int)date('j', $ts);
$month = $months[(int)date('n', $ts) - 1];
return "{$day}. {$month} " . date('Y', $ts);
}
/** Human-friendly display of a date-only value (e.g. "2026-08-25"). Never
* use this for the <input type=date> value attribute, which must stay ISO. */
function publiserFormatDate(?string $raw): string {
if (!$raw) return '';
$ts = strtotime($raw);
return $ts === false ? $raw : publiserNorwegianDate($ts);
}
function publiserFormatScheduleTime(?string $raw): string {
if (!$raw) return '';
$ts = strtotime($raw);
if ($ts === false) return $raw;
return publiserNorwegianDate($ts) . ' kl. ' . date('H:i', $ts);
}
function publiserImageUrl(string $location, string $section, string $folder, string $filename): string {
if ($location === 'live') {
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/' . rawurlencode($filename);
@ -482,9 +512,9 @@ function publiserRenderList(string $csrf): void {
<div class="p-list-info">
<div class="p-list-title"><?= h($item['title']) ?></div>
<div class="p-list-meta">
<?= h($item['date']) ?>
<?= h(publiserFormatDate($item['date'])) ?>
<?php if ($tab === 'scheduled' && $item['publishAt']): ?>
&middot; publiseres <?= h($item['publishAt']) ?>
&middot; publiseres <?= h(publiserFormatScheduleTime($item['publishAt'])) ?>
<?php endif; ?>
</div>
</div>
@ -696,7 +726,7 @@ function publiserRenderEditor(string $csrf): void {
<?php if ($location === 'draft'): ?>
<?php if (($meta['status'] ?? '') === 'scheduled'): ?>
<p class="p-muted">Planlagt: <?= h($meta['publish_at'] ?? '') ?></p>
<p class="p-muted">Planlagt: <?= h(publiserFormatScheduleTime($meta['publish_at'] ?? null)) ?></p>
<button type="submit" name="unschedule" value="1" class="button">Avbryt planlegging</button>
<?php endif; ?>
<label>Planlegg publisering (valgfritt)

126
docs/publiser-tool.md Normal file
View file

@ -0,0 +1,126 @@
# 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.

View file

@ -54,3 +54,5 @@ Use **SSL/TLS** (Home > Security > SSL/TLS) or **AutoSSL** to ensure HTTPS is ac
- [ ] `custom/listmonk-config.php` exists but is NOT in git (check `.gitignore`)
- [ ] `custom/data/` directory writable by web server (`chmod 755` or `775`)
- [ ] `custom/data/.htaccess` present with `Require all denied`
- [ ] `content/nyheter/` and `content/artikler/` writable by web server (`/publiser` publishes into them)
- [ ] `/publiser` login set: `php custom/tools/set-publiser-password.php <user> <pass>` (writes `content/publiser/.htpasswd`, gitignored - see `docs/publiser-tool.md`)