Add standalone /publiser admin application

Implement a minimal, dependency-free publishing tool to manage news
and articles. The app includes:

- A Markdown-based block editor with rich-text capabilities.
- Image upload and resizing using GD.
- Support for drafts, scheduled publishing, and a trash system.
- Integrated Basic Auth protection via PHP and .htpasswd.
- A global plugin for automatic execution of scheduled posts.

The tool reuses core site logic in custom/plugins/publiser-lib.php to
ensure consistency between the editor and the live site.
This commit is contained in:
Ruben 2026-08-30 21:54:17 +02:00
parent c7ec163a36
commit b609c8596c
15 changed files with 2301 additions and 8 deletions

View file

@ -3,7 +3,7 @@ default = "no"
available = "no"
[plugins]
enabled = "languages"
enabled = "languages, scheduled-publisher"
[feed]
exclude_files = "nyhetsbrev, newsletter"

View file

@ -0,0 +1,24 @@
<?php
/**
* Scheduled Publisher Plugin
*
* Publishes due scheduled posts created via /publiser. Runs on every page
* request (piggybacking on normal site traffic) instead of relying on a
* cron job - there's nothing to configure on the host, and with regular
* traffic a scheduled post goes live within moments of its scheduled time.
*
* Usage: add to custom/config.ini:
* [plugins]
* enabled = "languages, scheduled-publisher"
*/
require_once __DIR__ . '/../publiser-lib.php';
Hooks::add(Hook::CONTEXT_READY, function (Context $ctx, array $config) {
try {
publiserPublishDueScheduledItems();
} catch (Throwable $e) {
error_log('scheduled-publisher: ' . $e->getMessage());
}
return $ctx;
});

View file

