6 Commits

Author SHA1 Message Date
nick 5bf0582468 beta works with personal test site. 2026-08-02 14:04:37 -07:00
nick 7650354abd updated readme 2026-07-20 22:00:45 -07:00
nick 0831331fe3 changed to php base 2026-07-20 20:30:03 -07:00
code 76fd1ca3ed Install php-sqlite for pdo_sqlite; add generator meta tag
Arch splits pdo_sqlite/sqlite3 into a separate php-sqlite package —
installing the sqlite CLI package alone left php.ini's pdo_sqlite
uncomment with no module to enable. pdo_mysql needs no such package;
it ships in core php via the bundled mysqlnd driver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 03:00:31 +00:00
code 15b32ed256 Make Docker bind mounts actually work; refresh homepage feature grid
App/, cache/, uploads/, and data/ are now bind-mounted by default, so
docker-entrypoint.sh seeds an empty App/ from a build-time backup and
re-chowns the mounted paths to http:http on every start (a bind mount
doesn't inherit a named volume's ownership or get seeded from the image
the way COPY does). Also fixes AllowOverride never actually being
enabled (the sed pattern didn't account for httpd.conf's indentation,
so only DirectoryIndex-served routes worked) and pins Apache/PHP/SQLite
to a dated Arch Linux Archive snapshot for reproducible builds.

Homepage's feature grid was six cards behind what's actually shipped;
brought it in line with the features blog post.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 02:43:03 +00:00
code d1ce803412 Fix review findings; consolidate pre-release migrations
Bug fixes:
- Deleting a user with comments no longer 500s: remove the user's
  comments first (covers old DBs) and add ON DELETE CASCADE to the FK.
- Drop 'svg' from the default media upload allowlist (stored-XSS vector
  for files served directly from public/uploads/).
- Content index now reindexes on page deletion: track routable page
  count in content_index_meta and treat a count change as stale, since
  the newest-mtime check alone can't see a removal.
- Dev router (public/router.php) preserves the query string across the
  canonical trailing-slash redirect, matching .htaccess.
- Fix stale worked-example reference in the comments thread partial.

