Forum integration: Flarum extension, local test stack, embedded comments

- forum/extension: oc:import command (archived comments -> one thread per
  machine) and embed styling
- forum/docker: local Flarum 2 test stack
- site: optional params.forum.url adds a Forum menu entry and shows the
  machine thread in the Comments tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDRjtmAiNpo5mK5N3d7QwZ
This commit is contained in:
2026-10-11 21:21:08 +02:00
co-authored by Claude Opus 5.5
parent 2b5c9df929
commit 32b735283e
17 changed files with 436 additions and 2 deletions
+71
View File
@@ -0,0 +1,71 @@
# Forum and comments (Flarum)
The forum is a separate PHP application ([Flarum](https://flarum.org/) 2, PHP 8.3+, MariaDB or
MySQL). It is not part of the static build: the site only links to it and shows its threads in a
frame.
| Path | Role |
|---|---|
| `extension/` | Flarum extension `oc/flarum-museum`: the `oc:import` command and the look of embedded threads |
| `docker/`, `docker-compose.yml` | local test forum |
| `import/` | not versioned: `machines.json` (export from the extraction repo) and `forum.json` (thread map) |
## How comments work
- `../oc/scripts/06_forum.py` writes `import/machines.json`: one entry per machine with its
archived comments, oldest first.
- `php flarum oc:import` creates one discussion per machine: an opening post linking to the
museum page, then one post per archived comment, all by the user `Archive`, with the original
author and date at the top of each post and the original date as the post date. Discussions go
under three tags (Computers, Consoles, Pongs) that are hidden from "All discussions".
- The command records what it imported (table `oc_threads`) and skips it on later runs, so it
can be run again when new machines arrive. It writes the map machine → discussion to
`forum.json`.
- Copied to `../data/forum.json`, that map makes `make content` give every machine a Comments
tab pointing at its thread (`forum_thread` in the page front matter); the old comments are
then no longer written into the pages.
- With `params.forum.url` set, the Comments tab shows `<forum>/embed/<id>` (Flarum's Embed
extension) in a frame that follows the height of the thread.
## Local test
make forum-up # http://localhost:8081, admin / adminadmin (first start takes a minute)
make forum-import # export + import; safe to repeat
To see it in the site, copy `forum/import/forum.json` to `data/forum.json`, run `make content`,
and build with `HUGO_PARAMS_FORUM_URL=http://localhost:8081 hugo server`. Do not commit a
`data/forum.json` made from the test forum: its thread numbers are not those of the real one.
## Server install (outline)
The forum must live **outside** the folder `make deploy` writes to (that folder is synchronised
with deletion). On the test server: `web/oldcomputers-forum/`, next to `web/oldcomputers/`.
1. PHP 8.3 or newer with curl, dom, fileinfo, gd, json, mbstring, openssl, pdo_mysql, tokenizer,
zip; an empty database and a user for it.
2. `composer create-project flarum/flarum oldcomputers-forum "^2.0"`, then in that folder
`composer require flarum/embed "^2.0"`.
3. Web server: requests that are not files go to `public/index.php`, and nothing outside `public/`
is served. Flarum ships rules for Apache (`public/.htaccess`) and nginx (`.nginx.conf`); a
sub-folder install is described in the Flarum documentation ("Customizing paths").
4. Open the forum address and finish the installer (database, administrator account).
5. Copy `extension/` to `packages/oc-museum/` in the forum folder, then:
composer config repositories.oc path "packages/*"
composer require "oc/flarum-museum:*@dev"
php flarum migrate
php flarum extension:enable flarum-tags
php flarum extension:enable flarum-embed
php flarum extension:enable oc-museum
6. In the admin panel: e-mail settings (registration confirms addresses by e-mail), and a spam
filter extension. Schedule a database dump.
7. Import: copy `import/machines.json` to the server and run
php flarum oc:import machines.json --site-url=https://example.org/oldcomputers --map=forum.json
then bring `forum.json` back as `data/forum.json`, set `params.forum.url`, `make content`,
`make deploy`.
The forum and the site should share one domain: logging in from the embedded thread relies on
the forum's cookie, which browsers restrict across domains.
+24
View File
@@ -0,0 +1,24 @@
# Local test forum on http://localhost:8081 (admin / adminadmin). Data in named volumes.
services:
db:
image: mariadb:11
environment:
MARIADB_DATABASE: flarum
MARIADB_USER: flarum
MARIADB_PASSWORD: flarum
MARIADB_RANDOM_ROOT_PASSWORD: "1"
volumes:
- forum-db:/var/lib/mysql
flarum:
build: docker
depends_on: [db]
environment:
FORUM_URL: http://localhost:8081
ADMIN_PASSWORD: adminadmin
ports:
- "127.0.0.1:8081:80"
volumes:
- ./extension:/var/www/flarum/packages/oc-museum
- ./import:/import
volumes:
forum-db:
+19
View File
@@ -0,0 +1,19 @@
# Local test forum: Flarum 2 on Apache. Not the production install (see forum/README.md).
FROM php:8.3-apache
RUN apt-get update && apt-get install -y --no-install-recommends git unzip libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install pdo_mysql gd zip \
&& a2enmod rewrite && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER=1
RUN composer create-project flarum/flarum /var/www/flarum "^2.0" --no-interaction --prefer-dist \
&& cd /var/www/flarum && mkdir packages \
&& composer require --no-interaction flarum/embed "^2.0" \
&& composer config repositories.oc '{"type": "path", "url": "packages/*", "options": {"symlink": true}}' \
&& chown -R www-data:www-data /var/www/flarum
RUN sed -ri 's#/var/www/html#/var/www/flarum/public#g' /etc/apache2/sites-available/000-default.conf \
&& printf '<Directory /var/www/flarum/public>\n AllowOverride All\n Require all granted\n</Directory>\n' > /etc/apache2/conf-enabled/flarum.conf
COPY entrypoint.sh /entrypoint.sh
WORKDIR /var/www/flarum
ENTRYPOINT ["/entrypoint.sh"]
CMD ["apache2-foreground"]
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# First start: install Flarum against the db service, enable the extensions the site relies on.
set -e
cd /var/www/flarum
if [ ! -f config.php ]; then
until php -r 'new PDO("mysql:host=db;dbname=flarum", "flarum", "flarum");' >/dev/null 2>&1; do sleep 2; done
cat > /tmp/install.yml <<Y
debug: true
baseUrl: ${FORUM_URL}
databaseConfiguration:
driver: mysql
host: db
port: 3306
database: flarum
username: flarum
password: flarum
prefix:
adminUser:
username: admin
password: ${ADMIN_PASSWORD}
email: admin@example.com
settings:
forum_title: Old-Computers.com forum
Y
php flarum install --file=/tmp/install.yml
fi
# the museum extension is mounted from the repository (forum/extension)
composer require --no-interaction "oc/flarum-museum:*@dev" >/dev/null 2>&1 || composer require --no-interaction "oc/flarum-museum:*@dev"
php flarum migrate
for ext in flarum-tags flarum-markdown flarum-embed oc-museum; do php flarum extension:enable "$ext" || true; done
php flarum cache:clear
chown -R www-data:www-data storage public/assets
exec docker-php-entrypoint "$@"
+26
View File
@@ -0,0 +1,26 @@
{
"name": "oc/flarum-museum",
"description": "Old-Computers.com museum integration: imports the archived visitor comments as discussions and styles the embedded threads.",
"type": "flarum-extension",
"license": "MIT",
"require": {
"flarum/core": "^2.0",
"flarum/tags": "^2.0",
"flarum/embed": "^2.0"
},
"autoload": {
"psr-4": {
"Oc\\Museum\\": "src/"
}
},
"extra": {
"flarum-extension": {
"title": "Old-Computers museum",
"icon": {
"name": "fas fa-landmark",
"backgroundColor": "#9a3412",
"color": "#fff"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
use Flarum\Extend;
use Oc\Museum\Console\ImportCommand;
return [
(new Extend\Console())->command(ImportCommand::class),
// threads shown inside the museum pages (flarum/embed) take the look of the static site
(new Extend\Frontend('embed'))->css(__DIR__.'/less/embed.less'),
];
+9
View File
@@ -0,0 +1,9 @@
// Embedded thread, as shown in the Comments tab of the museum pages: full width, on the
// light panel colour of the static site (--panel in its assets/css/main.css).
@oc-accent: #9a3412;
@oc-panel: #fffdf7;
body, .App { background: @oc-panel; }
.Page-content { max-width: none; }
.Post-body a, .PostUser-name a, .Button--link { color: @oc-accent; }
.Button--primary { background: @oc-accent; }
@@ -0,0 +1,10 @@
<?php
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
// One discussion per museum machine; oc_id is the machine id of the original site.
return Migration::createTable('oc_threads', function (Blueprint $table) {
$table->unsignedInteger('oc_id')->primary();
$table->unsignedInteger('discussion_id')->unique();
});
@@ -0,0 +1,167 @@
<?php
namespace Oc\Museum\Console;
use Carbon\Carbon;
use Flarum\Console\AbstractCommand;
use Flarum\Discussion\Discussion;
use Flarum\Post\CommentPost;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Str;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
/**
* Creates one discussion per museum machine from the file written by the extraction
* pipeline (oc/scripts/06_forum.py), with the archived visitor comments as its posts.
*
* Safe to run again: machines already imported (table oc_threads) are skipped.
*/
class ImportCommand extends AbstractCommand
{
private const ARCHIVE_USER = 'Archive';
private const TAGS = [
'Computers' => ['#9a3412', 'fas fa-desktop'],
'Consoles' => ['#1d4ed8', 'fas fa-gamepad'],
'Pongs' => ['#15803d', 'fas fa-table-tennis-paddle-ball'],
];
public function __construct(private ConnectionInterface $db)
{
parent::__construct();
}
protected function configure(): void
{
$this->setName('oc:import')
->setDescription('Import the museum machines and their archived comments as discussions')
->addArgument('file', InputArgument::REQUIRED, 'machines.json written by 06_forum.py')
->addOption('site-url', null, InputOption::VALUE_REQUIRED, 'Address of the static site, e.g. https://example.org/oldcomputers', '')
->addOption('map', null, InputOption::VALUE_REQUIRED, 'Where to write the machine -> discussion map (forum.json)');
}
protected function fire(): int
{
$machines = json_decode((string) file_get_contents($this->input->getArgument('file')), true);
if (! is_array($machines)) {
$this->error('Cannot read the import file.');
return 1;
}
$site = rtrim((string) $this->input->getOption('site-url'), '/');
$archive = $this->archiveUser();
$tags = $this->tags();
$done = $this->db->table('oc_threads')->pluck('discussion_id', 'oc_id');
$created = $posts = 0;
foreach ($machines as $machine) {
if (isset($done[$machine['oc_id']])) {
continue;
}
$this->db->transaction(function () use ($machine, $site, $archive, $tags, &$posts, &$done) {
$id = $this->importMachine($machine, $site, $archive, $tags[$machine['section']] ?? $tags['Computers']);
$this->db->table('oc_threads')->insert(['oc_id' => $machine['oc_id'], 'discussion_id' => $id]);
$done[$machine['oc_id']] = $id;
$posts += count($machine['comments']);
});
if (++$created % 50 === 0) {
$this->info("$created discussions…");
}
}
$archive->refreshCommentCount()->refreshDiscussionCount()->save();
foreach ($tags as $tag) {
$tag->discussion_count = $tag->discussions()->count();
$tag->refreshLastPostedDiscussion()->save();
}
if ($map = $this->input->getOption('map')) {
file_put_contents($map, json_encode($done, JSON_PRETTY_PRINT)."\n");
}
$this->info("$created discussions created with $posts archived comments; ".count($done).' machines mapped.');
return 0;
}
private function importMachine(array $machine, string $site, User $archive, Tag $tag): int
{
$comments = $machine['comments'];
// the thread opens just before its first comment (or now, when there is none)
$opened = $comments ? Carbon::parse($comments[0]['date'])->subMinute() : Carbon::now();
$page = $site.$machine['path'];
$discussion = Discussion::start($machine['title'], $archive);
$discussion->created_at = $opened;
$discussion->save();
$first = $this->post($discussion, $archive, $opened,
"Comments about the [{$machine['title']}]($page) page of the museum.\n\n"
.($comments ? 'The first messages below were left by visitors of the original site.' : 'Be the first to comment!'));
foreach ($comments as $comment) {
$header = '**'.$this->plain($comment['author']).'**'
.($comment['original_date'] ? ' — '.$this->plain($comment['original_date']) : '');
$this->post($discussion, $archive, Carbon::parse($comment['date']), $header."\n\n".$comment['text']);
}
$discussion->setFirstPost($first)->refreshCommentCount()->refreshLastPost()->refreshParticipantCount()->save();
$discussion->tags()->sync([$tag->id]);
return $discussion->id;
}
private function post(Discussion $discussion, User $user, Carbon $when, string $markdown): CommentPost
{
$post = new CommentPost();
$post->discussion_id = $discussion->id;
$post->user_id = $user->id;
$post->created_at = $when;
$post->type = CommentPost::$type;
// the pipeline writes hard line breaks as "\" + newline; the forum keeps plain newlines
$post->setContentAttribute(str_replace("\\\n", "\n", $markdown), $user);
$post->save(); // events raised by the model are not dispatched: no notifications
return $post;
}
private function plain(string $text): string
{
return preg_replace('/([\\\\*_`\[\]@])/', '\\\\$1', trim($text));
}
private function archiveUser(): User
{
$user = User::query()->where('username', self::ARCHIVE_USER)->first();
if (! $user) {
$user = new User();
$user->username = self::ARCHIVE_USER;
$user->email = 'archive@invalid.invalid';
$user->password = Str::random(40); // nobody logs in as the archive
$user->is_email_confirmed = true;
$user->joined_at = Carbon::now();
$user->save();
}
return $user;
}
/** @return array<string, Tag> */
private function tags(): array
{
$tags = [];
$position = (int) Tag::query()->max('position');
foreach (self::TAGS as $name => [$color, $icon]) {
$tag = Tag::query()->where('slug', Str::slug($name))->first();
if (! $tag) {
// hidden from "All discussions": one thread per machine would bury the forum topics
$tag = Tag::build($name, Str::slug($name), "Comments on the $name pages of the museum", $color, $icon, true);
$tag->is_primary = true;
$tag->position = ++$position;
$tag->save();
}
$tags[$name] = $tag;
}
return $tags;
}
}