@ -0,0 +1,691 @@
<?php
/**
* Shared library for the /publiser publishing tool.
*
* Used by both content/publiser/index.php (the admin app) and
* custom/plugins/global/scheduled-publisher.php (the auto-publish hook),
* so there is exactly one implementation of what "publishing a post" means.
*
* Deliberately reuses app/ helpers (findCoverImage, extractRawDateFromFolder,
* getSubdirectories, extractTitle, CONTENT_EXTENSIONS, COVER_IMAGE_EXTENSIONS)
* rather than duplicating them, so the tool and the live site can never
* disagree about what a "cover image" or "date" is. app/ is a separate git
* repo but is always deployed alongside this one (see README's symlink
* setup); custom/plugins/page/* already depends on it the same way.
*/
define('PUBLISER_ROOT', dirname(__DIR__, 2));
define('PUBLISER_MAX_IMAGE_WIDTH', 1600);
define('PUBLISER_IMAGE_QUALITY', 82);
define('PUBLISER_SECTIONS', ['nyheter', 'artikler']);
require_once PUBLISER_ROOT . '/app/hooks.php';
require_once PUBLISER_ROOT . '/app/constants.php';
require_once PUBLISER_ROOT . '/app/helpers.php';
// --- Paths ---------------------------------------------------------------
/**
* The content/ directory. Resolved via DOCUMENT_ROOT (same approach as
* app/config.php's createContext()) rather than a path relative to this
* file, because 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 from here would only be correct in
* production, where content/custom/app are plain siblings.
*/
function publiserContentRoot(): string {
$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? (PUBLISER_ROOT . '/content');
return rtrim($docRoot, '/');
}
function publiserDataRoot(): string {
return PUBLISER_ROOT . '/custom/data/publiser';
}
function publiserAssertSection(string $section): void {
if (!in_array($section, PUBLISER_SECTIONS, true)) {
throw new InvalidArgumentException("Unknown section: $section");
}
}
function publiserLiveDir(string $section, string $folder): string {
publiserAssertSection($section);
return publiserContentRoot() . "/$section/" . basename($folder);
}
function publiserDraftDir(string $section, string $folder): string {
publiserAssertSection($section);
return publiserDataRoot() . "/drafts/$section/" . basename($folder);
}
function publiserTrashDir(string $section, string $folder): string {
publiserAssertSection($section);
return publiserDataRoot() . "/trash/$section/" . basename($folder);
}
function publiserEnsureDir(string $dir): void {
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
throw new RuntimeException("Could not create directory: $dir");
}
}
function publiserEnsureParentDir(string $path): void {
publiserEnsureDir(dirname($path));
}
// --- Slugs -----------------------------------------------------------------
function publiserSlugify(string $text): string {
$map = [
'æ' => 'ae', 'ø' => 'o', 'å' => 'a', 'Æ' => 'ae', 'Ø' => 'o', 'Å' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ü' => 'u', 'ö' => 'o', 'ä' => 'a',
];
$text = strtr($text, $map);
$text = mb_strtolower($text, 'UTF-8');
$text = preg_replace('/[^a-z0-9]+/u', '-', $text) ?? '';
$text = trim($text, '-');
$text = preg_replace('/-+/', '-', $text) ?? '';
return $text === '' ? 'uten-tittel' : $text;
}
function publiserSlugTaken(string $section, string $slug, ?string $excludeLiveFolder = null): bool {
if (is_dir(publiserDraftDir($section, $slug))) return true;
$liveRoot = publiserContentRoot() . "/$section";
foreach ((glob("$liveRoot/*", GLOB_ONLYDIR) ?: []) as $d) {
$name = basename($d);
if ($excludeLiveFolder !== null && $name === $excludeLiveFolder) continue;
if ($name === $slug) return true;
if (preg_match('/^\d{4}-\d{2}-\d{2}-' . preg_quote($slug, '/') . '$/', $name)) return true;
}
return false;
}
/** $excludeLiveFolder: skip this live folder in the collision check - used
* when re-deriving a slug for an item that is itself being moved (e.g.
* unpublish), so it doesn't collide with its own current live folder. */
function publiserUniqueSlug(string $section, string $baseSlug, ?string $excludeLiveFolder = null): string {
$slug = $baseSlug;
$i = 2;
while (publiserSlugTaken($section, $slug, $excludeLiveFolder)) {
$slug = "{$baseSlug}-{$i}";
$i++;
}
return $slug;
}
// --- metadata.ini read/write ------------------------------------------------
function publiserReadMetadata(string $dir): array {
$path = "$dir/metadata.ini";
if (!file_exists($path)) return [];
// TYPED (unlike the plain parse_ini_file() the CMS itself uses to read
// metadata) keeps bare `true`/`false` as real booleans instead of
// PHP's "1"/"" normal-mode quirk, so publiserIniValue() can round-trip
// them back to bare true/false instead of a quoted "1" string.
return parse_ini_file($path, true, INI_SCANNER_TYPED) ?: [];
}
function publiserIniValue(mixed $value): string {
if (is_bool($value)) return $value ? 'true' : 'false';
if (is_int($value) || is_float($value)) return (string)$value;
$str = (string)$value;
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $str) . '"';
}
/** Writes a full metadata array back to disk. Array-valued keys become [section] blocks (e.g. "en"). */
function publiserWriteMetadataFile(string $dir, array $meta): void {
$lines = [];
$sectionBlocks = [];
foreach ($meta as $key => $value) {
if (is_array($value)) {
$sectionBlocks[$key] = $value;
continue;
}
if ($value === null || $value === '') continue;
$lines[] = "$key = " . publiserIniValue($value);
}
foreach ($sectionBlocks as $name => $fields) {
$sectionLines = [];
foreach ($fields as $k => $v) {
if ($v === null || $v === '' || is_array($v)) continue;
$sectionLines[] = "$k = " . publiserIniValue($v);
}
if (!$sectionLines) continue;
$lines[] = '';
$lines[] = "[$name]";
array_push($lines, ...$sectionLines);
}
publiserAtomicWrite("$dir/metadata.ini", implode("\n", $lines) . "\n");
}
function publiserSaveMetadata(string $dir, array $fields): void {
$existing = publiserReadMetadata($dir);
foreach ($fields as $k => $v) {
if ($v === null) {
unset($existing[$k]);
continue;
}
$existing[$k] = $v;
}
publiserWriteMetadataFile($dir, $existing);
}
function publiserAtomicWrite(string $path, string $content): void {
$fp = fopen($path, 'c');
if (!$fp) throw new RuntimeException("Cannot open for writing: $path");
if (!flock($fp, LOCK_EX)) {
fclose($fp);
throw new RuntimeException("Cannot lock: $path");
}
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $content);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
}
// --- Locked move (publish / unpublish / trash / restore all go through this) ---
function publiserLockedRename(string $src, string $dest): void {
if (!is_dir($src)) throw new RuntimeException("Not found: $src");
if (file_exists($dest)) throw new RuntimeException("Already exists: $dest");
$lockPath = $src . '/.publiser-lock';
$fp = fopen($lockPath, 'c');
if (!$fp) throw new RuntimeException('Could not create lock file');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
throw new RuntimeException('Item is being modified by another request, try again');
}
clearstatcache(true, $src);
if (!is_dir($src)) {
flock($fp, LOCK_UN);
fclose($fp);
throw new RuntimeException('Item was already moved');
}
@unlink($lockPath);
$ok = publiserMoveDir($src, $dest);
flock($fp, LOCK_UN);
fclose($fp);
if (!$ok) throw new RuntimeException("Failed to move $src to $dest");
}
/**
* Moves a directory, falling back to recursive copy+delete if rename()
* fails (EXDEV - crossing a filesystem/mount boundary). content/ and
* custom/ are separate bind mounts in the dev container even though
* they're the same host filesystem, so this isn't just a theoretical case.
*/
function publiserMoveDir(string $src, string $dest): bool {
if (@rename($src, $dest)) {
return true;
}
try {
publiserCopyRecursive($src, $dest);
publiserRemoveRecursive($src);
return true;
} catch (Throwable $e) {
if (is_dir($dest)) {
publiserRemoveRecursive($dest);
}
error_log('publiser: move fallback failed: ' . $e->getMessage());
return false;
}
}
function publiserCopyRecursive(string $src, string $dest): void {
publiserEnsureDir($dest);
foreach ((scandir($src) ?: []) as $item) {
if ($item === '.' || $item === '..') continue;
$srcPath = "$src/$item";
$destPath = "$dest/$item";
if (is_dir($srcPath)) {
publiserCopyRecursive($srcPath, $destPath);
} elseif (!copy($srcPath, $destPath)) {
throw new RuntimeException("Failed to copy $srcPath to $destPath");
}
}
}
function publiserRemoveRecursive(string $dir): void {
foreach ((scandir($dir) ?: []) as $item) {
if ($item === '.' || $item === '..') continue;
$path = "$dir/$item";
if (is_dir($path)) {
publiserRemoveRecursive($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
// --- Blocks (content files) -------------------------------------------------
/**
* True for a language-specific content file (e.g. "article.en.md"). The
* site supports translations this way (see docs/content-system.md), but
* /publiser only edits the base-language content - translations are listed
* as read-only in publiserListExtraFiles() instead of being treated as
* ordinary blocks, since they're a different language's version of the
* SAME content, not another sequential block of it.
*/
function publiserIsLanguageVariant(string $filename): bool {
return (bool) preg_match('/\.en\.(md|html|php)$/i', $filename);
}
function publiserListBlocks(string $dir): array {
if (!is_dir($dir)) return [];
$files = scandir($dir) ?: [];
$blocks = [];
foreach ($files as $f) {
if ($f === '.' || $f === '..') continue;
if (publiserIsLanguageVariant($f)) continue;
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
if (!in_array($ext, CONTENT_EXTENSIONS, true)) continue;
$blocks[] = [
'filename' => $f,
'ext' => $ext,
'type' => $ext === 'md' ? 'richtext' : 'code',
];
}
usort($blocks, fn($a, $b) => strnatcmp($a['filename'], $b['filename']));
return $blocks;
}
/** The base-language title, mirroring app/helpers.php's extractTitle() but
* restricted to publiserListBlocks() (i.e. skipping .en. translation
* files) so a post with an English translation doesn't show its English
* title/H1 by alphabetical accident ("article.en.md" sorts before
* "article.md"). */
function publiserExtractTitle(string $dir): ?string {
foreach (publiserListBlocks($dir) as $block) {
$content = file_get_contents($dir . '/' . $block['filename']) ?: '';
if ($block['ext'] === 'md' && preg_match('/^#\s+(.+)$/m', $content, $m)) {
return trim($m[1]);
}
if (in_array($block['ext'], ['html', 'php'], true) && preg_match('/<h1[^>]*>(.*?)<\/h1>/i', $content, $m)) {
return trim(strip_tags($m[1]));
}
}
return null;
}
function publiserListExtraFiles(string $dir): array {
$extra = [];
foreach (getSubdirectories($dir) as $sub) {
$extra[] = ['type' => 'folder', 'name' => $sub];
}
foreach ((glob("$dir/*.pdf") ?: []) as $pdf) {
$extra[] = ['type' => 'pdf', 'name' => basename($pdf)];
}
foreach ((glob("$dir/*") ?: []) as $f) {
if (is_file($f) && publiserIsLanguageVariant(basename($f))) {
$extra[] = ['type' => 'translation', 'name' => basename($f)];
}
}
return $extra;
}
function publiserSaveBlockContent(string $dir, string $filename, string $content): void {
$path = $dir . '/' . basename($filename);
if (!is_file($path)) throw new RuntimeException('Block not found');
publiserAtomicWrite($path, $content);
}
function publiserRenumberBlocks(string $dir, array $orderedFilenames): array {
$temps = [];
foreach ($orderedFilenames as $i => $filename) {
$tmp = ".reorder-tmp-$i-" . uniqid();
rename("$dir/$filename", "$dir/$tmp");
$stem = preg_replace('/^\d+-/', '', pathinfo($filename, PATHINFO_FILENAME));
if ($stem === '' || $stem === null) $stem = 'block';
$temps[] = ['tmp' => $tmp, 'stem' => $stem, 'ext' => pathinfo($filename, PATHINFO_EXTENSION)];
}
$result = [];
foreach ($temps as $i => $t) {
$newName = sprintf('%d-%s.%s', ($i + 1) * 10, $t['stem'], $t['ext']);
rename("$dir/{$t['tmp']}", "$dir/$newName");
$result[] = $newName;
}
return $result;
}
function publiserReorderBlock(string $dir, string $filename, string $direction): array {
$blocks = array_column(publiserListBlocks($dir), 'filename');
$idx = array_search($filename, $blocks, true);
if ($idx === false) throw new RuntimeException('Block not found');
$swapWith = $direction === 'up' ? $idx - 1 : $idx + 1;
if ($swapWith < 0 || $swapWith >= count($blocks)) return $blocks;
[$blocks[$idx], $blocks[$swapWith]] = [$blocks[$swapWith], $blocks[$idx]];
return publiserRenumberBlocks($dir, $blocks);
}
function publiserAddBlock(string $dir, string $type = 'md'): string {
$ext = in_array($type, CONTENT_EXTENSIONS, true) ? $type : 'md';
$stem = $ext === 'md' ? 'tekst' : $ext;
$blocks = array_column(publiserListBlocks($dir), 'filename');
$hasUnprefixed = false;
$maxPrefix = 0;
foreach ($blocks as $f) {
if (preg_match('/^(\d+)-/', $f, $m)) {
$maxPrefix = max($maxPrefix, (int)$m[1]);
} else {
$hasUnprefixed = true;
}
}
if ($hasUnprefixed && count($blocks) > 0) {
$blocks = publiserRenumberBlocks($dir, $blocks);
$maxPrefix = count($blocks) * 10;
}
$newPrefix = $maxPrefix + 10;
$filename = sprintf('%d-%s.%s', $newPrefix, $stem, $ext);
$i = 2;
while (file_exists("$dir/$filename")) {
$filename = sprintf('%d-%s-%d.%s', $newPrefix, $stem, $i, $ext);
$i++;
}
touch("$dir/$filename");
return $filename;
}
function publiserDeleteBlock(string $dir, string $filename): void {
$path = $dir . '/' . basename($filename);
if (!is_file($path)) throw new RuntimeException('Block not found');
unlink($path);
}
// --- Images ------------------------------------------------------------
function publiserUniqueImageName(string $dir, string $originalName, string $ext): string {
$base = publiserSlugify(pathinfo($originalName, PATHINFO_FILENAME));
if ($base === 'uten-tittel') $base = 'bilde';
$candidate = "$base.$ext";
$i = 2;
while (file_exists("$dir/$candidate")) {
$candidate = "{$base}-{$i}.$ext";
$i++;
}
return $candidate;
}
/**
* Re-encodes an uploaded image via GD (resize to max width, recompress) and
* saves it into $dir. $forceStem, if given, fixes the output basename
* (used for cover images: always "cover.<ext>").
*/
function publiserSaveUploadedImage(string $dir, string $tmpPath, string $originalName, ?string $forceStem = null): string {
$info = getimagesize($tmpPath);
if (!$info) throw new RuntimeException('Filen er ikke et gyldig bilde');
[$width, $height, $type] = $info;
$allowed = [
IMAGETYPE_JPEG => 'jpg',
IMAGETYPE_PNG => 'png',
IMAGETYPE_WEBP => 'webp',
IMAGETYPE_GIF => 'gif',
];
if (!isset($allowed[$type])) throw new RuntimeException('Bildeformatet stottes ikke');
$ext = $allowed[$type];
$image = match ($type) {
IMAGETYPE_JPEG => imagecreatefromjpeg($tmpPath),
IMAGETYPE_PNG => imagecreatefrompng($tmpPath),
IMAGETYPE_WEBP => imagecreatefromwebp($tmpPath),
IMAGETYPE_GIF => imagecreatefromgif($tmpPath),
};
if (!$image) throw new RuntimeException('Kunne ikke lese bildet');
if ($width > PUBLISER_MAX_IMAGE_WIDTH) {
$newHeight = (int) round($height * (PUBLISER_MAX_IMAGE_WIDTH / $width));
$resized = imagecreatetruecolor(PUBLISER_MAX_IMAGE_WIDTH, $newHeight);
if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) {
imagealphablending($resized, false);
imagesavealpha($resized, true);
}
imagecopyresampled($resized, $image, 0, 0, 0, 0, PUBLISER_MAX_IMAGE_WIDTH, $newHeight, $width, $height);
imagedestroy($image);
$image = $resized;
}
$basename = $forceStem !== null ? "{$forceStem}.{$ext}" : publiserUniqueImageName($dir, $originalName, $ext);
$destPath = "$dir/$basename";
match ($type) {
IMAGETYPE_JPEG => imagejpeg($image, $destPath, PUBLISER_IMAGE_QUALITY),
IMAGETYPE_PNG => imagepng($image, $destPath, 6),
IMAGETYPE_WEBP => imagewebp($image, $destPath, PUBLISER_IMAGE_QUALITY),
IMAGETYPE_GIF => imagegif($image, $destPath),
};
imagedestroy($image);
return $basename;
}
function publiserSaveCoverImage(string $dir, string $tmpPath, string $originalName): string {
foreach (COVER_IMAGE_EXTENSIONS as $ext) {
$old = "$dir/cover.$ext";
if (file_exists($old)) unlink($old);
}
return publiserSaveUploadedImage($dir, $tmpPath, $originalName, 'cover');
}
// --- Item loading & listing ----------------------------------------------
function publiserLoadItem(string $location, string $section, string $folder): ?array {
$dir = match ($location) {
'live' => publiserLiveDir($section, $folder),
'draft' => publiserDraftDir($section, $folder),
'trash' => publiserTrashDir($section, $folder),
default => throw new InvalidArgumentException("Unknown location: $location"),
};
if (!is_dir($dir)) return null;
$meta = publiserReadMetadata($dir);
$base = array_filter($meta, fn($v) => !is_array($v));
$metaSections = array_filter($meta, fn($v) => is_array($v));
return [
'location' => $location,
'section' => $section,
'folder' => $folder,
'dir' => $dir,
'metadata' => $base,
'metaSections' => $metaSections,
'blocks' => publiserListBlocks($dir),
'cover' => findCoverImage($dir),
'extra' => publiserListExtraFiles($dir),
];
}
function publiserSummarize(string $location, string $section, string $folder, string $dir, array $meta): array {
$title = $meta['title'] ?? publiserExtractTitle($dir) ?? $folder;
$date = $meta['date'] ?? extractRawDateFromFolder($folder) ?? date('Y-m-d', filemtime($dir));
return [
'location' => $location,
'section' => $section,
'folder' => $folder,
'title' => $title,
'summary' => $meta['summary'] ?? null,
'cover' => findCoverImage($dir),
'date' => $date,
'sortKey' => $date . $folder,
'status' => $meta['status'] ?? ($location === 'live' ? 'published' : 'draft'),
'publishAt' => $meta['publish_at'] ?? null,
];
}
function publiserListLive(string $section): array {
publiserAssertSection($section);
$root = publiserContentRoot() . "/$section";
$items = [];
foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $itemDir) {
$folder = basename($itemDir);
$items[] = publiserSummarize('live', $section, $folder, $itemDir, publiserReadMetadata($itemDir));
}
usort($items, fn($a, $b) => strcmp($b['sortKey'], $a['sortKey']));
return $items;
}
function publiserListStaged(string $stage, string $section): array {
publiserAssertSection($section);
$location = $stage === 'trash' ? 'trash' : 'draft';
$root = publiserDataRoot() . "/$stage/$section";
$items = [];
foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $itemDir) {
$folder = basename($itemDir);
$items[] = publiserSummarize($location, $section, $folder, $itemDir, publiserReadMetadata($itemDir));
}
usort($items, fn($a, $b) => strcmp($b['sortKey'], $a['sortKey']));
return $items;
}
// --- Create / publish / unpublish / trash / restore -----------------------
function publiserCreateDraft(string $section, string $title): array {
publiserAssertSection($section);
$title = trim($title) !== '' ? trim($title) : 'Uten tittel';
$slug = publiserUniqueSlug($section, publiserSlugify($title));
$dir = publiserDraftDir($section, $slug);
publiserEnsureDir($dir);
publiserWriteMetadataFile($dir, ['title' => $title, 'status' => 'draft']);
touch("$dir/10-tekst.md");
return ['section' => $section, 'folder' => $slug];
}
function publiserPublish(string $section, string $folder, ?string $publishDate = null): array {
publiserAssertSection($section);
$srcDir = publiserDraftDir($section, $folder);
if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke utkastet');
$meta = publiserReadMetadata($srcDir);
$slug = $folder;
$date = $publishDate ?: ($meta['date'] ?? date('Y-m-d'));
$destFolder = $section === 'nyheter' ? "{$date}-{$slug}" : $slug;
if ($destFolder !== $folder && publiserSlugTaken($section, $destFolder)) {
// extremely unlikely (slug already unique-checked at draft creation), but stay safe
$destFolder = publiserUniqueSlug($section, $destFolder);
}
$destDir = publiserLiveDir($section, $destFolder);
publiserLockedRename($srcDir, $destDir);
$meta['date'] = $date;
unset($meta['status'], $meta['publish_at']);
publiserWriteMetadataFile($destDir, $meta);
return ['section' => $section, 'folder' => $destFolder];
}
function publiserUnpublish(string $section, string $folder): array {
publiserAssertSection($section);
$srcDir = publiserLiveDir($section, $folder);
if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke innlegget');
$meta = publiserReadMetadata($srcDir);
$slug = publiserDeriveSlugFromLiveFolder($section, $folder, $meta);
$slug = publiserUniqueSlug($section, $slug, $folder);
$destDir = publiserDraftDir($section, $slug);
publiserLockedRename($srcDir, $destDir);
$meta['status'] = 'draft';
unset($meta['publish_at']);
publiserWriteMetadataFile($destDir, $meta);
return ['section' => $section, 'folder' => $slug];
}
function publiserDeriveSlugFromLiveFolder(string $section, string $folder, array $meta): string {
if (!empty($meta['slug']) && is_string($meta['slug'])) return publiserSlugify($meta['slug']);
if ($section === 'nyheter' && preg_match('/^\d{4}-\d{2}-\d{2}-(.+)$/', $folder, $m)) {
return $m[1];
}
return $folder;
}
function publiserTrash(string $location, string $section, string $folder): void {
publiserAssertSection($section);
if (!in_array($location, ['live', 'draft'], true)) {
throw new InvalidArgumentException("Cannot trash from $location");
}
$srcDir = $location === 'live' ? publiserLiveDir($section, $folder) : publiserDraftDir($section, $folder);
if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke innlegget');
$meta = publiserReadMetadata($srcDir);
$meta['_publiser_trashed_from'] = $location;
$meta['_publiser_trashed_at'] = date('c');
$trashFolder = $folder . '~' . time();
$destDir = publiserTrashDir($section, $trashFolder);
publiserEnsureParentDir($destDir);
publiserLockedRename($srcDir, $destDir);
publiserWriteMetadataFile($destDir, $meta);
}
function publiserRestore(string $section, string $trashFolder): array {
publiserAssertSection($section);
$srcDir = publiserTrashDir($section, $trashFolder);
if (!is_dir($srcDir)) throw new RuntimeException('Fant ikke i papirkurven');
$meta = publiserReadMetadata($srcDir);
unset($meta['_publiser_trashed_from'], $meta['_publiser_trashed_at']);
$meta['status'] = 'draft';
unset($meta['publish_at']);
$slug = preg_replace('/~\d+$/', '', $trashFolder) ?? $trashFolder;
$slug = publiserUniqueSlug($section, $slug);
$destDir = publiserDraftDir($section, $slug);
publiserEnsureParentDir($destDir);
publiserLockedRename($srcDir, $destDir);
publiserWriteMetadataFile($destDir, $meta);
return ['section' => $section, 'folder' => $slug];
}
// --- Scheduling --------------------------------------------------------
function publiserSetSchedule(string $section, string $folder, ?string $publishAt): void {
$dir = publiserDraftDir($section, $folder);
if (!is_dir($dir)) throw new RuntimeException('Fant ikke utkastet');
$meta = publiserReadMetadata($dir);
if ($publishAt) {
$meta['status'] = 'scheduled';
$meta['publish_at'] = $publishAt;
} else {
$meta['status'] = 'draft';
unset($meta['publish_at']);
}
publiserWriteMetadataFile($dir, $meta);
}
/** Called on every site request via the scheduled-publisher global plugin. */
function publiserPublishDueScheduledItems(): int {
$published = 0;
foreach (PUBLISER_SECTIONS as $section) {
$root = publiserDataRoot() . "/drafts/$section";
foreach ((glob("$root/*", GLOB_ONLYDIR) ?: []) as $dir) {
$meta = publiserReadMetadata($dir);
if (($meta['status'] ?? '') !== 'scheduled') continue;
$publishAt = $meta['publish_at'] ?? null;
if (!$publishAt) continue;
$ts = strtotime($publishAt);
if ($ts === false || $ts > time()) continue;
try {
publiserPublish($section, basename($dir), date('Y-m-d', $ts));
$published++;
} catch (Throwable $e) {
error_log('publiser scheduled publish failed: ' . $e->getMessage());
}
}
}
return $published;
}

View file

@ -0,0 +1,57 @@
#!/usr/bin/env php
<?php
/**
* Set or reset the Basic Auth login for /publiser.
*
* Writes a bcrypt-hashed .htpasswd file (no dependency on the `htpasswd`
* binary being installed - Apache's mod_authn_file accepts $2y$ hashes).
*
* Usage:
* php custom/tools/set-publiser-password.php <username> <password>
* php custom/tools/set-publiser-password.php (interactive)
*/
$htpasswdPath = dirname(__DIR__, 2) . '/content/publiser/.htpasswd';
function prompt(string $question): string {
echo $question;
return trim(fgets(STDIN));
}
function promptHidden(string $question): string {
$isWindows = stripos(PHP_OS, 'WIN') === 0;
if (!$isWindows) {
system('stty -echo');
}
$value = prompt($question);
if (!$isWindows) {
system('stty echo');
echo "\n";
}
return $value;
}
$args = array_slice($argv, 1);
$username = $args[0] ?? prompt('Username: ');
$password = $args[1] ?? promptHidden('Password: ');
if ($username === '' || $password === '') {
fwrite(STDERR, "Username and password are required.\n");
exit(1);
}
if (str_contains($username, ':')) {
fwrite(STDERR, "Username cannot contain ':'.\n");
exit(1);
}
$hash = password_hash($password, PASSWORD_BCRYPT);
file_put_contents($htpasswdPath, "$username:$hash\n");
// World-readable: the web server user (www-data, varies by host/container)
// needs read access. This is safe - Apache denies direct HTTP access to
// this exact filename (content/publiser/.htaccess), and reaching it any
// other way already requires shell/filesystem access to the server.
chmod($htpasswdPath, 0644);
echo "Wrote $htpasswdPath for user \"$username\".\n";