Migrations: since v2 is unreleased with no live databases, fold the
incremental ALTERs into the base migrations rather than shipping them
separately: verification columns into 0002_create_users.sql, source_count
into 0001_create_content_index.sql; renumber comments to 0003. Update all
code/doc references to the removed/renamed files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 05:30:10 +00:00
22 changed files with 236 additions and 86 deletions
+1
View File
@@ -9,3 +9,4 @@
/graphify-out/ /graphify-out/
/public/uploads/* /public/uploads/*
!/public/uploads/.gitkeep !/public/uploads/.gitkeep
.env
+18
View File
@@ -424,6 +424,24 @@ button:hover {
.feature-card:nth-child(6) { .feature-card:nth-child(6) {
animation-delay: 0.66s; animation-delay: 0.66s;
} }
.feature-card:nth-child(7) {
animation-delay: 0.72s;
}
.feature-card:nth-child(8) {
animation-delay: 0.78s;
}
.feature-card:nth-child(9) {
animation-delay: 0.84s;
}
.feature-card:nth-child(10) {
animation-delay: 0.9s;
}
.feature-card:nth-child(11) {
animation-delay: 0.96s;
}
.feature-card:nth-child(12) {
animation-delay: 1.02s;
}
.feature-card h2 { .feature-card h2 {
font-size: 1.1rem; font-size: 1.1rem;
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
+30 -2
View File
@@ -56,8 +56,36 @@
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p> <p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
</article> </article>
<article class="feature-card"> <article class="feature-card">
<h2>Matomo &amp; admin auth</h2> <h2>Admin authentication</h2>
<p>Built-in analytics tracking and a multi-user session login for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p> <p>A multi-user session login gates every <code>/admin/*</code> route, with email verification for new accounts. Off by default in <code>App/config.php</code>.</p>
</article>
<article class="feature-card">
<h2>Access control &amp; drafts</h2>
<p>Gate a page to a user or group with one <code>Lib\Access</code> call in its sidecar, or preview an unfinished page as an admin-only draft.</p>
</article>
<article class="feature-card">
<h2>Media manager &amp; comments</h2>
<p>An upload/browse/delete UI at <code>/admin/media</code>, and <code>Lib\Comments</code> for a moderated comment thread on any page.</p>
</article>
<article class="feature-card">
<h2>Database, zero setup</h2>
<p><code>Lib\Db</code> wraps PDO for SQLite or MySQL, multiple named connections at once, migrating automatically on first use.</p>
</article>
<article class="feature-card">
<h2>Search, sitemap &amp; tags</h2>
<p>One content index backs full-text <code>/search</code>, <code>/sitemap.xml</code>, and blog tag browsing — reindexed lazily, no extra steps.</p>
</article>
<article class="feature-card">
<h2>Blog RSS feed</h2>
<p><code>/blog/feed</code> is built from the same hand-written post list <code>App/pages/blog/index.php</code> renders from — no database required.</p>
</article>
<article class="feature-card">
<h2>Matomo &amp; dark/light theme</h2>
<p>Built-in analytics tracking, off by default, alongside a nav toggle that swaps every color via CSS custom properties.</p>
</article>
<article class="feature-card">
<h2>Form security by default</h2>
<p><code>Lib\Csrf</code>, <code>Lib\SpamGuard</code>'s honeypot check, and cleaning input accessors — wired into the contact form and every admin form.</p>
</article> </article>
<article class="feature-card"> <article class="feature-card">
<h2>Override anything</h2> <h2>Override anything</h2>
+41 -26
View File
@@ -1,40 +1,55 @@
# Arch Linux + Apache + PHP image for running novaconium in production. # Official PHP + Apache image for running novaconium in production.
# See /admin/docs/docker for volumes, overriding App/ without a rebuild, # See /admin/docs/docker for the bind-mounted paths, docker-entrypoint.sh's
# and optional MySQL wiring. # seeding/permissions behavior, and optional MySQL wiring.
FROM archlinux:base
RUN pacman -Syu --noconfirm --needed apache php php-apache sqlite \ # Build: docker build --no-cache -t novaconium:latest .
&& pacman -Scc --noconfirm
# php-apache on Arch is built against mpm_prefork, not httpd's default # Fixed: full official image tag (was missing "php:")
# mpm_event — swap MPMs, enable mod_rewrite, point DocumentRoot at FROM php:8.5.8-apache-trixie
# public/, and wire in mod_php.
RUN sed -i \ # Pin to a specific tag (not a floating "php:apache") so a rebuild months
-e 's/^LoadModule mpm_event_module/#LoadModule mpm_event_module/' \ # from now installs the same PHP/Apache/Debian base instead of whatever
-e 's/^#LoadModule mpm_prefork_module/LoadModule mpm_prefork_module/' \ # happens to be current that day. Bump the tag above deliberately (e.g. to
-e '/^#LoadModule rewrite_module/s/^#//' \ # pick up a PHP security release), not as a side effect of an unrelated
-e 's#DocumentRoot "/srv/http"#DocumentRoot "/var/www/html/public"#' \ # rebuild.
-e 's#<Directory "/srv/http">#<Directory "/var/www/html/public">#' \
-e 's/^AllowOverride None/AllowOverride All/' \ RUN apt-get update \
/etc/httpd/conf/httpd.conf \ && apt-get install -y --no-install-recommends libsqlite3-dev \
&& printf '\nLoadModule php_module modules/libphp.so\nAddHandler php-script .php\nDirectoryIndex index.php index.html\nServerName localhost\n' \ && rm -rf /var/lib/apt/lists/* \
>> /etc/httpd/conf/httpd.conf \ && docker-php-ext-install pdo_sqlite pdo_mysql \
&& sed -i \ && a2enmod rewrite
-e 's/^;extension=pdo_sqlite/extension=pdo_sqlite/' \
-e 's/^;extension=pdo_mysql/extension=pdo_mysql/' \ # Point DocumentRoot at public/ and allow .htaccess overrides there.
/etc/php/php.ini RUN sed -ri -e 's#/var/www/html#/var/www/html/public#g' \
/etc/apache2/sites-available/*.conf \
&& sed -ri -e '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' \
/etc/apache2/apache2.conf
WORKDIR /var/www/html WORKDIR /var/www/html
# Copy application files
COPY novaconium/ ./novaconium/ COPY novaconium/ ./novaconium/
COPY public/ ./public/ COPY public/ ./public/
COPY App/ ./App/ COPY App/ ./App/
# Runtime-writable paths not covered by named volumes in docker-compose.yml. # Pristine copy of the starter App/, kept outside /var/www/html so
# docker-entrypoint.sh can reseed a bind-mounted (but empty/missing) App/ on
# first start — see docker-entrypoint.sh and /admin/docs/docker.
RUN cp -a App/ /opt/novaconium-app-default/
# Runtime-writable paths — cache/uploads/App/data are bind-mounted from the
# host by docker-compose.yml, so docker-entrypoint.sh re-chowns them at
# every container start (a build-time chown only survives on the image
# layer, not on a host bind mount). This chown still covers a fresh
# container with no bind mounts configured at all.
RUN mkdir -p public/cache public/uploads data \ RUN mkdir -p public/cache public/uploads data \
&& touch novaconium/contact-log.txt \ && touch novaconium/contact-log.txt \
&& chown -R http:http public/cache public/uploads data App novaconium/contact-log.txt && chown -R www-data:www-data public/cache public/uploads data App novaconium/contact-log.txt
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 80 EXPOSE 80
CMD ["httpd", "-D", "FOREGROUND"] ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["apache2-foreground"]
+29 -10
View File
@@ -1,19 +1,16 @@
``` ![Novaconium PHP](https://i.4lt.ca/git/novaconium-logo.png)
_ __ _____ ____ _ ___ ___ _ __ (_)_ _ _ __ ___
| '_ \ / _ \ \ / / _` |/ __/ _ \| '_ \| | | | | '_ ` _ \
| | | | (_) \ V / (_| | (_| (_) | | | | | |_| | | | | | |
|_| |_|\___/ \_/ \__,_|\___\___/|_| |_|_|\__,_|_| |_| |_|
```
# novaconium A Hugo-flavored PHP framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards.
A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards. No Composer — Twig is vendored directly into the repo as plain source files.
For a full tour of what's included — routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more — see the [Novaconium Features](http://127.0.0.1:8000/blog/novaconium-features) post once the site is running, or `/admin/docs` (see Documentation below). For a full tour of what's included — routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more — see the [Novaconium Features](http://127.0.0.1:8000/blog/novaconium-features) post once the site is running, or `/admin/docs` (see Documentation below).
## Getting started ## Getting started
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`. A few optional features (database, content index/search, admin authentication) need the `pdo_sqlite` extension — see `/admin/docs` for details once running. ### Requirements:
PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`. A few optional features (database, content index/search, admin authentication) need the `pdo_sqlite` extension — see `/admin/docs` for details once running.
### Development
Run it locally, no Apache needed: Run it locally, no Apache needed:
@@ -23,6 +20,28 @@ php -S 127.0.0.1:8000 -t public public/router.php
Visit `http://127.0.0.1:8000/` — click around the example pages, then open `http://127.0.0.1:8000/admin/docs` for the complete documentation, rendered live from this same instance. Visit `http://127.0.0.1:8000/` — click around the example pages, then open `http://127.0.0.1:8000/admin/docs` for the complete documentation, rendered live from this same instance.
### Production
```
#docker buildx build --no-cache -t 4lights/novaconium:2.0.0-beta -t 4lights/corxn:latest --load .
#docker login -u <username>
#docker push 4lights/novaconium:2.0.0
#docker push 4lights/novaconium:latest
docker buildx build --no-cache -t 4lights/novaconium:2.0.0-beta --load .
docker login git.4lt.ca -u nick
docker pull git.4lt.ca/4lt/novaconium:2.0.0-beta
docker compose up -d
root@b2c4133264c6:/var/www/html# php novaconium/bin/create-admin-user.php nick c@nickyeoman.com
```
### Webmasters
- Clone this repo.
- docker build: ``` docker build -t novaconium . ```
- docker compose up -d
## Documentation ## Documentation
The full framework documentation lives inside the framework itself, at `/admin/docs` on any running instance — so it travels with the code, no internet connection needed. That's the canonical reference for everything: requirements, running locally, deploying on Apache or Docker, starting a new project, updating the framework, adding a page, routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, access control, draft pages, media manager, styling, and project layout. The full framework documentation lives inside the framework itself, at `/admin/docs` on any running instance — so it travels with the code, no internet connection needed. That's the canonical reference for everything: requirements, running locally, deploying on Apache or Docker, starting a new project, updating the framework, adding a page, routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, access control, draft pages, media manager, styling, and project layout.
+6 -12
View File
@@ -1,14 +1,14 @@
services: services:
web: web:
build: . image: ${NOVACONIUM_IMAGE:-4lights/novaconium:2.0.0-beta}
ports: ports:
- "8080:80" - "8080:80"
volumes: volumes:
- cache:/var/www/html/public/cache - ${PROJECT_PATH:-/data}:/var/www/html/App
- uploads:/var/www/html/public/uploads - ${PROJECT_PATH:-/data}/css:/var/www/html/public/css
- data:/var/www/html/data - ${VOL_PATH:-/data}/novaconium/cache:/var/www/html/public/cache
# Uncomment to override the baked-in App/ with a host copy, no rebuild: - ${VOL_PATH:-/data}/novaconium/uploads:/var/www/html/public/uploads
# - ./App:/var/www/html/App - ${VOL_PATH:-/data}/novaconium/data:/var/www/html/data
# Optional — only needed if App/config.php adds a db_connections entry # Optional — only needed if App/config.php adds a db_connections entry
# with driver: mysql. See /admin/docs/database. # with driver: mysql. See /admin/docs/database.
@@ -21,9 +21,3 @@ services:
# MYSQL_ROOT_PASSWORD: change-me # MYSQL_ROOT_PASSWORD: change-me
# volumes: # volumes:
# - mysql-data:/var/lib/mysql # - mysql-data:/var/lib/mysql
volumes:
cache:
uploads:
data:
# mysql-data:
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Runs once per container start, before Apache — see /admin/docs/docker.
#
# docker-compose.yml bind-mounts App/, public/cache/, public/uploads/, and
# data/ from the host so a project's content/db survive a rebuild and can be
# edited without one. Two problems a plain COPY-at-build-time image can't
# solve on its own:
#
# 1. A bind mount to an empty (or not-yet-created) host directory shadows
# whatever COPY baked into that path in the image, replacing it with
# nothing — Docker does not seed bind mounts from image content the way
# it seeds a fresh named volume. App/ is only ever the docs/starter
# content wanted on host: seed it from the pristine copy stashed
# at build time (/opt/novaconium-app-default) if the mounted dir is
# empty, so `docker compose up` produces a working site on a first run
# with no manual copy step.
# 2. A bind-mounted host directory keeps the host's ownership, not the
# image's — the build-time `chown` in the Dockerfile never applies to
# it. Re-chown the mounted paths to the Apache worker user on every
# start so they're writable regardless of the host-side UID/GID.
set -e
if [ -z "$(ls -A /var/www/html/App 2>/dev/null)" ]; then
cp -a /opt/novaconium-app-default/. /var/www/html/App/
fi
chown -R www-data:www-data /var/www/html/public/cache /var/www/html/public/uploads /var/www/html/App /var/www/html/data
exec "$@"
+7 -2
View File
@@ -111,8 +111,13 @@ return [
// against the uploaded filename's extension; media_upload_max_bytes // against the uploaded filename's extension; media_upload_max_bytes
// caps a single file's size (checked against both $_FILES' reported // caps a single file's size (checked against both $_FILES' reported
// size and PHP's own upload_max_filesize/post_max_size ini limits, // size and PHP's own upload_max_filesize/post_max_size ini limits,
// see /admin/docs/media-manager). // see /admin/docs/media-manager). 'svg' is deliberately NOT in this
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip'], // default list: files under public/uploads/ are served directly from
// this origin, and an SVG can carry inline <script>, making it a
// stored-XSS vector. Add 'svg' back via App/config.php only if you
// serve uploads with a restrictive CSP or Content-Disposition:
// attachment.
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'txt', 'zip'],
'media_upload_max_bytes' => 10 * 1024 * 1024, 'media_upload_max_bytes' => 10 * 1024 * 1024,
// Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's // Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's
@@ -21,5 +21,6 @@ CREATE VIRTUAL TABLE IF NOT EXISTS content_search USING fts5(route UNINDEXED, ti
CREATE TABLE IF NOT EXISTS content_index_meta ( CREATE TABLE IF NOT EXISTS content_index_meta (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
newest_source_mtime INTEGER NOT NULL, newest_source_mtime INTEGER NOT NULL,
source_count INTEGER NOT NULL DEFAULT 0,
indexed_at TEXT NOT NULL indexed_at TEXT NOT NULL
); );
+4 -1
View File
@@ -6,5 +6,8 @@ CREATE TABLE IF NOT EXISTS users (
role TEXT NOT NULL DEFAULT 'registered', role TEXT NOT NULL DEFAULT 'registered',
user_group TEXT NOT NULL DEFAULT '', user_group TEXT NOT NULL DEFAULT '',
is_disabled INTEGER NOT NULL DEFAULT 0, is_disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL,
verified_at TEXT,
verification_token TEXT,
verification_token_expires_at TEXT
); );
@@ -1,4 +0,0 @@
ALTER TABLE users ADD COLUMN verified_at TEXT;
ALTER TABLE users ADD COLUMN verification_token TEXT;
ALTER TABLE users ADD COLUMN verification_token_expires_at TEXT;
UPDATE users SET verified_at = created_at WHERE verified_at IS NULL;
@@ -1,7 +1,7 @@
CREATE TABLE comments ( CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
page_path TEXT NOT NULL, page_path TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL, body TEXT NOT NULL,
is_hidden INTEGER NOT NULL DEFAULT 0, is_hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL
+1
View File
@@ -12,6 +12,7 @@
<meta name="keywords" content="{% block keywords %}{% endblock %}"> <meta name="keywords" content="{% block keywords %}{% endblock %}">
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}"> <link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="generator" content="novaconium">
{# tags/changefreq/priority (below) are metadata-only, not meant to be {# tags/changefreq/priority (below) are metadata-only, not meant to be
visible on the page. A block tag always emits its content wherever visible on the page. A block tag always emits its content wherever
@@ -4,7 +4,7 @@
(App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and (App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and
'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See 'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See
/admin/docs/comments for the full sidecar contract this expects, and /admin/docs/comments for the full sidecar contract this expects, and
App/pages/blog/hello-world/index.php for a worked example. A page App/pages/blog/comments-demo/index.php for a worked example. A page
with no 'comments' key never includes this at all — see the with no 'comments' key never includes this at all — see the
surrounding {% if comments is defined %} in blog/_layout/layout.twig. surrounding {% if comments is defined %} in blog/_layout/layout.twig.
#} #}
@@ -41,7 +41,7 @@ return [
<h2>Email verification</h2> <h2>Email verification</h2>
<p>Every user created after the first must click a link emailed to their address before they can log in at all — <code>AdminAuth::attempt()</code> fails a login the same generic way it fails a disabled account or a wrong password, with no distinction made to an anonymous caller between "wrong password", "disabled", and "unverified" (novaconium/migrations/0003_add_users_verification.sql's <code>verified_at</code> column). Two accounts are exempt by design, both following the same reasoning as the last-active-admin guard elsewhere on this page — verification can't depend on a mail transport actually being configured:</p> <p>Every user created after the first must click a link emailed to their address before they can log in at all — <code>AdminAuth::attempt()</code> fails a login the same generic way it fails a disabled account or a wrong password, with no distinction made to an anonymous caller between "wrong password", "disabled", and "unverified" (the <code>verified_at</code> column in <code>novaconium/migrations/0002_create_users.sql</code>). Two accounts are exempt by design, both following the same reasoning as the last-active-admin guard elsewhere on this page — verification can't depend on a mail transport actually being configured:</p>
<ul> <ul>
<li><strong>The very first user</strong> (created at <a href="/admin/users">/admin/users</a> or via the CLI below) is auto-verified at creation — there's no other admin to have vouched for them, and they're logged in immediately afterward regardless.</li> <li><strong>The very first user</strong> (created at <a href="/admin/users">/admin/users</a> or via the CLI below) is auto-verified at creation — there's no other admin to have vouched for them, and they're logged in immediately afterward regardless.</li>
@@ -48,5 +48,5 @@ return [
<h2>Schema</h2> <h2>Schema</h2>
<p>Ships as a framework migration, <code>novaconium/migrations/0004_create_comments.sql</code> — a <code>comments</code> table (<code>id</code>, <code>page_path</code>, <code>user_id</code>, <code>body</code>, <code>is_hidden</code>, <code>created_at</code>) on the same <code>default</code> connection as <code>users</code>, applied automatically the first time anything touches it, no manual step. <code>page_path</code> is whatever <code>Comments::currentPagePath()</code> derives from the request (e.g. <code>/blog/comments-demo</code>) — free-text, not a foreign key, so a thread survives even if the page it was attached to is later restructured.</p> <p>Ships as a framework migration, <code>novaconium/migrations/0003_create_comments.sql</code> — a <code>comments</code> table (<code>id</code>, <code>page_path</code>, <code>user_id</code>, <code>body</code>, <code>is_hidden</code>, <code>created_at</code>) on the same <code>default</code> connection as <code>users</code>, applied automatically the first time anything touches it, no manual step. <code>page_path</code> is whatever <code>Comments::currentPagePath()</code> derives from the request (e.g. <code>/blog/comments-demo</code>) — free-text, not a foreign key, so a thread survives even if the page it was attached to is later restructured.</p>
{% endblock %} {% endblock %}
+23 -15
View File
@@ -2,44 +2,52 @@
{% block title %}Docker{% endblock %} {% block title %}Docker{% endblock %}
{% block description %}Running novaconium in a container: the Apache/PHP image, its three volumes, and overriding App/ without a rebuild.{% endblock %} {% block description %}Running novaconium in a container: the Apache/PHP image, its four bind-mounted paths, and how docker-entrypoint.sh seeds and permissions them.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %} {% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %} {% block docs_content %}
<h1>Docker</h1> <h1>Docker</h1>
<p>The root <code>Dockerfile</code> builds an Apache + PHP image on an <a href="https://archlinux.org/">Arch Linux</a> base, with <code>mod_rewrite</code>, <code>AllowOverride All</code>, and <code>pdo_sqlite</code>/<code>pdo_mysql</code> already enabled — nothing else in <a href="/admin/docs/getting-started">Getting started</a>'s "Deploy on Apache" section needs configuring by hand. <code>docker-compose.yml</code> wires it up with three named volumes so a project's own cache, uploads, and database never live inside a path that gets wiped by the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow.</p> <p>The root <code>Dockerfile</code> builds on the official <a href="https://hub.docker.com/_/php"><code>php:8.3-apache</code></a> image, with <code>mod_rewrite</code>, <code>AllowOverride All</code>, and <code>pdo_sqlite</code>/<code>pdo_mysql</code> already enabled — nothing else in <a href="/admin/docs/getting-started">Getting started</a>'s "Deploy on Apache" section needs configuring by hand.</p>
<pre><code>docker compose up --build</code></pre> <pre><code>docker compose up --build</code></pre>
<p>Visit <code>http://localhost:8080/</code>.</p> <p>Visit <code>http://localhost:8080/</code>.</p>
<h2>The volumes</h2> <h2>The bind mounts</h2>
<p><code>docker-compose.yml</code> bind-mounts four host paths under <code>${VOL_PATH:-/data}/novaconium/</code> (override <code>VOL_PATH</code> in the environment to relocate all four at once) so a project's content, uploads, cache, and database live on the host — editable without a rebuild, and untouched by the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow:</p>
<ul> <ul>
<li><code>App</code> → <code>/var/www/html/App</code> — the project itself (pages, lib, config, migrations). Edit it on the host; changes show up after <code>docker compose restart web</code>, no rebuild.</li>
<li><code>cache</code> → <code>public/cache/</code> — the static HTML page cache (see <a href="/admin/docs/caching">Static caching</a>). Disposable; clear it from inside the container with <code>docker compose exec web php novaconium/bin/clear-cache.php</code>.</li> <li><code>cache</code> → <code>public/cache/</code> — the static HTML page cache (see <a href="/admin/docs/caching">Static caching</a>). Disposable; clear it from inside the container with <code>docker compose exec web php novaconium/bin/clear-cache.php</code>.</li>
<li><code>uploads</code> → <code>public/uploads/</code> — files uploaded through <a class="icon-link" href="/admin/docs/media-manager">Media manager</a> (<code>/admin/media</code>). Needs its own volume specifically because <code>public/</code> is otherwise baked into the image with <code>COPY</code> at build time — without this, an upload would only survive until the next <code>docker compose up --build</code>. Kept separate from the <code>data</code> volume below deliberately, since a project might use MySQL or no database at all and shouldn't have upload storage coupled to the SQLite volume.</li> <li><code>uploads</code> → <code>public/uploads/</code> — files uploaded through <a class="icon-link" href="/admin/docs/media-manager">Media manager</a> (<code>/admin/media</code>).</li>
<li><code>data</code> → <code>data/</code> — holds <code>data/novaconium.sqlite</code> if <a href="/admin/docs/database">Database</a>-backed features (admin auth, content index) are enabled. Persists across <code>docker compose down</code>/<code>up</code> as long as you don't pass <code>-v</code>.</li> <li><code>data</code> → <code>data/</code> — holds <code>data/novaconium.sqlite</code> if <a href="/admin/docs/database">Database</a>-backed features (admin auth, content index) are enabled.</li>
</ul> </ul>
<p><code>App/</code> itself is <strong>not</strong> a named volume — it's baked into the image with <code>COPY</code> at build time, so <code>docker compose up --build</code> alone produces a working site with no extra steps. A named volume seeded from <code>COPY App/</code> would only populate once, on first container creation, and would silently go stale on every later rebuild. To edit content without rebuilding, uncomment the bind mount in <code>docker-compose.yml</code>:</p> <p>A bind mount to a host directory shadows whatever the image's <code>COPY</code> put at that path — including with nothing at all, if the host directory doesn't exist yet or is empty. Docker doesn't seed a bind mount from image content the way it seeds a fresh named volume. Two consequences the plain image can't handle by itself, both worked around by <code>docker-entrypoint.sh</code> (the container's <code>ENTRYPOINT</code>, runs once per start before Apache):</p>
<pre><code>volumes: <ul>
- cache:/var/www/html/public/cache <li><strong>Empty <code>App/</code> on first run.</strong> The Dockerfile stashes a pristine copy of the baked-in <code>App/</code> at <code>/opt/novaconium-app-default</code> at build time. If the bind-mounted <code>/var/www/html/App</code> is empty when the container starts, the entrypoint copies that pristine copy in — so a first <code>docker compose up</code> against a fresh, empty <code>${VOL_PATH}/novaconium/App</code> on the host produces a working site instead of a blank one. It only seeds when the directory is empty, so it never clobbers content you've already put there.</li>
- uploads:/var/www/html/public/uploads <li><strong>Permissions.</strong> A bind mount keeps the host directory's ownership, not the image's — the build-time <code>chown -R www-data:www-data</code> in the Dockerfile only applies to the image layer, not to whatever gets mounted over it. The entrypoint re-runs <code>chown -R www-data:www-data</code> on all four mounted paths on every container start, so Apache's worker user (<code>www-data</code>, the Debian default) can always write to them regardless of the host-side UID/GID — no manual <code>chmod</code> on the host required.</li>
- data:/var/www/html/data </ul>
- ./App:/var/www/html/App</code></pre>
<p>A bind mount at the same path as a <code>COPY</code>'d directory shadows the image layer at container start, so a host-side edit under <code>App/pages/</code> shows up after <code>docker compose restart web</code> — no rebuild, no custom entrypoint logic.</p>
<h2>MySQL</h2> <h2>MySQL</h2>
<p><code>Lib\Db</code> supports MySQL alongside or instead of SQLite (see <a href="/admin/docs/database">Database</a>). <code>docker-compose.yml</code> has a commented-out <code>db</code> service and <code>mysql-data</code> volume — uncomment both, add a <code>db_connections</code> entry with <code>driver: mysql</code> and matching credentials in <code>App/config.php</code>, and the app container can reach it at hostname <code>db</code>.</p> <p><code>Lib\Db</code> supports MySQL alongside or instead of SQLite (see <a href="/admin/docs/database">Database</a>). <code>docker-compose.yml</code> has a commented-out <code>db</code> service and <code>mysql-data</code> volume — uncomment both, add a <code>db_connections</code> entry with <code>driver: mysql</code> and matching credentials in <code>App/config.php</code>, and the app container can reach it at hostname <code>db</code>.</p>
<h2>Arch-specific notes</h2> <h2>Pinned base image</h2>
<p>Arch's <code>php-apache</code> package is built against <code>mpm_prefork</code>, not <code>httpd</code>'s default <code>mpm_event</code> — the Dockerfile swaps MPMs as part of the build. The Apache/PHP worker user on Arch is <code>http</code>, not Debian's <code>www-data</code>; the Dockerfile <code>chown</code>s the cache/uploads/data/App directories and <code>novaconium/contact-log.txt</code> to <code>http:http</code> at build time so a freshly created named volume (which inherits the image mountpoint's ownership) is writable immediately. If you swap a named volume for a bind mount pointing at a host directory with different ownership, that automatic chown doesn't apply — you may need to adjust permissions on the host side.</p> <p>The Dockerfile pins an exact tag (<code>php:8.3-apache</code>), not a floating <code>php:apache</code>, so a rebuild months from now installs the same PHP/Apache/Debian base instead of whatever the tag happens to point at that day. Bump the tag in the <code>FROM</code> line deliberately (e.g. to pick up a PHP security release), then rebuild:</p>
<pre><code>docker build --no-cache -t novaconium-beta .</code></pre>
<p><code>--no-cache</code> is worth using any time you change the Dockerfile itself (not just <code>App/</code> content) — Docker otherwise reuses a cached layer for an unchanged-looking <code>RUN</code> step, and <code>docker-compose.yml</code> here uses <code>image:</code> rather than <code>build:</code>, so <code>docker compose up</code> alone never rebuilds at all; you have to <code>docker build</code> (and re-tag, if needed) yourself first.</p>
<h2>What the image adds on top of <code>php:8.3-apache</code></h2>
<p>The base image ships Apache with <code>mod_rewrite</code> disabled and <code>AllowOverride None</code>, no <code>pdo_sqlite</code>/<code>pdo_mysql</code>, and a <code>/var/www/html</code> document root. The Dockerfile runs <code>a2enmod rewrite</code>, <code>docker-php-ext-install pdo_sqlite pdo_mysql</code> (after installing <code>libsqlite3-dev</code>, needed to build <code>pdo_sqlite</code>), and rewrites both the vhost and <code>apache2.conf</code> to point the document root at <code>public/</code> and set <code>AllowOverride All</code> there.</p>
<p>This is a separate Dockerfile from the one-off Dart Sass build tool described in <a href="/admin/docs/styling">Styling</a> — that one lives at <code>Dockerfile.sass</code>, a Debian-based image whose only job is running the <code>sass</code> CLI, not serving the app.</p> <p>This is a separate Dockerfile from the one-off Dart Sass build tool described in <a href="/admin/docs/styling">Styling</a> — that one lives at <code>Dockerfile.sass</code>, a Debian-based image whose only job is running the <code>sass</code> CLI, not serving the app.</p>
{% endblock %} {% endblock %}
+9
View File
@@ -175,6 +175,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// "keep the account but shut it out", so delete is for // "keep the account but shut it out", so delete is for
// accounts that shouldn't exist at all. Any live session dies // accounts that shouldn't exist at all. Any live session dies
// on its next request (currentUser() re-checks the row). // on its next request (currentUser() re-checks the row).
//
// Remove the user's comments first: comments.user_id has a
// NOT NULL foreign key to users(id) (0003_create_comments.sql)
// and Lib\Db runs PRAGMA foreign_keys = ON, so deleting a user
// who has ever commented would otherwise raise a FOREIGN KEY
// constraint violation and 500. Fresh installs also get
// ON DELETE CASCADE on the FK, but this keeps already-migrated
// databases (whose FK predates that) working too.
Db::query('DELETE FROM comments WHERE user_id = ?', [$id]);
Db::query('DELETE FROM users WHERE id = ?', [$id]); Db::query('DELETE FROM users WHERE id = ?', [$id]);
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted."); Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
} }
+1 -1
View File
@@ -386,7 +386,7 @@ button
opacity: 0 opacity: 0
animation: fade-in-up 0.5s ease-out forwards animation: fade-in-up 0.5s ease-out forwards
@for $i from 1 through 6 @for $i from 1 through 12
&:nth-child(#{$i}) &:nth-child(#{$i})
animation-delay: #{0.3 + $i * 0.06}s animation-delay: #{0.3 + $i * 0.06}s
+5 -4
View File
@@ -114,10 +114,11 @@ final class AdminAuth
* exactly like a wrong password — the response never distinguishes * exactly like a wrong password — the response never distinguishes
* "no such user", "disabled", "unverified", and "bad password" (see * "no such user", "disabled", "unverified", and "bad password" (see
* /admin/docs/admin-auth's email verification section). Unset * /admin/docs/admin-auth's email verification section). Unset
* verified_at means never verified; the first user ever created and * verified_at means never verified (the verified_at/verification_token
* every row that existed before this check shipped are backfilled to a * columns are part of novaconium/migrations/0002_create_users.sql); the
* non-null value (novaconium/migrations/0003_add_users_verification.sql) * first user ever created is inserted already-verified (see
* so verification only actually gates accounts created after it. * /admin/users and bin/create-admin-user.php), so verification only
* actually gates accounts created after it.
*/ */
public static function attempt(string $username, string $password): bool public static function attempt(string $username, string $password): bool
{ {
+22 -4
View File
@@ -104,12 +104,21 @@ final class ContentIndexer
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)'); $insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
$newestMtime = 0; $newestMtime = 0;
$sourceCount = 0;
foreach ($routes as $dir) { foreach ($routes as $dir) {
if (in_array($dir, $config['draft_routes'], true)) { if (in_array($dir, $config['draft_routes'], true)) {
continue; continue;
} }
// Count every non-draft routable page, regardless of
// whether it ends up indexed (a noindex or Response-only
// page still counts) — this is a stable fingerprint of the
// routable page *set*, so isStale() can notice a deletion,
// which the newest-mtime check alone can't (deleting a page
// only ever lowers the max mtime, never raises it).
$sourceCount++;
$mtime = self::sourceMtime($config['pages_dirs'], $dir); $mtime = self::sourceMtime($config['pages_dirs'], $dir);
$newestMtime = max($newestMtime, $mtime); $newestMtime = max($newestMtime, $mtime);
@@ -140,8 +149,8 @@ final class ContentIndexer
} }
$pdo->prepare('DELETE FROM content_index_meta')->execute(); $pdo->prepare('DELETE FROM content_index_meta')->execute();
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, indexed_at) VALUES (1, ?, ?)') $pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, source_count, indexed_at) VALUES (1, ?, ?, ?)')
->execute([$newestMtime, gmdate('Y-m-d\TH:i:s\Z')]); ->execute([$newestMtime, $sourceCount, gmdate('Y-m-d\TH:i:s\Z')]);
$pdo->commit(); $pdo->commit();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -166,17 +175,26 @@ final class ContentIndexer
{ {
$pdo = Db::connection(); $pdo = Db::connection();
$meta = $pdo->query('SELECT newest_source_mtime FROM content_index_meta WHERE id = 1')->fetch(); $meta = $pdo->query('SELECT newest_source_mtime, source_count FROM content_index_meta WHERE id = 1')->fetch();
if ($meta === false) { if ($meta === false) {
return true; return true;
} }
$newest = 0; $newest = 0;
$count = 0;
foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) { foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) {
if (in_array($dir, $config['draft_routes'], true)) {
continue;
}
$count++;
$newest = max($newest, self::sourceMtime($config['pages_dirs'], $dir)); $newest = max($newest, self::sourceMtime($config['pages_dirs'], $dir));
} }
return $newest > (int) $meta['newest_source_mtime']; // A newer source file means an edit/addition; a changed page count
// means a page was deleted (or a draft toggled) — the mtime check
// alone can't see a deletion, since removing a page only lowers the
// max mtime. Either signal means the index is stale.
return $newest > (int) $meta['newest_source_mtime'] || $count !== (int) $meta['source_count'];
} }
/** /**
+5 -1
View File
@@ -5,7 +5,11 @@
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); $uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
if ($uri !== '/' && str_ends_with($uri, '/')) { if ($uri !== '/' && str_ends_with($uri, '/')) {
header('Location: ' . rtrim($uri, '/'), true, 301); // Preserve the query string across the canonical redirect — Apache's
// .htaccess does this automatically, so dev must too or post/redirect
// flows like /contact/?sent=1 lose their flags under `php -S`.
$query = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY);
header('Location: ' . rtrim($uri, '/') . ($query !== null ? '?' . $query : ''), true, 301);
exit; exit;
} }