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

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ content/.user.ini
content/php.ini content/php.ini
custom/assets/petition-map-data.json custom/assets/petition-map-data.json
custom/data/petition-map-cache.json custom/data/petition-map-cache.json
custom/data/publiser/
content/publiser/.htpasswd

View file

@ -3,6 +3,13 @@ FROM php:8.4.14-apache
# Enable Apache modules and custom config as root during build # Enable Apache modules and custom config as root during build
RUN a2enmod rewrite headers RUN a2enmod rewrite headers
# GD extension - used by /publiser to resize/recompress uploaded images
RUN apt-get update \
&& apt-get install -y --no-install-recommends libjpeg62-turbo-dev libpng-dev libwebp-dev \
&& docker-php-ext-configure gd --with-jpeg --with-webp \
&& docker-php-ext-install gd \
&& rm -rf /var/lib/apt/lists/*
COPY apache.conf /etc/apache2/conf-available/custom.conf COPY apache.conf /etc/apache2/conf-available/custom.conf
RUN a2enconf custom RUN a2enconf custom

View file

@ -10,4 +10,4 @@ services:
ports: ports:
- "4040:80" - "4040:80"
command: > command: >
bash -c "chown -R www-data:www-data /var/www/custom/data /var/www/custom/assets && apache2-foreground" bash -c "chown -R www-data:www-data /var/www/custom/data /var/www/custom/assets /var/www/html/nyheter /var/www/html/artikler && apache2-foreground"

View file

@ -2,8 +2,8 @@ DirectorySlash Off
# Block direct access to content source files # Block direct access to content source files
<FilesMatch "\.(ini|md|html|php)$"> <FilesMatch "\.(ini|md|html|php)$">
# Allow only the entry point # Allow only the entry point, and the standalone /publiser admin app
<If "%{REQUEST_URI} != '/index.php'"> <If "%{REQUEST_URI} != '/index.php' && %{REQUEST_URI} !~ m#^/publiser(/|$)#">
Require all denied Require all denied
</If> </If>
</FilesMatch> </FilesMatch>
@ -30,6 +30,13 @@ DirectorySlash Off
RewriteCond %{REQUEST_URI} ^/app/ RewriteCond %{REQUEST_URI} ^/app/
RewriteRule ^(.*)$ /index.php [L,QSA] RewriteRule ^(.*)$ /index.php [L,QSA]
# Standalone /publiser admin app - bypass the CMS entirely, dispatch
# everything to its own entry point (static assets like .css/.js are
# excluded so Apache serves those files directly)
RewriteCond %{REQUEST_URI} ^/publiser
RewriteCond %{REQUEST_URI} !\.(css|js|png|jpe?g|webp|gif|svg)$
RewriteRule ^publiser(/.*)?$ publiser/index.php [L,QSA]
# Don't rewrite if file exists # Don't rewrite if file exists
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ /index.php [L,QSA] RewriteRule ^(.*)$ /index.php [L,QSA]

View file

@ -2,8 +2,8 @@ DirectorySlash Off
# Block direct access to content source files # Block direct access to content source files
<FilesMatch "\.(ini|md|html|php)$"> <FilesMatch "\.(ini|md|html|php)$">
# Allow only the entry point # Allow only the entry point, and the standalone /publiser admin app
<If "%{REQUEST_URI} != '/index.php'"> <If "%{REQUEST_URI} != '/index.php' && %{REQUEST_URI} !~ m#^/publiser(/|$)#">
Require all denied Require all denied
</If> </If>
</FilesMatch> </FilesMatch>
@ -30,6 +30,13 @@ DirectorySlash Off
RewriteCond %{REQUEST_URI} ^/app/ RewriteCond %{REQUEST_URI} ^/app/
RewriteRule ^(.*)$ /index.php [L,QSA] RewriteRule ^(.*)$ /index.php [L,QSA]
# Standalone /publiser admin app - bypass the CMS entirely, dispatch
# everything to its own entry point (static assets like .css/.js are
# excluded so Apache serves those files directly)
RewriteCond %{REQUEST_URI} ^/publiser
RewriteCond %{REQUEST_URI} !\.(css|js|png|jpe?g|webp|gif|svg)$
RewriteRule ^publiser(/.*)?$ publiser/index.php [L,QSA]
# Don't rewrite if file exists # Don't rewrite if file exists
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ /index.php [L,QSA] RewriteRule ^(.*)$ /index.php [L,QSA]

View file

@ -11,5 +11,3 @@ Uten førerkort blir hverdagen veldig vanskelig: matbutikk, legeavtaler og famil
Denne rettssaken er en unik mulighet til å utfordre regelverket og skape reell endring. Ditt bidrag går direkte til rettssaken en kamp for rettferdighet, medisinsk frihet og et verdig liv for alle pasienter. Bidra nå, og vær med på å endre regelverket! Denne rettssaken er en unik mulighet til å utfordre regelverket og skape reell endring. Ditt bidrag går direkte til rettssaken en kamp for rettferdighet, medisinsk frihet og et verdig liv for alle pasienter. Bidra nå, og vær med på å endre regelverket!
Gi ditt bidrag i dag hver krone teller! Les Glenn Dahl [sin historie på NRK.no](https://www.nrk.no/norge/nektes-a-kjore-bil-fordi-han-bruker-medisinsk-cannabis-1.17210314). Gi ditt bidrag i dag hver krone teller! Les Glenn Dahl [sin historie på NRK.no](https://www.nrk.no/norge/nektes-a-kjore-bil-fordi-han-bruker-medisinsk-cannabis-1.17210314).
<iframe style="padding-top:1rem" width='350' height='535' src='https://www.spleis.no/project/483880/embed' frameborder='0'></iframe>

View file

@ -0,0 +1 @@
<iframe style="padding-top:1rem" width='350' height='535' src='https://www.spleis.no/project/483880/embed' frameborder='0'></iframe>

View file

@ -0,0 +1,15 @@
DirectorySlash On
# Auth is checked in PHP (index.php), not via Apache AuthUserFile: relative
# AuthUserFile paths resolve against ServerRoot, not this directory, and
# that differs per environment (podman dev container vs cPanel). Some SAPIs
# (LSAPI/CGI/FastCGI, used on cPanel) strip the Authorization header from
# PHP by default, so forward it explicitly via an internal env var.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
</IfModule>
<Files ".htpasswd">
Require all denied
</Files>

726
content/publiser/index.php Normal file
View file

@ -0,0 +1,726 @@
<?php
/**
* /publiser - standalone publishing tool for content/nyheter and content/artikler.
*
* This file is served directly by Apache (see the passthrough rule in
* content/.htaccess.base) and is deliberately NOT routed through the CMS -
* no Context, no templates, no site chrome. It only reuses the pure,
* hook-free helpers from app/helpers.php via custom/plugins/publiser-lib.php.
*
* Access is protected by Basic Auth, checked here in PHP against a bcrypt
* .htpasswd (see custom/tools/set-publiser-password.php) rather than via
* Apache's AuthUserFile - that directive needs an absolute path that
* differs between the podman dev container and cPanel, which this avoids.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../custom/plugins/publiser-lib.php';
function publiserRequireAuth(): void {
$htpasswd = __DIR__ . '/.htpasswd';
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
// Some SAPIs (LSAPI/CGI/FastCGI) don't populate PHP_AUTH_*; fall back to
// parsing the raw header forwarded by .htaccess's RewriteRule.
if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Basic\s+(.+)$/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
$decoded = base64_decode($m[1], true) ?: '';
if (str_contains($decoded, ':')) {
[$user, $pass] = explode(':', $decoded, 2);
}
}
$valid = false;
if ($user !== null && $pass !== null && is_file($htpasswd)) {
foreach (file($htpasswd, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
[$lineUser, $hash] = array_pad(explode(':', $line, 2), 2, '');
if ($hash !== '' && hash_equals($lineUser, (string)$user) && password_verify((string)$pass, $hash)) {
$valid = true;
break;
}
}
}
if (!$valid) {
header('WWW-Authenticate: Basic realm="Stopp lidelsen - publisering"');
http_response_code(401);
header('Content-Type: text/plain; charset=utf-8');
echo "Palogging kreves.";
exit;
}
}
publiserRequireAuth();
session_start();
if (empty($_SESSION['publiser_csrf'])) {
$_SESSION['publiser_csrf'] = bin2hex(random_bytes(32));
}
$csrf = $_SESSION['publiser_csrf'];
// --- small helpers ---------------------------------------------------------
function h(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
}
function publiserBaseUrl(): string {
return '/publiser/';
}
function publiserListUrl(string $tab, string $section = 'nyheter'): string {
return publiserBaseUrl() . '?view=list&tab=' . urlencode($tab) . '&section=' . urlencode($section);
}
function publiserEditUrl(string $location, string $section, string $folder, array $flags = []): string {
$url = publiserBaseUrl() . '?view=edit&location=' . urlencode($location)
. '&section=' . urlencode($section) . '&folder=' . urlencode($folder);
foreach ($flags as $k => $v) {
$url .= '&' . urlencode($k) . '=' . urlencode((string)$v);
}
return $url;
}
function publiserPublicUrl(string $section, string $folder): string {
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/';
}
function publiserImageUrl(string $location, string $section, string $folder, string $filename): string {
if ($location === 'live') {
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/' . rawurlencode($filename);
}
return publiserBaseUrl() . '?action=image&location=' . urlencode($location)
. '&section=' . urlencode($section) . '&folder=' . urlencode($folder) . '&file=' . urlencode($filename);
}
function publiserCheckCsrf(): void {
global $csrf;
$token = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($csrf, $token)) {
http_response_code(403);
die('Ugyldig forespørsel (utløpt økt). Last siden på nytt og prøv igjen.');
}
}
function publiserRedirect(string $url): never {
header("Location: $url");
exit;
}
function publiserItemDir(string $location, string $section, string $folder): string {
return match ($location) {
'live' => publiserLiveDir($section, $folder),
'draft' => publiserDraftDir($section, $folder),
'trash' => publiserTrashDir($section, $folder),
default => throw new RuntimeException('Ugyldig plassering'),
};
}
// --- minimal Markdown -> HTML for populating the WYSIWYG editor on load ----
// (mirrors the bounded tag set publiser.js can serialize back to Markdown)
function publiserEscapeInline(string $text): string {
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
function publiserInlineMdToHtml(string $text, callable $imageUrlResolver): string {
$text = publiserEscapeInline($text);
$text = preg_replace_callback('/!\[(.*?)\]\((.*?)\)/', function ($m) use ($imageUrlResolver) {
$alt = $m[1];
$file = $m[2];
$realFile = html_entity_decode($file, ENT_QUOTES, 'UTF-8');
$url = htmlspecialchars($imageUrlResolver($realFile), ENT_QUOTES, 'UTF-8');
return '<img alt="' . $alt . '" src="' . $url . '" data-filename="' . $file . '">';
}, $text) ?? $text;
$text = preg_replace_callback('/\[(.*?)\]\((.*?)\)/', function ($m) {
return '<a href="' . $m[2] . '">' . $m[1] . '</a>';
}, $text) ?? $text;
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text) ?? $text;
$text = preg_replace('/(?<!\*)\*([^*]+?)\*(?!\*)/', '<em>$1</em>', $text) ?? $text;
return $text;
}
function publiserBlockMdToHtml(string $md, callable $imageUrlResolver): string {
$md = str_replace("\r\n", "\n", trim($md));
if ($md === '') return '<p><br></p>';
$blocks = preg_split('/\n{2,}/', $md) ?: [];
$html = [];
foreach ($blocks as $block) {
$block = trim($block);
if ($block === '') continue;
$lines = explode("\n", $block);
if (preg_match('/^<[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?>/', $lines[0])) {
// Raw HTML embedded in markdown (e.g. an <iframe> embed) - render
// it live but non-editable, and store the exact original source
// in data-raw-html so the JS serializer can pass it through
// byte-for-byte on save instead of trying to reconstruct it from
// the rendered DOM.
$html[] = '<div class="p-raw-html" contenteditable="false" data-raw-html="' . htmlspecialchars($block, ENT_QUOTES, 'UTF-8') . '">' . $block . '</div>';
continue;
}
if (preg_match('/^### (.*)$/', $lines[0], $m)) {
$html[] = '<h3>' . publiserInlineMdToHtml($m[1], $imageUrlResolver) . '</h3>';
continue;
}
if (preg_match('/^## (.*)$/', $lines[0], $m)) {
$html[] = '<h2>' . publiserInlineMdToHtml($m[1], $imageUrlResolver) . '</h2>';
continue;
}
if (count($lines) === 1 && preg_match('/^!\[(.*?)\]\((.*?)\)$/', $lines[0])) {
$html[] = '<p>' . publiserInlineMdToHtml($lines[0], $imageUrlResolver) . '</p>';
continue;
}
if (str_starts_with($lines[0], '> ')) {
$inner = implode(' ', array_map(fn($l) => preg_replace('/^>\s?/', '', $l), $lines));
$html[] = '<blockquote><p>' . publiserInlineMdToHtml($inner, $imageUrlResolver) . '</p></blockquote>';
continue;
}
if (preg_match('/^[-*] /', $lines[0])) {
$items = array_map(fn($l) => publiserInlineMdToHtml(preg_replace('/^[-*] /', '', $l), $imageUrlResolver), $lines);
$html[] = '<ul>' . implode('', array_map(fn($i) => "<li>$i</li>", $items)) . '</ul>';
continue;
}
if (preg_match('/^\d+\. /', $lines[0])) {
$items = array_map(fn($l) => publiserInlineMdToHtml(preg_replace('/^\d+\. /', '', $l), $imageUrlResolver), $lines);
$html[] = '<ol>' . implode('', array_map(fn($i) => "<li>$i</li>", $items)) . '</ol>';
continue;
}
$html[] = '<p>' . publiserInlineMdToHtml(implode(' ', $lines), $imageUrlResolver) . '</p>';
}
return implode("\n", $html) ?: '<p><br></p>';
}
/**
* The page's visible <h1> comes from a leading "# Title" line in the first
* content file (extractTitle() in app/helpers.php relies on the same
* convention) - there's no separate metadata-driven heading in page.php.
* The editor's "Tittel" field owns that line: stripped here for display/
* editing, re-prepended in publiserApplyFormSave() on save.
*/
function publiserStripLeadingH1(string $md): string {
$trimmed = ltrim($md, "\n");
if (preg_match('/^#[ \t]+.*(?:\n+|$)/', $trimmed, $m)) {
return (string)substr($trimmed, strlen($m[0]));
}
return $md;
}
// --- form save (metadata + any blocks the user actually touched) ----------
function publiserApplyFormSave(string $dir, array $post): void {
$title = trim((string)($post['title'] ?? ''));
$fields = [
'title' => $title,
'summary' => trim((string)($post['summary'] ?? '')),
'tags' => trim((string)($post['tags'] ?? '')),
'categories' => trim((string)($post['categories'] ?? '')),
];
if (!empty($post['date'])) {
$fields['date'] = $post['date'];
}
publiserSaveMetadata($dir, $fields);
// The first richtext block carries the page's visible <h1> (see
// publiserStripLeadingH1()) - it's rewritten every save to stay in sync
// with the title field, even if its body wasn't otherwise touched.
$titleBlockFilename = null;
foreach (publiserListBlocks($dir) as $b) {
if ($b['type'] === 'richtext') {
$titleBlockFilename = $b['filename'];
break;
}
}
$contents = $post['block_content'] ?? [];
$dirty = $post['block_dirty'] ?? [];
if (is_array($contents)) {
foreach ($contents as $filename => $content) {
$filename = basename((string)$filename);
if (!is_file("$dir/$filename")) continue;
$content = (string)$content;
if ($filename === $titleBlockFilename) {
if ($title !== '') {
publiserSaveBlockContent($dir, $filename, "# {$title}\n\n" . ltrim($content, "\n"));
} elseif (!empty($dirty[$filename])) {
publiserSaveBlockContent($dir, $filename, $content);
}
continue;
}
if (empty($dirty[$filename])) continue; // untouched blocks are never rewritten
publiserSaveBlockContent($dir, $filename, $content);
}
}
}
// --- image upload / serve endpoints ----------------------------------------
if (($_GET['action'] ?? '') === 'image') {
$location = (string)($_GET['location'] ?? 'draft');
$section = (string)($_GET['section'] ?? '');
$folder = (string)($_GET['folder'] ?? '');
$file = basename((string)($_GET['file'] ?? ''));
try {
$dir = publiserItemDir($location, $section, $folder);
$path = "$dir/$file";
if (!is_file($path)) {
http_response_code(404);
exit;
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'][$ext] ?? 'application/octet-stream';
header("Content-Type: $mime");
header('Cache-Control: private, max-age=60');
readfile($path);
} catch (Throwable $e) {
http_response_code(404);
}
exit;
}
if (($_GET['action'] ?? '') === 'upload-image' && $_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
try {
publiserCheckCsrf();
$location = (string)($_POST['location'] ?? 'draft');
$section = (string)($_POST['section'] ?? '');
$folder = (string)($_POST['folder'] ?? '');
$target = (string)($_POST['target'] ?? 'inline');
$dir = publiserItemDir($location, $section, $folder);
if (!is_dir($dir)) throw new RuntimeException('Fant ikke innlegget');
if (empty($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Ingen fil mottatt');
}
if ($_FILES['image']['size'] > 15 * 1024 * 1024) {
throw new RuntimeException('Bildet er for stort (maks 15 MB)');
}
$filename = $target === 'cover'
? publiserSaveCoverImage($dir, $_FILES['image']['tmp_name'], $_FILES['image']['name'])
: publiserSaveUploadedImage($dir, $_FILES['image']['tmp_name'], $_FILES['image']['name']);
echo json_encode([
'ok' => true,
'filename' => $filename,
'url' => publiserImageUrl($location, $section, $folder, $filename),
]);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// --- POST actions (create / save / publish / unpublish / trash / restore) -
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
publiserCheckCsrf();
try {
if (isset($_POST['create'])) {
$title = trim((string)($_POST['title'] ?? ''));
$newSection = (string)($_POST['new_section'] ?? 'nyheter');
$created = publiserCreateDraft($newSection, $title);
publiserRedirect(publiserEditUrl('draft', $created['section'], $created['folder']));
}
if (isset($_POST['restore'])) {
$result = publiserRestore((string)$_POST['section'], (string)$_POST['folder']);
publiserRedirect(publiserEditUrl('draft', $result['section'], $result['folder'], ['restored' => 1]));
}
$postSection = (string)($_POST['section'] ?? '');
$postFolder = (string)($_POST['folder'] ?? '');
$postLocation = (string)($_POST['location'] ?? 'draft');
$dir = publiserItemDir($postLocation, $postSection, $postFolder);
if (!is_dir($dir)) throw new RuntimeException('Fant ikke innlegget');
if (isset($_POST['delete_item'])) {
publiserTrash($postLocation, $postSection, $postFolder);
publiserRedirect(publiserListUrl('drafts', $postSection));
}
// every other action first persists whatever's currently in the form
publiserApplyFormSave($dir, $_POST);
if (isset($_POST['add_block'])) {
publiserAddBlock($dir, (string)$_POST['add_block']);
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
}
if (isset($_POST['reorder_up'])) {
publiserReorderBlock($dir, (string)$_POST['reorder_up'], 'up');
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
}
if (isset($_POST['reorder_down'])) {
publiserReorderBlock($dir, (string)$_POST['reorder_down'], 'down');
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
}
if (isset($_POST['delete_block'])) {
publiserDeleteBlock($dir, (string)$_POST['delete_block']);
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
}
if (isset($_POST['unschedule'])) {
publiserSetSchedule($postSection, $postFolder, null);
publiserRedirect(publiserEditUrl('draft', $postSection, $postFolder));
}
if (isset($_POST['unpublish'])) {
$result = publiserUnpublish($postSection, $postFolder);
publiserRedirect(publiserEditUrl('draft', $result['section'], $result['folder'], ['unpublished' => 1]));
}
if (isset($_POST['publish'])) {
$scheduleAt = trim((string)($_POST['schedule_at'] ?? ''));
if ($scheduleAt !== '' && strtotime($scheduleAt) > time()) {
publiserSetSchedule($postSection, $postFolder, $scheduleAt);
publiserRedirect(publiserEditUrl('draft', $postSection, $postFolder, ['scheduled' => 1]));
}
$result = publiserPublish($postSection, $postFolder);
publiserRedirect(publiserEditUrl('live', $result['section'], $result['folder'], ['published' => 1]));
}
// plain save
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder, ['saved' => 1]));
} catch (Throwable $e) {
http_response_code(400);
publiserShell('Feil', '<div class="p-notice p-notice-error"><p>' . h($e->getMessage()) . '</p><p><a href="' . h(publiserListUrl('published')) . '">Tilbake</a></p></div>');
exit;
}
}
// --- page shell --------------------------------------------------------
function publiserShell(string $title, string $body): void {
$view = $_GET['view'] ?? 'list';
$currentTab = $view === 'list' ? ($_GET['tab'] ?? 'published') : null;
$navItems = [
'published' => 'Publisert',
'scheduled' => 'Planlagt',
'drafts' => 'Utkast',
'trash' => 'Papirkurv',
];
$cssHash = file_exists(__DIR__ . '/publiser.css') ? hash_file('md5', __DIR__ . '/publiser.css') : '0';
$jsHash = file_exists(__DIR__ . '/publiser.js') ? hash_file('md5', __DIR__ . '/publiser.js') : '0';
?><!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($title) ?> - Publisering</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/app/styles/base.css">
<link rel="stylesheet" href="/publiser/publiser.css?v=<?= h($cssHash) ?>">
</head>
<body class="p-app">
<header class="p-topbar">
<nav class="p-topbar-inner">
<ul>
<?php foreach ($navItems as $key => $label): ?>
<li><a class="button <?= $currentTab === $key ? 'active' : '' ?>" href="<?= h(publiserListUrl($key)) ?>"><?= h($label) ?></a></li>
<?php endforeach; ?>
<li><a class="button" href="<?= h(publiserBaseUrl()) ?>?view=new">+ Nytt innlegg</a></li>
</ul>
</nav>
</header>
<main class="p-main">
<?= $body ?>
</main>
<script src="/publiser/publiser.js?v=<?= h($jsHash) ?>"></script>
</body>
</html>
<?php
}
// --- views ---------------------------------------------------------------
function publiserRenderList(string $csrf): void {
$tab = (string)($_GET['tab'] ?? 'published');
$section = (string)($_GET['section'] ?? 'nyheter');
if (!in_array($section, PUBLISER_SECTIONS, true)) $section = 'nyheter';
$items = match ($tab) {
'scheduled' => array_values(array_filter(publiserListStaged('drafts', $section), fn($i) => $i['status'] === 'scheduled')),
'drafts' => array_values(array_filter(publiserListStaged('drafts', $section), fn($i) => $i['status'] !== 'scheduled')),
'trash' => publiserListStaged('trash', $section),
default => publiserListLive($section),
};
ob_start(); ?>
<div class="p-header-row">
<h1>Innhold</h1>
<div class="p-section-switch">
<a class="<?= $section === 'nyheter' ? 'active' : '' ?>" href="<?= h(publiserListUrl($tab, 'nyheter')) ?>">Nyheter</a>
<a class="<?= $section === 'artikler' ? 'active' : '' ?>" href="<?= h(publiserListUrl($tab, 'artikler')) ?>">Artikler</a>
</div>
</div>
<?php if (isset($_GET['deleted'])): ?><div class="p-notice">Flyttet til papirkurven.</div><?php endif; ?>
<?php if (empty($items)): ?>
<p class="p-empty">Ingen innlegg her ennå.</p>
<?php else: ?>
<ul class="p-list">
<?php foreach ($items as $item): ?>
<li class="p-list-item">
<?php if ($item['cover']): ?>
<img class="p-list-cover" src="<?= h(publiserImageUrl($item['location'], $item['section'], $item['folder'], $item['cover'])) ?>" alt="">
<?php else: ?>
<div class="p-list-cover p-list-cover-empty"></div>
<?php endif; ?>
<div class="p-list-info">
<div class="p-list-title"><?= h($item['title']) ?></div>
<div class="p-list-meta">
<?= h($item['date']) ?>
<?php if ($tab === 'scheduled' && $item['publishAt']): ?>
&middot; publiseres <?= h($item['publishAt']) ?>
<?php endif; ?>
</div>
</div>
<div class="p-list-actions">
<?php if ($tab === 'trash'): ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<input type="hidden" name="section" value="<?= h($item['section']) ?>">
<input type="hidden" name="folder" value="<?= h($item['folder']) ?>">
<button type="submit" name="restore" value="1" class="button">Gjenopprett</button>
</form>
<?php else: ?>
<a class="button" href="<?= h(publiserEditUrl($item['location'], $item['section'], $item['folder'])) ?>">Rediger</a>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php
publiserShell('Innhold', ob_get_clean());
}
function publiserRenderNew(string $csrf): void {
ob_start(); ?>
<h1>Nytt innlegg</h1>
<form method="post" class="p-form p-form-new">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<label>Type
<select name="new_section">
<option value="nyheter">Nyhet</option>
<option value="artikler">Artikkel</option>
</select>
</label>
<label>Tittel
<input type="text" name="title" required autofocus placeholder="Skriv en tittel...">
</label>
<button type="submit" name="create" value="1" class="button active">Opprett</button>
</form>
<?php
publiserShell('Nytt innlegg', ob_get_clean());
}
function publiserRenderEditor(string $csrf): void {
$location = (string)($_GET['location'] ?? 'draft');
$section = (string)($_GET['section'] ?? '');
$folder = (string)($_GET['folder'] ?? '');
$item = publiserLoadItem($location, $section, $folder);
if (!$item) {
publiserShell('Ikke funnet', '<p>Fant ikke innlegget.</p><p><a href="' . h(publiserListUrl('published')) . '">Tilbake</a></p>');
return;
}
$meta = $item['metadata'];
if (empty($meta['title'])) {
// Legacy items often have no explicit title field - the site
// derives it from the first content file's H1 (publiserExtractTitle()).
$meta['title'] = publiserExtractTitle($item['dir']) ?? $folder;
}
$resolver = fn(string $file) => publiserImageUrl($location, $section, $folder, $file);
if ($location === 'live') {
$backTab = 'published';
} elseif (($meta['status'] ?? '') === 'scheduled') {
$backTab = 'scheduled';
} else {
$backTab = 'drafts';
}
ob_start(); ?>
<div class="p-editor-topline">
<a class="p-back" href="<?= h(publiserListUrl($backTab, $section)) ?>">&larr; Tilbake til listen</a>
<?php if ($location === 'live'): ?>
<a class="p-view-live" href="<?= h(publiserPublicUrl($section, $folder)) ?>" target="_blank" rel="noopener">Se siden &#8599;</a>
<?php endif; ?>
</div>
<?php if (isset($_GET['saved'])): ?><div class="p-notice">Lagret.</div><?php endif; ?>
<?php if (isset($_GET['published'])): ?><div class="p-notice">Publisert.</div><?php endif; ?>
<?php if (isset($_GET['unpublished'])): ?><div class="p-notice">Avpublisert - ligger nå i utkast.</div><?php endif; ?>
<?php if (isset($_GET['scheduled'])): ?><div class="p-notice">Planlagt publisering satt.</div><?php endif; ?>
<?php if (isset($_GET['restored'])): ?><div class="p-notice">Gjenopprettet til utkast.</div><?php endif; ?>
<?php if ($location === 'trash'): ?>
<h1><?= h($meta['title'] ?? $folder) ?></h1>
<p class="p-muted">Ligger i papirkurven.</p>
<form method="post">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<input type="hidden" name="section" value="<?= h($section) ?>">
<input type="hidden" name="folder" value="<?= h($folder) ?>">
<button type="submit" name="restore" value="1" class="button active">Gjenopprett til utkast</button>
</form>
<?php publiserShell(h($meta['title'] ?? $folder), ob_get_clean()); return; ?>
<?php endif; ?>
<form method="post" id="editor-form" class="p-editor p-form" data-location="<?= h($location) ?>" data-section="<?= h($section) ?>" data-folder="<?= h($folder) ?>" data-upload-url="<?= h(publiserBaseUrl() . '?action=upload-image') ?>" data-csrf="<?= h($csrf) ?>">
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
<input type="hidden" name="location" value="<?= h($location) ?>">
<input type="hidden" name="section" value="<?= h($section) ?>">
<input type="hidden" name="folder" value="<?= h($folder) ?>">
<div class="p-editor-grid">
<div class="p-editor-main">
<input type="text" name="title" class="p-title-input" value="<?= h($meta['title'] ?? '') ?>" placeholder="Tittel" required>
<div class="p-blocks">
<?php $titleBlockSeen = false; ?>
<?php foreach ($item['blocks'] as $block): ?>
<?php
$raw = file_get_contents($item['dir'] . '/' . $block['filename']) ?: '';
$isTitleBlock = !$titleBlockSeen && $block['type'] === 'richtext';
if ($isTitleBlock) {
$titleBlockSeen = true;
$raw = publiserStripLeadingH1($raw);
}
?>
<div class="p-block" data-filename="<?= h($block['filename']) ?>" data-type="<?= h($block['type']) ?>">
<div class="p-block-toolbar">
<?php if ($block['type'] === 'richtext'): ?>
<button type="button" data-cmd="bold" title="Fet"><strong>B</strong></button>
<button type="button" data-cmd="italic" title="Kursiv"><em>I</em></button>
<button type="button" data-cmd="h2" title="Overskrift">H2</button>
<button type="button" data-cmd="h3" title="Underoverskrift">H3</button>
<button type="button" data-cmd="ul" title="Punktliste">&bull; Liste</button>
<button type="button" data-cmd="ol" title="Nummerert liste">1. Liste</button>
<button type="button" data-cmd="quote" title="Sitat">&rdquo; Sitat</button>
<button type="button" data-cmd="link" title="Lenke">&#128279; Lenke</button>
<button type="button" data-cmd="image" title="Sett inn bilde">&#128247; Bilde</button>
<?php else: ?>
<span class="p-block-label">Egendefinert kode (<?= h(strtoupper($block['ext'])) ?>) - vises ikke visuelt her</span>
<?php endif; ?>
<span class="p-block-spacer"></span>
<button type="submit" name="reorder_up" value="<?= h($block['filename']) ?>" title="Flytt opp">&uarr;</button>
<button type="submit" name="reorder_down" value="<?= h($block['filename']) ?>" title="Flytt ned">&darr;</button>
<button type="submit" name="delete_block" value="<?= h($block['filename']) ?>" class="danger" data-confirm="Slette denne blokken?" title="Slett blokk">&times;</button>
</div>
<?php if ($block['type'] === 'richtext'): ?>
<div class="p-richtext" contenteditable="true"><?= publiserBlockMdToHtml($raw, $resolver) ?></div>
<textarea class="p-block-hidden" name="block_content[<?= h($block['filename']) ?>]" hidden><?= h($raw) ?></textarea>
<input type="hidden" name="block_dirty[<?= h($block['filename']) ?>]" value="0" class="p-dirty-flag">
<?php else: ?>
<textarea class="p-code" rows="8" name="block_content[<?= h($block['filename']) ?>]"><?= h($raw) ?></textarea>
<input type="hidden" name="block_dirty[<?= h($block['filename']) ?>]" value="1" class="p-dirty-flag">
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="p-add-block">
<button type="submit" name="add_block" value="md" class="button">+ Tekst</button>
<button type="submit" name="add_block" value="html" class="button">+ Egendefinert HTML (avansert)</button>
<button type="submit" name="add_block" value="php" class="button">+ Egendefinert PHP (avansert)</button>
</div>
<?php if (!empty($item['extra'])): ?>
<div class="p-extra-files">
<p class="p-muted">Andre filer i denne mappen (rediger disse direkte i kildekoden):</p>
<ul>
<?php foreach ($item['extra'] as $extra): ?>
<?php
$icon = match ($extra['type']) {
'folder' => '📁',
'translation' => '🌐',
default => '📄',
};
$note = $extra['type'] === 'translation' ? ' (engelsk oversettelse)' : '';
?>
<li><?= h($icon) ?> <?= h($extra['name']) ?><?= h($note) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="p-editor-sidebar">
<div class="p-side-box">
<p class="p-side-label">Metadata</p>
<label>Sammendrag
<textarea name="summary" rows="3" placeholder="Vises i lister og deling"><?= h($meta['summary'] ?? '') ?></textarea>
</label>
<label>Stikkord (kommaseparert)
<input type="text" name="tags" value="<?= h($meta['tags'] ?? '') ?>">
</label>
<label>Kategori
<input type="text" name="categories" value="<?= h($meta['categories'] ?? '') ?>">
</label>
<?php if ($section === 'nyheter'): ?>
<label>Dato
<input type="date" name="date" value="<?= h($meta['date'] ?? date('Y-m-d')) ?>">
</label>
<?php endif; ?>
</div>
<div class="p-side-box">
<p class="p-side-label">Forsidebilde</p>
<div class="p-cover-drop" data-target="cover">
<?php if ($item['cover']): ?>
<img src="<?= h(publiserImageUrl($location, $section, $folder, $item['cover'])) ?>" alt="">
<?php else: ?>
<span>Slipp bilde her eller klikk for å velge</span>
<?php endif; ?>
<input type="file" accept="image/*" hidden>
</div>
</div>
<div class="p-side-box p-actions-box">
<button type="submit" name="save" value="1" class="button active">Lagre</button>
<?php if ($location === 'draft'): ?>
<?php if (($meta['status'] ?? '') === 'scheduled'): ?>
<p class="p-muted">Planlagt: <?= h($meta['publish_at'] ?? '') ?></p>
<button type="submit" name="unschedule" value="1" class="button">Avbryt planlegging</button>
<?php endif; ?>
<label>Planlegg publisering (valgfritt)
<input type="datetime-local" name="schedule_at" value="<?= h($meta['publish_at'] ?? '') ?>">
</label>
<button type="submit" name="publish" value="1" class="button active">Publiser</button>
<?php else: ?>
<button type="submit" name="unpublish" value="1" class="button">Avpubliser</button>
<?php endif; ?>
<button type="submit" name="delete_item" value="1" class="button danger" data-confirm="Flytte dette innlegget til papirkurven?">Slett</button>
</div>
</div>
</div>
</form>
<?php
publiserShell(h($meta['title'] ?? $folder), ob_get_clean());
}
// --- dispatch --------------------------------------------------------------
$view = (string)($_GET['view'] ?? 'list');
match ($view) {
'new' => publiserRenderNew($csrf),
'edit' => publiserRenderEditor($csrf),
default => publiserRenderList($csrf),
};

View file

@ -0,0 +1,376 @@
/* /publiser admin app - reuses the site's CSS variables (base.css) for
brand consistency, but is otherwise a self-contained layout. Follows the
site convention of margin-top-only spacing (global reset zeroes
margin-bottom). */
/* Reuses the site's own .button class for nav/action links (base.css), so
they inherit real site styling instead of parallel custom classes. The
header bar itself has no site equivalent (the public site's header is a
narrower, logo-anchored layout) - .p-topbar-inner matches .p-main's width
instead, left-aligned. */
.p-app {
font-family: var(--font-body);
color: var(--color-grey);
background: var(--color-green-light);
min-height: 100vh;
}
.p-topbar {
background: #fff;
border-bottom: 3px #00000022 solid;
}
.p-topbar-inner {
max-width: 64rem;
margin: 0 auto;
padding: .8rem 1rem;
box-sizing: border-box;
}
.p-topbar-inner ul {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: .6rem;
margin: 0;
padding: 0;
}
.p-main {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
h1 {
font-family: var(--font-heading);
color: var(--color-green);
font-size: 1.8rem;
font-weight: 400;
}
.p-muted { color: #777; font-size: .9rem }
.p-editor-topline {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.p-back, .p-view-live {
display: inline-block;
margin-top: 0;
font-size: .9rem;
}
.p-notice {
margin-top: .8rem;
padding: .6rem .9rem;
background: var(--color-green-light);
border: 1px solid var(--color-green);
border-radius: .4rem;
color: var(--color-grey);
}
.p-notice-error {
background: #fdecea;
border-color: #c0392b;
}
/* --- list view --- */
.p-header-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 1rem;
justify-content: space-between;
}
.p-section-switch a {
padding: .3rem .7rem;
border-radius: .4rem;
font-size: .9rem;
}
.p-section-switch a.active {
background: var(--color-green);
color: #fff !important;
}
.p-empty { margin-top: 2rem; color: #777 }
.p-list {
list-style: none;
margin-top: 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: .6rem;
}
.p-list-item {
display: flex;
align-items: center;
gap: 1rem;
background: #fff;
border-radius: .5rem;
padding: .6rem .9rem;
border: 1px solid #00000012;
}
.p-list-cover {
width: 4rem;
height: 4rem;
object-fit: cover;
border-radius: .4rem;
flex: none;
}
.p-list-cover-empty { background: #eee }
.p-list-info { flex: 1; min-width: 0 }
.p-list-title {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.p-list-meta { font-size: .85rem; color: #777; margin-top: .1rem }
.p-list-actions form { margin: 0 }
/* --- buttons & forms --- */
/* base.css's .button has no destructive/danger variant - the one addition
needed on top of the site's real button styling. */
.button.danger { outline-color: #c0392b; color: #c0392b }
.button.danger:hover, .button.danger:focus { background-color: #c0392b; color: #fff; outline: none }
.p-form label {
display: block;
margin-top: 1rem;
font-size: .82rem;
font-weight: 600;
color: #555;
}
.p-side-box .p-form label:first-of-type,
.p-side-box label:first-of-type { margin-top: .6rem }
.p-form input[type=text],
.p-form input[type=date],
.p-form input[type=datetime-local],
.p-form textarea,
.p-form select {
display: block;
width: 100%;
margin-top: .35rem;
padding: .5rem .6rem;
border: 1px solid #00000022;
border-radius: .4rem;
font-family: var(--font-body);
font-size: .95rem;
color: var(--color-grey);
background: #fbfbfa;
box-sizing: border-box;
transition: border-color .15s, background .15s;
}
.p-form input[type=text]:focus,
.p-form input[type=date]:focus,
.p-form input[type=datetime-local]:focus,
.p-form textarea:focus,
.p-form select:focus {
outline: none;
border-color: var(--color-green);
background: #fff;
}
.p-form textarea { resize: vertical }
/* .p-form textarea's `display: block` above outranks the browser's native
[hidden] { display: none }, which un-hides this fallback field (it holds
the untouched block's raw content and must never actually show). Force it
back off with higher specificity. */
.p-form textarea.p-block-hidden { display: none }
.p-form-new { max-width: 28rem }
.p-form-new button { margin-top: 1.5rem }
/* --- editor --- */
.p-editor-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 18rem;
gap: 1.5rem;
margin-top: 1rem;
align-items: start;
}
@media (max-width: 55rem) {
.p-editor-grid { grid-template-columns: 1fr }
}
.p-title-input {
width: 100%;
font-family: var(--font-heading);
font-size: 1.8rem;
border: none;
border-bottom: 2px solid #00000015;
padding: .3rem 0;
background: transparent;
box-sizing: border-box;
}
.p-title-input:focus { outline: none; border-bottom-color: var(--color-green) }
.p-blocks {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
.p-block {
background: #fff;
border: 1px solid #00000015;
border-radius: .5rem;
overflow: hidden;
}
.p-block-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .2rem;
padding: .4rem;
background: #fafaf8;
border-bottom: 1px solid #00000012;
}
.p-block-toolbar button {
border: 1px solid transparent;
background: transparent;
padding: .3rem .55rem;
border-radius: .3rem;
cursor: pointer;
font-size: .9rem;
color: var(--color-grey);
font-family: var(--font-body);
}
.p-block-toolbar button:hover { background: #eee }
.p-block-toolbar button.danger { color: #c0392b }
.p-block-toolbar button.danger:hover { background: #fdecea }
.p-block-spacer { flex: 1 }
.p-block-label { font-size: .85rem; color: #777; padding: 0 .4rem }
.p-richtext {
min-height: 6rem;
padding: 1rem;
line-height: 1.5;
}
.p-richtext:focus { outline: none; background: #fffef8 }
.p-richtext h2, .p-richtext h3 { margin-top: .6em; color: var(--color-green) }
.p-richtext p { margin-top: .8em }
.p-richtext p:first-child { margin-top: 0 }
.p-richtext img { max-width: 100%; border-radius: .3rem; margin-top: .5em }
.p-richtext blockquote {
margin-top: .8em;
padding-left: 1rem;
border-left: 3px solid var(--color-green);
color: #555;
}
.p-richtext ul, .p-richtext ol { margin-top: .5em; padding-left: 1.4rem }
.p-raw-html {
margin-top: .8em;
padding: .6rem;
border: 1px dashed #00000030;
border-radius: .4rem;
background: #fafaf8;
cursor: default;
position: relative;
}
.p-raw-html::before {
content: "Innebygd kode - kan flyttes/slettes, men ikke redigeres her";
display: block;
margin-bottom: .5em;
font-size: .75rem;
color: #888;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.p-code {
width: 100%;
box-sizing: border-box;
border: none;
padding: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .85rem;
resize: vertical;
}
.p-code:focus { outline: none; background: #fffef8 }
.p-add-block {
display: flex;
flex-wrap: wrap;
gap: .6rem;
margin-top: 1rem;
}
.p-extra-files {
margin-top: 1.5rem;
padding: .8rem;
background: #fafaf8;
border-radius: .4rem;
font-size: .9rem;
}
.p-extra-files ul { margin-top: .3rem; padding-left: 1.2rem }
.p-editor-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.p-side-box {
background: #fff;
border: 1px solid #00000015;
border-radius: .6rem;
padding: 1.1rem;
box-shadow: 0 1px 2px #00000008;
}
.p-side-label {
font-family: var(--font-heading);
font-size: 1rem;
font-weight: 400;
color: var(--color-green);
padding-bottom: .5rem;
border-bottom: 1px solid #00000012;
}
.p-actions-box { display: flex; flex-direction: column; gap: .6rem }
.p-actions-box .button { width: 100%; text-align: center; box-sizing: border-box }
.p-actions-box label { margin-top: .8rem }
.p-actions-box .p-muted { margin-top: 0 }
.p-cover-drop {
margin-top: .5rem;
border: 2px dashed #00000030;
border-radius: .4rem;
min-height: 6rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: .85rem;
color: #777;
cursor: pointer;
padding: .5rem;
overflow: hidden;
}
.p-cover-drop.dragover { border-color: var(--color-green); background: var(--color-green-light) }
.p-cover-drop img { width: 100%; height: 8rem; object-fit: cover; border-radius: .3rem }
@media (max-width: 30rem) {
.p-main { padding: 1rem .7rem 4rem }
.p-list-item { flex-wrap: wrap }
}

View file

@ -0,0 +1,382 @@
/* /publiser block editor: rich-text toolbar for .md blocks, plain code
* editing for .html/.php blocks, image upload (cover + inline), and a
* small Markdown serializer for the bounded tag set the toolbar produces.
* No external dependencies - contenteditable + document.execCommand for
* the handful of inline/list commands (still functional everywhere,
* despite being spec-deprecated), manual DOM ops for block-level toggles.
*/
(function () {
'use strict';
document.execCommand('defaultParagraphSeparator', false, 'p');
// --- Markdown serialization (HTML -> MD, mirrors index.php's MD -> HTML) --
function serializeChildren(node) {
return Array.from(node.childNodes).map(serializeNode).join('');
}
function serializeNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
// Not escaping markdown-significant characters (*, [, ] ...) in plain
// text: round-tripping literal asterisks/brackets through the parser
// is a rare, low-severity edge case not worth the added complexity.
return node.textContent;
}
if (node.nodeType !== Node.ELEMENT_NODE) return '';
if (node.hasAttribute('data-raw-html')) {
// Non-editable raw-HTML island (see index.php's publiserBlockMdToHtml)
// - pass the original source through unchanged rather than trying to
// reconstruct it from the rendered DOM.
return `\n\n${node.getAttribute('data-raw-html')}\n\n`;
}
const tag = node.tagName.toLowerCase();
switch (tag) {
case 'strong':
case 'b': {
const inner = serializeChildren(node).trim();
return inner ? `**${inner}**` : '';
}
case 'em':
case 'i': {
const inner = serializeChildren(node).trim();
return inner ? `*${inner}*` : '';
}
case 'a': {
const href = node.getAttribute('href') || '';
const inner = serializeChildren(node).trim();
return href ? `[${inner}](${href})` : inner;
}
case 'img': {
const file = node.getAttribute('data-filename') || node.getAttribute('src') || '';
const alt = node.getAttribute('alt') || '';
return `![${alt}](${file})`;
}
case 'br':
return ' ';
case 'h2':
return `\n\n## ${serializeChildren(node).trim()}\n\n`;
case 'h3':
return `\n\n### ${serializeChildren(node).trim()}\n\n`;
case 'blockquote': {
const text = serializeChildren(node).trim().replace(/\s+/g, ' ');
return text ? `\n\n> ${text}\n\n` : '';
}
case 'ul': {
const items = Array.from(node.children)
.filter((c) => c.tagName === 'LI')
.map((li) => `- ${serializeChildren(li).trim()}`)
.join('\n');
return items ? `\n\n${items}\n\n` : '';
}
case 'ol': {
const items = Array.from(node.children)
.filter((c) => c.tagName === 'LI')
.map((li, i) => `${i + 1}. ${serializeChildren(li).trim()}`)
.join('\n');
return items ? `\n\n${items}\n\n` : '';
}
case 'li':
return serializeChildren(node);
case 'p':
case 'div': {
const inner = serializeChildren(node).trim();
return inner ? `\n\n${inner}\n\n` : '';
}
default:
return serializeChildren(node);
}
}
function serializeRichText(root) {
let out = Array.from(root.childNodes).map(serializeNode).join('');
out = out.replace(/[ \t]+\n/g, '\n');
out = out.replace(/\n{3,}/g, '\n\n');
return out.trim();
}
// --- dirty tracking ---------------------------------------------------
function markDirty(richDiv) {
const block = richDiv.closest('.p-block');
if (!block) return;
const dirtyFlag = block.querySelector('.p-dirty-flag');
const hidden = block.querySelector('.p-block-hidden');
if (dirtyFlag) dirtyFlag.value = '1';
if (hidden) hidden.value = serializeRichText(richDiv);
}
// --- selection helpers --------------------------------------------------
function saveSelection(richDiv) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!richDiv.contains(range.startContainer)) return null;
return range.cloneRange();
}
function restoreSelection(range) {
if (!range) return;
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function placeCaretAtEnd(el) {
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function getCurrentBlock(richDiv) {
const sel = window.getSelection();
if (!sel.rangeCount) return null;
let node = sel.getRangeAt(0).startContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
while (node && node !== richDiv && !['P', 'DIV', 'H2', 'H3', 'BLOCKQUOTE', 'LI'].includes(node.tagName)) {
node = node.parentElement;
}
return node === richDiv ? null : node;
}
function toggleBlockTag(richDiv, tagName) {
const block = getCurrentBlock(richDiv);
if (!block) return;
const targetTag = block.tagName === tagName ? 'P' : tagName;
const replacement = document.createElement(targetTag);
replacement.innerHTML = block.innerHTML || '<br>';
block.replaceWith(replacement);
placeCaretAtEnd(replacement);
}
// --- image upload -------------------------------------------------------
async function uploadImage(file, form, target) {
const fd = new FormData();
fd.append('image', file);
fd.append('csrf', form.dataset.csrf);
fd.append('location', form.dataset.location);
fd.append('section', form.dataset.section);
fd.append('folder', form.dataset.folder);
fd.append('target', target);
try {
const res = await fetch(form.dataset.uploadUrl, { method: 'POST', body: fd });
const data = await res.json();
if (!data.ok) {
alert('Opplasting feilet: ' + (data.error || 'ukjent feil'));
return null;
}
return data;
} catch (err) {
alert('Opplasting feilet: ' + err.message);
return null;
}
}
function insertImageAtRange(richDiv, range, imgData) {
const el = document.createElement('img');
el.src = imgData.url;
el.alt = '';
el.setAttribute('data-filename', imgData.filename);
restoreSelection(range);
const sel = window.getSelection();
if (sel.rangeCount && richDiv.contains(sel.getRangeAt(0).startContainer)) {
const r = sel.getRangeAt(0);
r.deleteContents();
r.insertNode(el);
r.setStartAfter(el);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
} else {
richDiv.appendChild(el);
}
markDirty(richDiv);
}
async function uploadAndInsertImage(richDiv, file, range) {
const form = richDiv.closest('form');
const data = await uploadImage(file, form, 'inline');
if (!data) return;
insertImageAtRange(richDiv, range, data);
}
function triggerImageInsert(richDiv) {
const savedRange = saveSelection(richDiv);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
if (input.files[0]) uploadAndInsertImage(richDiv, input.files[0], savedRange);
});
input.click();
}
// --- toolbar commands -----------------------------------------------
function execToolbarCommand(cmd, richDiv) {
richDiv.focus();
switch (cmd) {
case 'bold':
document.execCommand('bold');
break;
case 'italic':
document.execCommand('italic');
break;
case 'ul':
document.execCommand('insertUnorderedList');
break;
case 'ol':
document.execCommand('insertOrderedList');
break;
case 'link': {
const url = prompt('Lenke (URL):', 'https://');
if (url) document.execCommand('createLink', false, url);
break;
}
case 'h2':
toggleBlockTag(richDiv, 'H2');
break;
case 'h3':
toggleBlockTag(richDiv, 'H3');
break;
case 'quote':
toggleBlockTag(richDiv, 'BLOCKQUOTE');
break;
case 'image':
triggerImageInsert(richDiv);
return; // markDirty happens after upload completes
default:
return;
}
markDirty(richDiv);
}
// --- wire up rich text blocks -------------------------------------------
document.querySelectorAll('.p-block').forEach((block) => {
const richDiv = block.querySelector('.p-richtext');
if (!richDiv) return;
richDiv.addEventListener('input', () => markDirty(richDiv));
richDiv.addEventListener('paste', (e) => {
const cd = e.clipboardData || window.clipboardData;
const imageFile = Array.from(cd.files || []).find((f) => f.type.startsWith('image/'));
e.preventDefault();
if (imageFile) {
const sel = window.getSelection();
const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
uploadAndInsertImage(richDiv, imageFile, range);
return;
}
const text = cd.getData('text/plain');
document.execCommand('insertText', false, text);
markDirty(richDiv);
});
richDiv.addEventListener('dragover', (e) => {
e.preventDefault();
richDiv.classList.add('dragover');
});
richDiv.addEventListener('dragleave', () => richDiv.classList.remove('dragover'));
richDiv.addEventListener('drop', (e) => {
e.preventDefault();
richDiv.classList.remove('dragover');
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) return;
let range = null;
if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(e.clientX, e.clientY);
} else if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
if (pos) {
range = document.createRange();
range.setStart(pos.offsetNode, pos.offset);
}
}
uploadAndInsertImage(richDiv, file, range);
});
block.querySelectorAll('.p-block-toolbar [data-cmd]').forEach((btn) => {
btn.addEventListener('click', () => execToolbarCommand(btn.dataset.cmd, richDiv));
});
});
// --- cover image dropzone ------------------------------------------------
function wireCoverDrop(drop) {
const form = drop.closest('form');
let input = drop.querySelector('input[type=file]');
async function handleFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const previousContent = drop.innerHTML;
drop.textContent = 'Laster opp...';
const data = await uploadImage(file, form, 'cover');
if (!data) {
drop.innerHTML = previousContent;
return;
}
drop.innerHTML = '';
const img = document.createElement('img');
img.src = data.url;
drop.appendChild(img);
const newInput = document.createElement('input');
newInput.type = 'file';
newInput.accept = 'image/*';
newInput.hidden = true;
drop.appendChild(newInput);
input = newInput;
newInput.addEventListener('change', () => handleFile(newInput.files[0]));
}
drop.addEventListener('click', () => input && input.click());
if (input) input.addEventListener('change', () => handleFile(input.files[0]));
drop.addEventListener('dragover', (e) => {
e.preventDefault();
drop.classList.add('dragover');
});
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
drop.addEventListener('drop', (e) => {
e.preventDefault();
drop.classList.remove('dragover');
handleFile(e.dataTransfer.files && e.dataTransfer.files[0]);
});
}
document.querySelectorAll('.p-cover-drop').forEach(wireCoverDrop);
// --- confirmations for destructive submit buttons -----------------------
document.querySelectorAll('[data-confirm]').forEach((btn) => {
btn.addEventListener('click', (e) => {
if (!confirm(btn.dataset.confirm)) e.preventDefault();
});
});
// --- safety net: resync any dirty block right before submit -------------
const form = document.getElementById('editor-form');
if (form) {
form.addEventListener('submit', () => {
form.querySelectorAll('.p-block[data-type="richtext"]').forEach((block) => {
const richDiv = block.querySelector('.p-richtext');
const dirtyFlag = block.querySelector('.p-dirty-flag');
if (richDiv && dirtyFlag && dirtyFlag.value === '1') {
block.querySelector('.p-block-hidden').value = serializeRichText(richDiv);
}
});
});
}
})();

View file

@ -3,7 +3,7 @@ default = "no"
available = "no" available = "no"
[plugins] [plugins]
enabled = "languages" enabled = "languages, scheduled-publisher"
[feed] [feed]
exclude_files = "nyhetsbrev, newsletter" 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";