Compare commits
9 Commits
c0455241ea
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf0582468 | |||
| 7650354abd | |||
| 0831331fe3 | |||
| 76fd1ca3ed | |||
| 15b32ed256 | |||
| d1ce803412 | |||
| 8540c6d9ea | |||
| 64defe7f74 | |||
| e699027b4b |
@@ -9,3 +9,4 @@
|
||||
/graphify-out/
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitkeep
|
||||
.env
|
||||
@@ -51,4 +51,13 @@ return [
|
||||
// `php novaconium/bin/index-content.php` (e.g. from a deploy step):
|
||||
// 'content_index_enabled' => true,
|
||||
// 'content_index_auto' => false,
|
||||
|
||||
// Docs: /admin/docs/admin-auth — email verification. Set all four to
|
||||
// switch Lib\Mailer's transactional mail (verification links, not the
|
||||
// contact form) from the zero-dependency log fallback to MailJet:
|
||||
// 'mail_driver' => 'mailjet',
|
||||
// 'mail_from_email' => 'noreply@example.com',
|
||||
// 'mail_from_name' => 'Example Site',
|
||||
// 'mailjet_api_key' => '...',
|
||||
// 'mailjet_api_secret' => '...',
|
||||
];
|
||||
|
||||
@@ -424,6 +424,24 @@ button:hover {
|
||||
.feature-card:nth-child(6) {
|
||||
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 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.5rem;
|
||||
@@ -17,6 +17,13 @@
|
||||
</aside>
|
||||
<article>
|
||||
{% block blog_content %}{% endblock %}
|
||||
{# Only pages whose sidecar opts in by returning a 'comments'
|
||||
key get a thread — see /admin/docs/comments and
|
||||
App/pages/blog/hello-world/index.php. A sidecar-less post
|
||||
never has this key, so it's silently skipped. #}
|
||||
{% if comments is defined %}
|
||||
{% include '_partials/comments/thread.twig' %}
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
// Demonstrates attaching Lib\Comments to a page — see /admin/docs/comments.
|
||||
// This is the one post under App/pages/blog/ with a sidecar, specifically
|
||||
// so it can carry a live comment thread; giving it one is what excludes
|
||||
// it from the static HTML cache (Renderer::render() only ever caches
|
||||
// sidecar-less pages) — every other post stays sidecar-less/cached and
|
||||
// has no thread, since blog/_layout/layout.twig only includes one when
|
||||
// 'comments' is present in the returned context.
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Comments;
|
||||
use Lib\Csrf;
|
||||
use Lib\FormValidator;
|
||||
use Lib\Input;
|
||||
use Lib\SpamGuard;
|
||||
|
||||
$pagePath = Comments::currentPagePath();
|
||||
$user = AdminAuth::currentUser();
|
||||
$spamGuard = new SpamGuard();
|
||||
$commentError = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect($pagePath . '?error=security');
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
return Response::redirect($pagePath . '?error=login');
|
||||
}
|
||||
|
||||
$body = Input::post('body', '');
|
||||
$validator = (new FormValidator())
|
||||
->required($body, 'body', 'Enter a comment.')
|
||||
->maxLength($body, 'body', 2000, 'Comments are 2000 characters max.');
|
||||
|
||||
if ($validator->passes()) {
|
||||
// Same "bot gets an identical response" reasoning as the contact
|
||||
// form — see App/pages/contact/index.php. Only the insert is
|
||||
// skipped for spam.
|
||||
if (!$spamGuard->isSpam(Input::post())) {
|
||||
Comments::create($pagePath, $user['id'], $body);
|
||||
}
|
||||
|
||||
return Response::redirect($pagePath);
|
||||
}
|
||||
|
||||
$commentError = $validator->errors()['body'] ?? null;
|
||||
}
|
||||
|
||||
return [
|
||||
'comments' => Comments::forPage($pagePath),
|
||||
'currentUser' => $user,
|
||||
'commentError' => $commentError,
|
||||
'renderedAt' => $spamGuard->renderedAt(),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Comments Demo{% endblock %}
|
||||
{% block description %}A worked example of Lib\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block tags %}comments, meta{% endblock %}
|
||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||
|
||||
{% block og_type %}article{% endblock %}
|
||||
{% block og_title %}{{ block('title') }}{% endblock %}
|
||||
{% block og_description %}{{ block('description') }}{% endblock %}
|
||||
{% block og_url %}{{ block('canonical') }}{% endblock %}
|
||||
|
||||
{% block twitter_card %}summary{% endblock %}
|
||||
{% block twitter_title %}{{ block('title') }}{% endblock %}
|
||||
{% block twitter_description %}{{ block('description') }}{% endblock %}
|
||||
|
||||
{% block blog_content %}
|
||||
<h1>Comments Demo</h1>
|
||||
|
||||
<p>Unlike every other post under <code>App/pages/blog/</code>, this one has its own <code>index.php</code> sidecar — <code>App/pages/blog/comments-demo/index.php</code> — which reads and writes a <code>comments</code> table via <code>Lib\Comments</code> and returns a <code>comments</code> key in its context. <code>App/pages/blog/_layout/layout.twig</code> only includes the comment-thread partial (<code>novaconium/pages/_partials/comments/thread.twig</code>) when that key is present, so this is the only post here with a thread below.</p>
|
||||
|
||||
<p>Having a sidecar means this page is never served from the static HTML cache the way its sidecar-less siblings are (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — an explicit, per-page tradeoff you accept the moment a page needs comments. See <a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a> for the full write-up of <code>Lib\Comments</code>, including why comments are tied to real logged-in accounts rather than anonymous name/email fields, and why they're auto-approved with after-the-fact moderation at <code>/admin/comments</code> rather than a pending queue.</p>
|
||||
{% endblock %}
|
||||
@@ -46,5 +46,11 @@ return [
|
||||
'excerpt' => 'A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.',
|
||||
'published' => '2026-07-15',
|
||||
],
|
||||
[
|
||||
'slug' => 'comments-demo',
|
||||
'title' => 'Comments Demo',
|
||||
'excerpt' => 'A worked example of Lib\\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.',
|
||||
'published' => '2026-07-15',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -32,10 +32,11 @@
|
||||
<li><strong>Layout inheritance</strong> — <code>_layout/layout.twig</code> directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.</li>
|
||||
<li><strong>SEO boilerplate out of the box</strong> — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.</li>
|
||||
<li><strong>Built-in Matomo analytics</strong> — set <code>matomo_url</code> and <code>matomo_site_id</code> in <code>App/config.php</code> to enable tracking site-wide, including automatic 404 tracking. Off by default.</li>
|
||||
<li><strong>Admin authentication</strong> — gate every <code>/admin/*</code> route behind a session login with multi-user management: a SQLite-backed <code>users</code> table, <code>/admin/login</code>/<code>/admin/logout</code>, and an <code>/admin/users</code> page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a <code>novaconium/bin/create-admin-user.php</code> CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; off by default.</li>
|
||||
<li><strong>Admin authentication</strong> — gate every <code>/admin/*</code> route behind a session login with multi-user management: a SQLite-backed <code>users</code> table, <code>/admin/login</code>/<code>/admin/logout</code>, and an <code>/admin/users</code> page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a <code>novaconium/bin/create-admin-user.php</code> CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Every account after the first must verify its email (a link sent via <code>Lib\Mailer</code>, logged to a file by default or sent through MailJet if configured) before it can log in. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; off by default.</li>
|
||||
<li><strong>Access control</strong> — assign a page (or a section, one line per page) to a user or group from its sidecar: <code>Access::require('group:members')</code> returns <code>null</code> or a ready-made <code>Response</code> (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction.</li>
|
||||
<li><strong>Draft pages</strong> — list a route under <code>draft_routes</code> in <code>App/config.php</code> to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt.</li>
|
||||
<li><strong>Media manager</strong> — <code>/admin/media</code>, an upload/browse/delete UI for files under <code>public/uploads/</code>, covered by the existing <code>/admin/*</code> auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.</li>
|
||||
<li><strong>Comments</strong> — <code>Lib\Comments</code>, a reusable comment thread any page can attach to itself via its own sidecar (see <code>App/pages/blog/comments-demo/</code>). Tied to real logged-in accounts, not anonymous name/email fields; auto-approved on submission with after-the-fact hide/delete moderation at <code>/admin/comments</code>.</li>
|
||||
<li><strong>Dark/light theme toggle</strong> — a nav button flips a <code>data-theme</code> attribute (persisted to <code>localStorage</code>) that swaps every color via CSS custom properties.</li>
|
||||
<li><strong>Self-hosted spam prevention & form validation</strong> — <code>Lib\SpamGuard</code> (honeypot + submission-timing check, no external CAPTCHA), <code>Lib\FormValidator</code>, and <code>Lib\Validate</code>, demonstrated on the contact form.</li>
|
||||
<li><strong>Form security by default</strong> — <code>Lib\Input</code> (cleaning accessor for <code>$_POST</code>/<code>$_GET</code>) and <code>Lib\Csrf</code> (standalone session-token CSRF protection), wired into the contact form and every admin form.</li>
|
||||
|
||||
+30
-2
@@ -56,8 +56,36 @@
|
||||
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Matomo & admin auth</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>
|
||||
<h2>Admin authentication</h2>
|
||||
<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 & 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 & 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 & 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 & 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 class="feature-card">
|
||||
<h2>Override anything</h2>
|
||||
|
||||
+41
-26
@@ -1,40 +1,55 @@
|
||||
# Arch Linux + Apache + PHP image for running novaconium in production.
|
||||
# See /admin/docs/docker for volumes, overriding App/ without a rebuild,
|
||||
# and optional MySQL wiring.
|
||||
FROM archlinux:base
|
||||
# Official PHP + Apache image for running novaconium in production.
|
||||
# See /admin/docs/docker for the bind-mounted paths, docker-entrypoint.sh's
|
||||
# seeding/permissions behavior, and optional MySQL wiring.
|
||||
|
||||
RUN pacman -Syu --noconfirm --needed apache php php-apache sqlite \
|
||||
&& pacman -Scc --noconfirm
|
||||
# Build: docker build --no-cache -t novaconium:latest .
|
||||
|
||||
# php-apache on Arch is built against mpm_prefork, not httpd's default
|
||||
# mpm_event — swap MPMs, enable mod_rewrite, point DocumentRoot at
|
||||
# public/, and wire in mod_php.
|
||||
RUN sed -i \
|
||||
-e 's/^LoadModule mpm_event_module/#LoadModule mpm_event_module/' \
|
||||
-e 's/^#LoadModule mpm_prefork_module/LoadModule mpm_prefork_module/' \
|
||||
-e '/^#LoadModule rewrite_module/s/^#//' \
|
||||
-e 's#DocumentRoot "/srv/http"#DocumentRoot "/var/www/html/public"#' \
|
||||
-e 's#<Directory "/srv/http">#<Directory "/var/www/html/public">#' \
|
||||
-e 's/^AllowOverride None/AllowOverride All/' \
|
||||
/etc/httpd/conf/httpd.conf \
|
||||
&& printf '\nLoadModule php_module modules/libphp.so\nAddHandler php-script .php\nDirectoryIndex index.php index.html\nServerName localhost\n' \
|
||||
>> /etc/httpd/conf/httpd.conf \
|
||||
&& sed -i \
|
||||
-e 's/^;extension=pdo_sqlite/extension=pdo_sqlite/' \
|
||||
-e 's/^;extension=pdo_mysql/extension=pdo_mysql/' \
|
||||
/etc/php/php.ini
|
||||
# Fixed: full official image tag (was missing "php:")
|
||||
FROM php:8.5.8-apache-trixie
|
||||
|
||||
# Pin to a specific tag (not a floating "php:apache") so a rebuild months
|
||||
# from now installs the same PHP/Apache/Debian base instead of whatever
|
||||
# happens to be current that day. Bump the tag above deliberately (e.g. to
|
||||
# pick up a PHP security release), not as a side effect of an unrelated
|
||||
# rebuild.
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& docker-php-ext-install pdo_sqlite pdo_mysql \
|
||||
&& a2enmod rewrite
|
||||
|
||||
# Point DocumentRoot at public/ and allow .htaccess overrides there.
|
||||
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
|
||||
|
||||
# Copy application files
|
||||
COPY novaconium/ ./novaconium/
|
||||
COPY public/ ./public/
|
||||
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 \
|
||||
&& 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
|
||||
|
||||
CMD ["httpd", "-D", "FOREGROUND"]
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
@@ -1,19 +1,16 @@
|
||||
```
|
||||
_ __ _____ ____ _ ___ ___ _ __ (_)_ _ _ __ ___
|
||||
| '_ \ / _ \ \ / / _` |/ __/ _ \| '_ \| | | | | '_ ` _ \
|
||||
| | | | (_) \ V / (_| | (_| (_) | | | | | |_| | | | | | |
|
||||
|_| |_|\___/ \_/ \__,_|\___\___/|_| |_|_|\__,_|_| |_| |_|
|
||||
```
|
||||

|
||||
|
||||
# novaconium
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
**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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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
@@ -1,14 +1,14 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
image: ${NOVACONIUM_IMAGE:-4lights/novaconium:2.0.0-beta}
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- cache:/var/www/html/public/cache
|
||||
- uploads:/var/www/html/public/uploads
|
||||
- data:/var/www/html/data
|
||||
# Uncomment to override the baked-in App/ with a host copy, no rebuild:
|
||||
# - ./App:/var/www/html/App
|
||||
- ${PROJECT_PATH:-/data}:/var/www/html/App
|
||||
- ${PROJECT_PATH:-/data}/css:/var/www/html/public/css
|
||||
- ${VOL_PATH:-/data}/novaconium/cache:/var/www/html/public/cache
|
||||
- ${VOL_PATH:-/data}/novaconium/uploads:/var/www/html/public/uploads
|
||||
- ${VOL_PATH:-/data}/novaconium/data:/var/www/html/data
|
||||
|
||||
# Optional — only needed if App/config.php adds a db_connections entry
|
||||
# with driver: mysql. See /admin/docs/database.
|
||||
@@ -21,9 +21,3 @@ services:
|
||||
# MYSQL_ROOT_PASSWORD: change-me
|
||||
# volumes:
|
||||
# - mysql-data:/var/lib/mysql
|
||||
|
||||
volumes:
|
||||
cache:
|
||||
uploads:
|
||||
data:
|
||||
# mysql-data:
|
||||
|
||||
@@ -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 "$@"
|
||||
+63
-136
@@ -58,99 +58,85 @@ expected vs. actual behavior. For features, include the motivating use case.>
|
||||
|
||||
Suggested build order (foundations first):
|
||||
|
||||
1. **In-house comments** — admin login & user management (Done
|
||||
2026-07-14) unblocked this; comments are tied to real user accounts,
|
||||
not anonymous.
|
||||
2. **Ecommerce functionality** — its dependencies (SQLite groundwork,
|
||||
session handling for the cart, admin login for order/product admin)
|
||||
are all done now.
|
||||
3. **Paywall functionality** — needs everything Ecommerce needs, plus
|
||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
||||
build after it rather than in parallel — also now has a concrete
|
||||
precedent to follow for the "gated content must skip the static cache"
|
||||
part of its design (see Draft pages (admin-only preview) in Done, and
|
||||
the caching/auth standing rule in `AGENTS.md`), which was still an open
|
||||
question when this entry was originally written.
|
||||
1. **Ecommerce: Lib\Money** — no dependencies, and the other two Ecommerce
|
||||
pieces below both need it.
|
||||
2. **Ecommerce: Lib\Cart** — needs Lib\Money.
|
||||
3. **Ecommerce: payment gateway helper** — needs Lib\Money; independent of
|
||||
Lib\Cart, so it could also go before it.
|
||||
4. **Paywall functionality** — needs the payment gateway helper above for
|
||||
its recurring-billing/payment plumbing; build after it rather than in
|
||||
parallel — also now has a concrete precedent to follow for the "gated
|
||||
content must skip the static cache" part of its design (see Draft
|
||||
pages (admin-only preview) in Done, and the caching/auth standing rule
|
||||
in `AGENTS.md`), which was still an open question when this entry was
|
||||
originally written.
|
||||
|
||||
Session handling (with flash sessions), Draft pages (admin-only preview),
|
||||
Admin login & user management, and Media/file manager all shipped (see
|
||||
Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager.
|
||||
and Admin login & user management all shipped (see Done) — every open
|
||||
entry above still depends on at least one of them. The original single
|
||||
"Ecommerce functionality" entry was scoped down into the three
|
||||
Lib\-helper pieces above on 2026-07-15 — see Lib\Money's entry for why.
|
||||
|
||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||
|
||||
### Email verification for user accounts
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
|
||||
Verify a user's email address by sending a confirmation link — the
|
||||
groundwork is already in place: every account has a required, unique,
|
||||
normalized email (`users.email`, added the same day as user deletion —
|
||||
see User deletion & email addresses under Done). Needs a `verified_at`
|
||||
(or token) column, a token-generation/expiry scheme, a send path
|
||||
(`Lib\Mailer` is currently a log-to-file stand-in — this feature is
|
||||
probably what forces it to grow a real mail transport), and a decision
|
||||
on what an unverified account may do (log in but fail `Lib\Access`
|
||||
rules? not log in at all?). Also the natural home for password-reset-
|
||||
by-email later, which shares all the same plumbing.
|
||||
|
||||
### In-house comments
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
|
||||
- **Added:** 2026-07-14
|
||||
|
||||
A self-hosted comments library — no third-party service (Disqus,
|
||||
Commento, etc.) — a `Lib\` class any sidecar can call to attach comments
|
||||
to any page, not just blog posts, the same way `Lib\SpamGuard`/
|
||||
`Lib\FormValidator` are reusable across any form rather than hardcoded to
|
||||
the contact page. Comments tied to a real user account rather than
|
||||
anonymous name/email fields, which is why this rides on Admin login &
|
||||
user management rather than SQLite groundwork alone — needs that
|
||||
feature's user store to exist first. Likely a `comments` table
|
||||
(route/user/body/created_at/approved or similar — a migration under
|
||||
`App/migrations/`, following the two-root convention documented in
|
||||
`/admin/docs/database`) plus a small set of sidecar-callable methods
|
||||
(list comments for a route, submit one, moderate one). Needs a decision
|
||||
on moderation model (auto-approve vs. admin-approval queue, reusing the
|
||||
`/admin/*` auth gate for the moderation UI) and on spam handling (reuse
|
||||
`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second
|
||||
mechanism, consistent with how CSRF protection already works — see
|
||||
`/admin/docs/sidecars`'s "Form security" section for the existing
|
||||
input-cleaning/CSRF/spam-prevention layers a comment form should compose
|
||||
the same way the contact form does).
|
||||
|
||||
### Ecommerce functionality
|
||||
### Ecommerce: Lib\Money
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **Added:** 2026-07-12
|
||||
- **Added:** 2026-07-15
|
||||
|
||||
Product catalog, cart, checkout, and order storage — a `products` /
|
||||
`orders` table in SQLite, a session-based cart (rides on the flash-session
|
||||
work above), and a payment gateway integration for actually taking money.
|
||||
Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling
|
||||
a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL)
|
||||
over vendoring a full SDK, same reasoning as vendoring only Twig's `src/`
|
||||
rather than pulling in a package manager. Needs a decision on which
|
||||
provider(s) to support first. Order/product management rides on admin
|
||||
login above. Large feature — likely worth its own sub-breakdown (catalog,
|
||||
cart, checkout, order admin) once it's actually picked up rather than
|
||||
planning it all up front here.
|
||||
First and smallest piece of the former "Ecommerce functionality" entry —
|
||||
scoped down (2026-07-15) from a full catalog/cart/checkout/order-admin
|
||||
system to a handful of composable `Lib\` helpers, since a project's idea
|
||||
of a "product" is too site-specific to standardize; the project builds
|
||||
its own catalog and admin UI on `Lib\Db` the same way it would for any
|
||||
other feature, the same split `Lib\Comments` already makes for what a
|
||||
"page" is. `Lib\Money`: integer-cents arithmetic (add/subtract/multiply
|
||||
by a quantity) and formatting, avoiding the classic float-rounding bugs
|
||||
of storing prices as floats. No dependencies — the foundation `Lib\Cart`
|
||||
and the payment gateway helper below both need a non-lossy way to
|
||||
represent an amount before either can be built.
|
||||
|
||||
### Ecommerce: Lib\Cart
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** Ecommerce: Lib\Money, Session handling (with flash sessions) (Done)
|
||||
- **Added:** 2026-07-15
|
||||
|
||||
Second piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||
above for the scoping note). A session-based cart primitive — add/remove/
|
||||
update line items, line and cart totals via `Lib\Money` — riding on
|
||||
`Lib\Session` the same way `Lib\Csrf`/`Lib\AdminAuth` lazily touch the
|
||||
native session. Generic on purpose: the cart holds id/qty/price entries a
|
||||
sidecar hands it, with no opinion on what a "product" is or where its
|
||||
catalog data comes from.
|
||||
|
||||
### Ecommerce: payment gateway helper
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** Ecommerce: Lib\Money
|
||||
- **Added:** 2026-07-15
|
||||
|
||||
Third piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||
above for the scoping note). A driver-dispatched `Lib\` class for taking
|
||||
a payment, mirroring `Lib\Mailer`'s `mail_driver` config-key pattern —
|
||||
Stripe first (one `charge()`-shaped call plus webhook signature
|
||||
verification), calling the provider's REST API directly via cURL rather
|
||||
than vendoring an SDK, same reasoning as vendoring only Twig's `src/`
|
||||
rather than pulling in a package manager. Adding a second provider later
|
||||
means one more driver case, same as `Lib\Mailer::sendMail()`.
|
||||
|
||||
### Paywall functionality
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **Depends on:** Ecommerce: payment gateway helper, SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **Added:** 2026-07-12
|
||||
|
||||
Subscription/membership content gating, similar to OnlyFans/Patreon:
|
||||
@@ -177,65 +163,6 @@ _Nothing yet._
|
||||
|
||||
## Done
|
||||
|
||||
### Media/file manager
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-15
|
||||
|
||||
An upload/browse/delete UI for media (images, PDFs, etc.) at `/admin/media`
|
||||
so sidecars and Twig templates have a consistent place to reference
|
||||
uploaded files from — e.g. a blog post's header image — instead of authors
|
||||
manually copying files into `public/`. Covered by the existing
|
||||
`/admin/*` auth gate the moment the page was added, no extra wiring
|
||||
needed — as planned, there's no separate `*_enabled` flag (unlike admin
|
||||
auth/content index) since it has no SQLite dependency to gate. Backed by a
|
||||
plain directory, `public/uploads/` (gitignored per-file with a tracked
|
||||
`.gitkeep`, same convention as `data/`), rather than a database — no
|
||||
metadata store (alt text, captions) yet; that's flagged as a natural
|
||||
SQLite fit if wanted later, not built now. Also removed the top-level
|
||||
`images/` scaffolding directory/volume (reserved for a future
|
||||
image-upload feature, per the prior `AGENTS.md` wording): nothing had
|
||||
ever consumed it, and `public/uploads/` now covers the use case it was
|
||||
held open for. Safety handling: an extension
|
||||
allowlist and max upload size, both configurable
|
||||
(`media_upload_extensions`/`media_upload_max_bytes` in `App/config.php`),
|
||||
plus filename sanitization (`basename()` + safe-charset reduction,
|
||||
collision-safe via a `-1`/`-2`/... suffix) and a `realpath()` re-check on
|
||||
delete to confirm the resolved path still lands inside `public/uploads/`
|
||||
before unlinking. Documented at `/admin/docs/media-manager` (new topic,
|
||||
linked from the docs nav/index and the admin dashboard), with a matching
|
||||
README feature/docs-index entry.
|
||||
|
||||
### User deletion & email addresses
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
- **Shipped:** 2026-07-14 (b882c30)
|
||||
|
||||
Second same-day follow-up to Admin login & user management (below),
|
||||
also pre-commit — so the `email` column went into the existing
|
||||
`0002_create_users.sql` like the roles change before it. `/admin/users`
|
||||
gained a hard-delete action (username/email become reusable; any live
|
||||
session dies on its next request via the same per-request row re-check
|
||||
disabling uses; the last-active-admin guard covers delete as well as
|
||||
disable/demote) and a change-email action. Every account now requires a
|
||||
unique email address — validated and normalized (trim + lowercase) via
|
||||
the existing `Lib\Validate::isEmail()`, so uniqueness is
|
||||
case-insensitive by construction (verified: `BOB@example.com` collides
|
||||
with `bob@example.com`) — on the create form, the change-email action,
|
||||
and `bin/create-admin-user.php` (now `<username> <email>`). Not used
|
||||
for login or any mail yet; it exists so the planned email-verification
|
||||
flow (new Backlog entry above) has an address for every account that
|
||||
predates it. Delete stays deliberately distinct from disable in the UI
|
||||
(inside a confirm-style `<details>` with a warning): disable is
|
||||
keep-but-shut-out, delete is gone-for-good.
|
||||
|
||||
### User roles, groups & page access control
|
||||
|
||||
- **Type:** Feature
|
||||
|
||||
@@ -65,10 +65,15 @@ if (strlen($password) < 8) {
|
||||
|
||||
// Always role 'admin', as the script name says — /admin/users is the
|
||||
// place to create registered users; this exists for first-user setup and
|
||||
// lockout recovery, both of which need an admin.
|
||||
// lockout recovery, both of which need an admin. Auto-verified for the
|
||||
// same reason /admin/users auto-verifies the very first user: an admin
|
||||
// created this way has nobody else to have vouched for them, and lockout
|
||||
// recovery in particular can't depend on a mail transport being
|
||||
// configured (see /admin/docs/admin-auth's email verification section).
|
||||
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||
Db::query(
|
||||
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, 'admin', '', 0, ?)",
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), gmdate('Y-m-d\TH:i:s\Z')]
|
||||
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, 'admin', '', 0, ?, ?)",
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $now, $now]
|
||||
);
|
||||
|
||||
echo "User '{$username}' created.\n";
|
||||
|
||||
+27
-2
@@ -111,7 +111,32 @@ return [
|
||||
// against the uploaded filename's extension; media_upload_max_bytes
|
||||
// caps a single file's size (checked against both $_FILES' reported
|
||||
// size and PHP's own upload_max_filesize/post_max_size ini limits,
|
||||
// see /admin/docs/media-manager).
|
||||
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip'],
|
||||
// see /admin/docs/media-manager). 'svg' is deliberately NOT in this
|
||||
// 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,
|
||||
|
||||
// Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's
|
||||
// email verification section) — separate from Mailer::send(), the
|
||||
// contact form's own log-to-file stand-in, which this doesn't touch.
|
||||
// 'log' (default) writes to the same novaconium/contact-log.txt with no
|
||||
// external dependency, so a fresh checkout with admin_auth_enabled on
|
||||
// can still create/verify accounts (via the logged link) with zero
|
||||
// setup. 'mailjet' sends through MailJet's Send API v3.1
|
||||
// (https://api.mailjet.com/v3.1/send) using the four keys below — set
|
||||
// all four via App/config.php, e.g.:
|
||||
// 'mail_driver' => 'mailjet',
|
||||
// 'mail_from_email' => 'noreply@example.com',
|
||||
// 'mail_from_name' => 'Example Site',
|
||||
// 'mailjet_api_key' => '...',
|
||||
// 'mailjet_api_secret' => '...',
|
||||
'mail_driver' => 'log',
|
||||
'mail_from_email' => '',
|
||||
'mail_from_name' => '',
|
||||
'mailjet_api_key' => '',
|
||||
'mailjet_api_secret' => '',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* A reusable comment thread any sidecar can attach to any page (not just
|
||||
* blog posts), the same way Lib\SpamGuard/Lib\FormValidator are reusable
|
||||
* across any form rather than hardcoded to the contact page. See
|
||||
* /admin/docs/comments and novaconium/pages/_partials/comments/thread.twig
|
||||
* for the paired Twig partial.
|
||||
*
|
||||
* Comments are tied to a real logged-in account (App\AdminAuth::currentUser()),
|
||||
* never anonymous name/email fields — and since currentUser() already
|
||||
* excludes disabled and unverified accounts, any user id passed to
|
||||
* create() is already a real, verified account with nothing further to
|
||||
* check here. Auto-approved on submission (no pending/approved state) —
|
||||
* only a verified account can post one in the first place, so there's no
|
||||
* anonymous-spam vector to pre-vet against — with a single is_hidden flag
|
||||
* an admin can flip after the fact at /admin/comments, mirroring how
|
||||
* /admin/users disables rather than pre-vets accounts.
|
||||
*/
|
||||
final class Comments
|
||||
{
|
||||
/**
|
||||
* The current route's path, e.g. "/blog/hello-world" — the natural
|
||||
* page_path key for forPage()/create(), derived from the request
|
||||
* itself so callers never hand-write a path that could drift from the
|
||||
* actual route.
|
||||
*/
|
||||
public static function currentPagePath(): string
|
||||
{
|
||||
return (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible (non-hidden) comments for a page, oldest first, joined to
|
||||
* the posting user's username.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function forPage(string $pagePath): array
|
||||
{
|
||||
return Db::query(
|
||||
'SELECT comments.id, comments.body, comments.created_at, users.username ' .
|
||||
'FROM comments JOIN users ON users.id = comments.user_id ' .
|
||||
'WHERE comments.page_path = ? AND comments.is_hidden = 0 ' .
|
||||
'ORDER BY comments.created_at ASC',
|
||||
[$pagePath]
|
||||
)->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function create(string $pagePath, int $userId, string $body): void
|
||||
{
|
||||
Db::query(
|
||||
'INSERT INTO comments (page_path, user_id, body, is_hidden, created_at) VALUES (?, ?, ?, 0, ?)',
|
||||
[$pagePath, $userId, $body, gmdate('Y-m-d\TH:i:s\Z')]
|
||||
);
|
||||
}
|
||||
|
||||
public static function setHidden(int $id, bool $hidden): void
|
||||
{
|
||||
Db::query('UPDATE comments SET is_hidden = ? WHERE id = ?', [$hidden ? 1 : 0, $id]);
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
Db::query('DELETE FROM comments WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every comment, hidden or not, newest first — for the /admin/comments
|
||||
* moderation list.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return Db::query(
|
||||
'SELECT comments.id, comments.page_path, comments.body, comments.created_at, comments.is_hidden, users.username ' .
|
||||
'FROM comments JOIN users ON users.id = comments.user_id ' .
|
||||
'ORDER BY comments.created_at DESC'
|
||||
)->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,93 @@ final class Mailer
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactional mail (verification links, and later password reset) —
|
||||
* separate from send() above, which is specifically the contact form's
|
||||
* "notify the site owner of a submission" shape and stays untouched.
|
||||
* Driver-dispatched via config['mail_driver'] (see /admin/docs/admin-auth
|
||||
* and novaconium/config.php): 'log' (default, zero external dependency,
|
||||
* same novaconium/contact-log.txt as send() above but a distinguishable
|
||||
* line prefix) or 'mailjet' (Send API v3.1, https://api.mailjet.com/v3.1/send,
|
||||
* Basic auth mailjet_api_key:mailjet_api_secret). Adding a future
|
||||
* provider means adding a case here and a private sendVia*() method —
|
||||
* callers never change.
|
||||
*/
|
||||
public function sendMail(string $toEmail, string $subject, string $textBody): bool
|
||||
{
|
||||
$config = self::config();
|
||||
|
||||
return match ($config['mail_driver']) {
|
||||
'mailjet' => $this->sendViaMailjet($config, $toEmail, $subject, $textBody),
|
||||
default => $this->sendViaLog($toEmail, $subject, $textBody),
|
||||
};
|
||||
}
|
||||
|
||||
private function sendViaLog(string $toEmail, string $subject, string $textBody): bool
|
||||
{
|
||||
$line = sprintf(
|
||||
"[%s] MAIL <%s> %s: %s\n",
|
||||
date('c'),
|
||||
$toEmail,
|
||||
$subject,
|
||||
str_replace("\n", ' ', $textBody)
|
||||
);
|
||||
|
||||
file_put_contents(__DIR__ . '/../contact-log.txt', $line, FILE_APPEND);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function sendViaMailjet(array $config, string $toEmail, string $subject, string $textBody): bool
|
||||
{
|
||||
$payload = [
|
||||
'Messages' => [
|
||||
[
|
||||
'From' => [
|
||||
'Email' => $config['mail_from_email'],
|
||||
'Name' => $config['mail_from_name'],
|
||||
],
|
||||
'To' => [
|
||||
['Email' => $toEmail],
|
||||
],
|
||||
'Subject' => $subject,
|
||||
'TextPart' => $textBody,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$ch = curl_init('https://api.mailjet.com/v3.1/send');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_USERPWD => $config['mailjet_api_key'] . ':' . $config['mailjet_api_secret'],
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $status >= 200 && $status < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same two-step App-over-novaconium shallow merge every entry point
|
||||
* duplicates (see novaconium/bootstrap.php, Lib\Db::config()) rather
|
||||
* than a shared Config class — no such class exists in this codebase.
|
||||
*/
|
||||
private static function config(): array
|
||||
{
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
newest_source_mtime INTEGER NOT NULL,
|
||||
source_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -6,5 +6,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
role TEXT NOT NULL DEFAULT 'registered',
|
||||
user_group TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_path TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
is_hidden INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_comments_page_path ON comments(page_path);
|
||||
@@ -12,6 +12,7 @@
|
||||
<meta name="keywords" content="{% block keywords %}{% endblock %}">
|
||||
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="generator" content="novaconium">
|
||||
|
||||
{# tags/changefreq/priority (below) are metadata-only, not meant to be
|
||||
visible on the page. A block tag always emits its content wherever
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{#
|
||||
Reusable comment thread — included from any page whose sidecar
|
||||
returns 'comments' (Lib\Comments::forPage()), 'currentUser'
|
||||
(App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and
|
||||
'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See
|
||||
/admin/docs/comments for the full sidecar contract this expects, and
|
||||
App/pages/blog/comments-demo/index.php for a worked example. A page
|
||||
with no 'comments' key never includes this at all — see the
|
||||
surrounding {% if comments is defined %} in blog/_layout/layout.twig.
|
||||
#}
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
<section class="comments">
|
||||
<h2 class="icon-heading">{{ icons.users() }}Comments</h2>
|
||||
|
||||
{% if comments is empty %}
|
||||
<p>No comments yet.</p>
|
||||
{% else %}
|
||||
{% for comment in comments %}
|
||||
<article class="comment">
|
||||
<p><strong>{{ comment.username }}</strong> — <small>{{ comment.created_at }}</small></p>
|
||||
<p>{{ comment.body }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if currentUser %}
|
||||
<form method="post" action="{{ request_path|default('/') }}">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="comment-website">Leave this field blank</label>
|
||||
<input type="text" id="comment-website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" name="rendered_at" value="{{ renderedAt }}">
|
||||
<p>
|
||||
<label for="comment-body">Add a comment</label><br>
|
||||
<textarea id="comment-body" name="body" rows="4"></textarea>
|
||||
{% if commentError %}<br><small>{{ commentError }}</small>{% endif %}
|
||||
</p>
|
||||
<button type="submit">Post comment</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p><a href="/admin/login">Log in</a> to leave a comment.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Response;
|
||||
use Lib\Comments;
|
||||
use Lib\Csrf;
|
||||
use Lib\Db;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
|
||||
// No auth check here — bootstrap.php's admin gate already covers this
|
||||
// route like every other /admin/* page.
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
Session::flash('comments_error', 'Your session expired before submitting — please try again.');
|
||||
|
||||
return Response::redirect('/admin/comments', 303);
|
||||
}
|
||||
|
||||
$action = Input::post('action', '');
|
||||
$id = (int) Input::post('id', '0');
|
||||
$target = Db::query('SELECT id FROM comments WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('comments_error', 'No such comment.');
|
||||
} elseif ($action === 'hide') {
|
||||
Comments::setHidden($id, true);
|
||||
Session::flash('comments_notice', 'Comment hidden.');
|
||||
} elseif ($action === 'show') {
|
||||
Comments::setHidden($id, false);
|
||||
Session::flash('comments_notice', 'Comment shown.');
|
||||
} elseif ($action === 'delete') {
|
||||
Comments::delete($id);
|
||||
Session::flash('comments_notice', 'Comment deleted.');
|
||||
}
|
||||
|
||||
return Response::redirect('/admin/comments', 303);
|
||||
}
|
||||
|
||||
// Truncated in PHP, not with Twig's |slice — that filter calls
|
||||
// mb_substr() unconditionally, a hard mbstring dependency this project
|
||||
// deliberately avoids (see AGENTS.md).
|
||||
$truncate = static function (string $body): string {
|
||||
$limit = 140;
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($body) : strlen($body);
|
||||
if ($length <= $limit) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
return (function_exists('mb_substr') ? mb_substr($body, 0, $limit) : substr($body, 0, $limit)) . '…';
|
||||
};
|
||||
|
||||
$comments = Comments::all();
|
||||
foreach ($comments as &$comment) {
|
||||
$comment['excerpt'] = $truncate($comment['body']);
|
||||
}
|
||||
unset($comment);
|
||||
|
||||
return [
|
||||
'comments' => $comments,
|
||||
'notice' => Session::getFlash('comments_notice'),
|
||||
'error' => Session::getFlash('comments_error'),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Comments{% endblock %}
|
||||
|
||||
{% block description %}Moderate comments left across the site.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.users() }}Comments</h1>
|
||||
|
||||
<p>Every comment left via <code>Lib\Comments</code>, across every page — auto-approved on submission (only a verified account can post one), moderated here after the fact instead of a pending queue. Hiding a comment removes it from its page immediately; deleting it is permanent. See <a class="icon-link" href="/admin/docs/comments">{{ icons.book() }}Comments</a>.</p>
|
||||
|
||||
{% if notice %}
|
||||
<p><strong>{{ notice }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<p><strong>{{ error }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if comments is empty %}
|
||||
<p>No comments yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page</th>
|
||||
<th>User</th>
|
||||
<th>Comment</th>
|
||||
<th>Posted</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for comment in comments %}
|
||||
<tr>
|
||||
<td><a href="{{ comment.page_path }}">{{ comment.page_path }}</a></td>
|
||||
<td>{{ comment.username }}</td>
|
||||
<td>{{ comment.excerpt }}</td>
|
||||
<td>{{ comment.created_at }}</td>
|
||||
<td>{{ comment.is_hidden ? 'Hidden' : 'Visible' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/comments">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ comment.id }}">
|
||||
<input type="hidden" name="action" value="{{ comment.is_hidden ? 'show' : 'hide' }}">
|
||||
<button type="submit">{{ comment.is_hidden ? 'Show' : 'Hide' }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/comments">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ comment.id }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,7 @@
|
||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
||||
|
||||
@@ -37,7 +37,20 @@ return [
|
||||
|
||||
<p>The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an <em>admin</em> — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at <code>/admin/users</code>. Running it <em>before</em> flipping <code>admin_auth_enabled</code> on is the safer order — the open setup window then never exists at all.</p>
|
||||
|
||||
<p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username) or for any mail yet, but required up front so the planned email-verification flow (see <code>novaconium/ISSUES.md</code>) has an address for every account that already exists by then.</p>
|
||||
<p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username), but every account after the first now must verify it before logging in (see below).</p>
|
||||
|
||||
<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" (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>
|
||||
<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>Every row that existed before this feature shipped</strong> is grandfathered — the migration backfills <code>verified_at</code> from <code>created_at</code>, so nobody who could already log in gets locked out retroactively.</li>
|
||||
</ul>
|
||||
|
||||
<p>Every other new account gets a 24-hour verification link (<code>bin2hex(random_bytes(32))</code>, the same token pattern <code>Lib\Csrf::token()</code> uses) sent via <code>Lib\Mailer::sendMail()</code> to <code>/verify-email?token=...</code>. That route follows the same GET-confirms/POST-mutates shape as <a href="/admin/logout">/admin/logout</a>, and for the same two reasons: the content-index crawl hits every routable page as a forced GET, and email-security scanners prefetch links before a human clicks — either would burn a GET-mutates link's token. An invalid, already-used, or expired token never touches the database on either method — it just renders an "invalid or expired" page pointing back at <code>/admin/users</code>. Changing an account's email (the <code>email</code> action at <code>/admin/users</code>) resets verification and sends a fresh link to the <em>new</em> address, since an unconfirmed new address shouldn't inherit the old one's verified status — and because <code>AdminAuth::currentUser()</code> re-checks <code>verified_at</code> on every request (the same immediate re-check <code>is_disabled</code> already gets), a live session is locked out on its very next request too, not just future logins.</p>
|
||||
|
||||
<p><code>Lib\Mailer::sendMail()</code> is separate from the pre-existing <code>Mailer::send()</code> the contact form uses (unchanged) — it's driver-dispatched via <code>mail_driver</code> in <code>App/config.php</code>: <code>'log'</code> (the default) appends to the same <code>novaconium/contact-log.txt</code> the contact form uses, so a fresh checkout can create and verify accounts with zero external dependency; <code>'mailjet'</code> sends through MailJet's Send API v3.1 using <code>mailjet_api_key</code>/<code>mailjet_api_secret</code> plus <code>mail_from_email</code>/<code>mail_from_name</code>. Adding a different provider later means adding a new case to that dispatch — callers of <code>sendMail()</code> never change.</p>
|
||||
|
||||
<h2>Managing users</h2>
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Comments{% endblock %}
|
||||
|
||||
{% block description %}Lib\Comments — a reusable comment thread any page can attach to itself via its own sidecar.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Comments</h1>
|
||||
|
||||
<p><code>Lib\Comments</code> is a self-hosted comment thread — no third-party service (Disqus, Commento, etc.) — a plain static class any sidecar can call to attach comments to any page, not just blog posts, the same way <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}<code>Lib\SpamGuard</code>/<code>Lib\FormValidator</code></a> are reusable across any form rather than hardcoded to the contact page. It depends on <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> being enabled — comments are tied to a real logged-in account (<code>App\AdminAuth::currentUser()</code>), never anonymous name/email fields, since <code>currentUser()</code> already excludes disabled and unverified accounts, there's nothing further to check before accepting a comment from whoever it returns.</p>
|
||||
|
||||
<h2>Auto-approve, moderate after</h2>
|
||||
|
||||
<p>A comment is visible the instant it's posted — there's no pending-approval queue. Since only a verified account can post one at all, there's no anonymous-spam vector to pre-vet against, the same reasoning <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> already applies by disabling rather than pre-vetting accounts. An admin can hide or permanently delete any comment after the fact at <a href="/admin/comments">/admin/comments</a> — hiding removes it from its page immediately (it's excluded at the query level, not just visually), deleting is permanent.</p>
|
||||
|
||||
<h2>Attaching a thread to a page</h2>
|
||||
|
||||
<p>A page needs its own sidecar to use <code>Lib\Comments</code> — this codebase has no client-side JS/fetch anywhere, every dynamic feature is a plain server-rendered POST form, and comments are no different. Giving a page a sidecar is exactly what excludes it from the <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}static HTML cache</a> (only sidecar-less pages are ever cached), so attaching comments to a previously sidecar-less page is an explicit, per-page tradeoff — the same one <a href="/admin/media">/admin/media</a> and <a href="/search">/search</a> already accept. See <code>App/pages/blog/comments-demo/index.php</code> for a full worked example (the one post under <code>App/pages/blog/</code> with a sidecar, specifically for this):</p>
|
||||
|
||||
<pre><code>$pagePath = Comments::currentPagePath();
|
||||
$user = AdminAuth::currentUser();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) { ... }
|
||||
if ($user === null) { ... } // must be logged in
|
||||
// validate $body, run it past SpamGuard, then:
|
||||
Comments::create($pagePath, $user['id'], $body);
|
||||
return Response::redirect($pagePath);
|
||||
}
|
||||
|
||||
return [
|
||||
'comments' => Comments::forPage($pagePath),
|
||||
'currentUser' => $user,
|
||||
// ...csrfField/csrfToken/renderedAt, same as any other form sidecar
|
||||
];</code></pre>
|
||||
|
||||
<p>Then include the reusable partial, <code>novaconium/pages/_partials/comments/thread.twig</code> — a <code>_</code>-prefixed segment, so it's never itself routable (see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a>'s reserved-segments rule), the same convention <code>_layout/</code> already uses:</p>
|
||||
|
||||
<pre><code class="nohighlight">{% verbatim %}{% if comments is defined %}
|
||||
{% include '_partials/comments/thread.twig' %}
|
||||
{% endif %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>Guarding the include with <code>comments is defined</code> — as <code>App/pages/blog/_layout/layout.twig</code> does — means a layout shared by both comment-enabled and sidecar-less pages can include it unconditionally without breaking the pages that never set the key. The partial itself expects <code>comments</code>, <code>currentUser</code>, <code>csrfField</code>/<code>csrfToken</code>, and <code>renderedAt</code> in context — the same shape returned above — and renders the thread plus a honeypot-carrying submit form (mirroring the contact form's spam prevention, see <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a>) when someone's logged in, or a "log in to comment" link otherwise.</p>
|
||||
|
||||
<h2>Schema</h2>
|
||||
|
||||
<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 %}
|
||||
@@ -2,44 +2,52 @@
|
||||
|
||||
{% 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 docs_content %}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>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>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>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.</li>
|
||||
</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:
|
||||
- cache:/var/www/html/public/cache
|
||||
- uploads:/var/www/html/public/uploads
|
||||
- data:/var/www/html/data
|
||||
- ./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>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block title %}Docs{% endblock %}
|
||||
|
||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, layouts, caching, styling.{% endblock %}
|
||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, comments, layouts, caching, styling.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a> — upload, browse, and delete files under <code>public/uploads/</code> from <code>/admin/media</code>.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a> — <code>Lib\Comments</code>, a reusable comment thread any page can attach to itself via its own sidecar.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
|
||||
<li><a class="icon-link" href="/admin/media">{{ icons.tag() }}Media</a></li>
|
||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
|
||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/comments">{{ icons.users() }}Comments</a></li>{% endif %}
|
||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Db;
|
||||
use Lib\Input;
|
||||
use Lib\Mailer;
|
||||
use Lib\Session;
|
||||
use Lib\Validate;
|
||||
|
||||
@@ -35,6 +36,32 @@ $activeAdminCount = static fn (): int => (int) Db::query(
|
||||
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
|
||||
)->fetchColumn();
|
||||
|
||||
// Issues a fresh verification token (bin2hex(random_bytes(32)), same
|
||||
// pattern as Lib\Csrf::token()) for the given user id, emails the link via
|
||||
// Lib\Mailer::sendMail() (log-file by default, MailJet if configured — see
|
||||
// /admin/docs/admin-auth), and returns the token for callers that don't
|
||||
// need it. Used by both the create action (new non-bootstrap users) and
|
||||
// the resend_verification/email actions below.
|
||||
$issueVerification = static function (int $id, string $email): void {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiresAt = gmdate('Y-m-d\TH:i:s\Z', time() + 86400);
|
||||
|
||||
Db::query(
|
||||
'UPDATE users SET verified_at = NULL, verification_token = ?, verification_token_expires_at = ? WHERE id = ?',
|
||||
[$token, $expiresAt, $id]
|
||||
);
|
||||
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$verifyUrl = "{$scheme}://{$host}/verify-email?token={$token}";
|
||||
|
||||
(new Mailer())->sendMail(
|
||||
$email,
|
||||
'Verify your account',
|
||||
"Confirm your email address to activate your account:\n\n{$verifyUrl}\n\nThis link expires in 24 hours."
|
||||
);
|
||||
};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
Session::flash('users_error', 'Your session expired before submitting — please try again.');
|
||||
@@ -83,10 +110,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// /admin/docs/access-control.
|
||||
$wasFirstUser = !AdminAuth::hasUsers();
|
||||
$role = $wasFirstUser ? 'admin' : 'registered';
|
||||
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||
|
||||
// The first user is auto-verified — there's no other admin to
|
||||
// have vouched for them, and the very next line logs them in
|
||||
// immediately, which a null verified_at would otherwise block
|
||||
// (see AdminAuth::attempt() and /admin/docs/admin-auth). Every
|
||||
// subsequent account starts unverified and gets a verification
|
||||
// email instead, via $issueVerification below.
|
||||
Db::query(
|
||||
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)',
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, gmdate('Y-m-d\TH:i:s\Z')]
|
||||
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)',
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, $now, $wasFirstUser ? $now : null]
|
||||
);
|
||||
|
||||
// Creating the first user is what closes the open setup gate —
|
||||
@@ -94,9 +128,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// bounce them to the login form they were just typing into.
|
||||
if ($wasFirstUser) {
|
||||
AdminAuth::attempt($username, $password);
|
||||
}
|
||||
|
||||
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created.");
|
||||
} else {
|
||||
$id = (int) Db::query('SELECT id FROM users WHERE username = ?', [$username])->fetchColumn();
|
||||
$issueVerification($id, $email);
|
||||
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created — a verification email was sent to {$email}; they can't log in until it's confirmed.");
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'disable' || $action === 'enable') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
@@ -138,6 +175,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// "keep the account but shut it out", so delete is for
|
||||
// accounts that shouldn't exist at all. Any live session dies
|
||||
// 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]);
|
||||
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
||||
}
|
||||
@@ -155,8 +201,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
} elseif ($emailExists) {
|
||||
Session::flash('users_error', 'That email address is already in use.');
|
||||
} else {
|
||||
// A changed address hasn't been proven owned yet — reset
|
||||
// verification and send a fresh link to the *new* address,
|
||||
// rather than silently keeping the old address's verified
|
||||
// status attached to an unconfirmed one. $issueVerification
|
||||
// also sets verified_at back to NULL, so the account can't log
|
||||
// in again until this new address is confirmed.
|
||||
Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]);
|
||||
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D}.");
|
||||
$issueVerification($id, $email);
|
||||
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D} — a verification email was sent to {$email}; they can't log in again until it's confirmed.");
|
||||
}
|
||||
} elseif ($action === 'resend_verification') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$target = Db::query('SELECT id, username, email, verified_at FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif ($target['verified_at'] !== null) {
|
||||
Session::flash('users_error', "\u{201C}{$target['username']}\u{201D} is already verified.");
|
||||
} else {
|
||||
$issueVerification($id, $target['email']);
|
||||
Session::flash('users_notice', "Verification email resent to \u{201C}{$target['username']}\u{201D}.");
|
||||
}
|
||||
} elseif ($action === 'group') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
@@ -192,7 +257,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
return [
|
||||
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at, verified_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'currentUserId' => AdminAuth::currentUser()['id'] ?? null,
|
||||
'notice' => Session::getFlash('users_notice'),
|
||||
'error' => Session::getFlash('users_error'),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.users() }}Users</h1>
|
||||
|
||||
<p>Accounts that can log in at <a href="/admin/login">/admin/login</a>. The first user created is the <strong>admin</strong>; everyone after is <strong>registered</strong> — able to log in and see whatever pages <code>Lib\Access</code> assigns to their account or group (see <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a>), but not the admin area. A disabled user can't log in, and any session they already had is locked out on its next request.</p>
|
||||
<p>Accounts that can log in at <a href="/admin/login">/admin/login</a>. The first user created is the <strong>admin</strong>; everyone after is <strong>registered</strong> — able to log in and see whatever pages <code>Lib\Access</code> assigns to their account or group (see <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a>), but not the admin area. A disabled user can't log in, and any session they already had is locked out on its next request. Every user after the first must also verify their email before they can log in at all — they're sent a verification link on creation (or resend it below), and changing an account's email requires re-verifying the new address.</p>
|
||||
|
||||
{% if notice %}
|
||||
<p><strong>{{ notice }}</strong></p>
|
||||
@@ -34,6 +34,7 @@
|
||||
<th>Group</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th>Verified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -46,6 +47,7 @@
|
||||
<td>{{ user.user_group ?: '—' }}</td>
|
||||
<td>{{ user.created_at }}</td>
|
||||
<td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td>
|
||||
<td>{{ user.verified_at ? 'Verified' : 'Unverified' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
@@ -53,6 +55,14 @@
|
||||
<input type="hidden" name="action" value="{{ user.is_disabled ? 'enable' : 'disable' }}">
|
||||
<button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button>
|
||||
</form>
|
||||
{% if not user.verified_at %}
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
<input type="hidden" name="action" value="resend_verification">
|
||||
<button type="submit">Resend verification</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Db;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
|
||||
// Same two-step config load as /admin/login — this sidecar isn't handed
|
||||
// $config, so it loads its own copy to read admin_auth_enabled before
|
||||
// touching Lib\Db at all.
|
||||
$config = require __DIR__ . '/../../config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
// Nothing to verify against without the users table — 404 exactly like a
|
||||
// route that doesn't exist, same zero-footprint posture as
|
||||
// login/logout/users, and never touch Lib\Db on a site that never opted
|
||||
// in.
|
||||
if (!$config['admin_auth_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
$findByToken = static function (string $token): array|false {
|
||||
if ($token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = Db::query(
|
||||
'SELECT id, username, verification_token_expires_at FROM users WHERE verification_token = ?',
|
||||
[$token]
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row === false || $row['verification_token_expires_at'] < gmdate('Y-m-d\TH:i:s\Z')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $row;
|
||||
};
|
||||
|
||||
// GET renders an inert confirm page, POST does the mutation — same
|
||||
// shape as /admin/logout, and for the same two reasons: the content-index
|
||||
// crawl (ContentIndexer::reindex()) hits every routable page as a forced
|
||||
// GET, and email-security scanners prefetch links before a human clicks —
|
||||
// either one would burn the token on a GET-mutates link. A missing,
|
||||
// wrong, or expired token never touches the database on GET or POST; it
|
||||
// just renders the "invalid or expired" state.
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$token = (string) Input::post('token', '');
|
||||
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect('/verify-email?error=security', 303);
|
||||
}
|
||||
|
||||
$user = $findByToken($token);
|
||||
|
||||
if ($user === false) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'token' => '',
|
||||
'securityError' => false,
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
}
|
||||
|
||||
Db::query(
|
||||
'UPDATE users SET verified_at = ?, verification_token = NULL, verification_token_expires_at = NULL WHERE id = ?',
|
||||
[gmdate('Y-m-d\TH:i:s\Z'), $user['id']]
|
||||
);
|
||||
|
||||
Session::flash('admin_notice', "Email verified for \u{201C}{$user['username']}\u{201D} — you can log in now.");
|
||||
|
||||
return Response::redirect('/admin/login', 303);
|
||||
}
|
||||
|
||||
$token = (string) Input::get('token', '');
|
||||
$user = $findByToken($token);
|
||||
|
||||
return [
|
||||
'valid' => $user !== false,
|
||||
'token' => $token,
|
||||
'securityError' => Input::get('error') === 'security',
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Verify email{% endblock %}
|
||||
|
||||
{% block description %}Confirm a new account's email address.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.email() }}Verify email</h1>
|
||||
|
||||
{% if securityError %}
|
||||
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if valid %}
|
||||
<p>Click below to confirm this address and finish activating the account.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="token" value="{{ token }}">
|
||||
<button type="submit">Verify email</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p><strong>This link is invalid or has expired.</strong> Ask an admin to resend the verification email from <a href="/admin/users">/admin/users</a>.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -386,7 +386,7 @@ button
|
||||
opacity: 0
|
||||
animation: fade-in-up 0.5s ease-out forwards
|
||||
|
||||
@for $i from 1 through 6
|
||||
@for $i from 1 through 12
|
||||
&:nth-child(#{$i})
|
||||
animation-delay: #{0.3 + $i * 0.06}s
|
||||
|
||||
|
||||
@@ -110,9 +110,15 @@ final class AdminAuth
|
||||
|
||||
/**
|
||||
* Verifies a username/password against the users table and, on
|
||||
* success, logs the session in. Disabled users fail exactly like a
|
||||
* wrong password — the response never distinguishes "no such user",
|
||||
* "disabled", and "bad password".
|
||||
* success, logs the session in. Disabled and unverified users fail
|
||||
* exactly like a wrong password — the response never distinguishes
|
||||
* "no such user", "disabled", "unverified", and "bad password" (see
|
||||
* /admin/docs/admin-auth's email verification section). Unset
|
||||
* verified_at means never verified (the verified_at/verification_token
|
||||
* columns are part of novaconium/migrations/0002_create_users.sql); the
|
||||
* first user ever created is inserted already-verified (see
|
||||
* /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
|
||||
{
|
||||
@@ -121,11 +127,11 @@ final class AdminAuth
|
||||
}
|
||||
|
||||
$user = Db::query(
|
||||
'SELECT id, username, email, password_hash, role, user_group, is_disabled FROM users WHERE username = ?',
|
||||
'SELECT id, username, email, password_hash, role, user_group, is_disabled, verified_at FROM users WHERE username = ?',
|
||||
[$username]
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($user === false || (int) $user['is_disabled'] === 1 || !password_verify($password, $user['password_hash'])) {
|
||||
if ($user === false || (int) $user['is_disabled'] === 1 || $user['verified_at'] === null || !password_verify($password, $user['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -159,10 +165,11 @@ final class AdminAuth
|
||||
/**
|
||||
* The logged-in user's row (id/username/email/role/user_group/
|
||||
* is_disabled — never the password hash), or null. Re-checked against
|
||||
* the users table on every request, not just at login, so disabling
|
||||
* or deleting a user locks their existing session out on their very
|
||||
* next request — no "still logged in until the session expires"
|
||||
* window.
|
||||
* the users table on every request, not just at login, so disabling,
|
||||
* deleting, or un-verifying (e.g. an email change reset — see
|
||||
* /admin/docs/admin-auth) a user locks their existing session out on
|
||||
* its very next request — no "still logged in until the session
|
||||
* expires" window.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
@@ -180,7 +187,7 @@ final class AdminAuth
|
||||
}
|
||||
|
||||
$user = Db::query(
|
||||
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0',
|
||||
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0 AND verified_at IS NOT NULL',
|
||||
[$userId]
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
|
||||
@@ -104,12 +104,21 @@ final class ContentIndexer
|
||||
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
|
||||
|
||||
$newestMtime = 0;
|
||||
$sourceCount = 0;
|
||||
|
||||
foreach ($routes as $dir) {
|
||||
if (in_array($dir, $config['draft_routes'], true)) {
|
||||
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);
|
||||
$newestMtime = max($newestMtime, $mtime);
|
||||
|
||||
@@ -140,8 +149,8 @@ final class ContentIndexer
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM content_index_meta')->execute();
|
||||
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, indexed_at) VALUES (1, ?, ?)')
|
||||
->execute([$newestMtime, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, source_count, indexed_at) VALUES (1, ?, ?, ?)')
|
||||
->execute([$newestMtime, $sourceCount, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
@@ -166,17 +175,26 @@ final class ContentIndexer
|
||||
{
|
||||
$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) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$newest = 0;
|
||||
$count = 0;
|
||||
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));
|
||||
}
|
||||
|
||||
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
@@ -5,7 +5,11 @@
|
||||
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user