Compare commits
6 Commits
8540c6d9ea
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf0582468 | |||
| 7650354abd | |||
| 0831331fe3 | |||
| 76fd1ca3ed | |||
| 15b32ed256 | |||
| d1ce803412 |
@@ -9,3 +9,4 @@
|
|||||||
/graphify-out/
|
/graphify-out/
|
||||||
/public/uploads/*
|
/public/uploads/*
|
||||||
!/public/uploads/.gitkeep
|
!/public/uploads/.gitkeep
|
||||||
|
.env
|
||||||
@@ -424,6 +424,24 @@ button:hover {
|
|||||||
.feature-card:nth-child(6) {
|
.feature-card:nth-child(6) {
|
||||||
animation-delay: 0.66s;
|
animation-delay: 0.66s;
|
||||||
}
|
}
|
||||||
|
.feature-card:nth-child(7) {
|
||||||
|
animation-delay: 0.72s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(8) {
|
||||||
|
animation-delay: 0.78s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(9) {
|
||||||
|
animation-delay: 0.84s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(10) {
|
||||||
|
animation-delay: 0.9s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(11) {
|
||||||
|
animation-delay: 0.96s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(12) {
|
||||||
|
animation-delay: 1.02s;
|
||||||
|
}
|
||||||
.feature-card h2 {
|
.feature-card h2 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
+30
-2
@@ -56,8 +56,36 @@
|
|||||||
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
|
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature-card">
|
<article class="feature-card">
|
||||||
<h2>Matomo & admin auth</h2>
|
<h2>Admin authentication</h2>
|
||||||
<p>Built-in analytics tracking and a multi-user session login for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p>
|
<p>A multi-user session login gates every <code>/admin/*</code> route, with email verification for new accounts. Off by default in <code>App/config.php</code>.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Access control & 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>
|
||||||
<article class="feature-card">
|
<article class="feature-card">
|
||||||
<h2>Override anything</h2>
|
<h2>Override anything</h2>
|
||||||
|
|||||||
+41
-26
@@ -1,40 +1,55 @@
|
|||||||
# Arch Linux + Apache + PHP image for running novaconium in production.
|
# Official PHP + Apache image for running novaconium in production.
|
||||||
# See /admin/docs/docker for volumes, overriding App/ without a rebuild,
|
# See /admin/docs/docker for the bind-mounted paths, docker-entrypoint.sh's
|
||||||
# and optional MySQL wiring.
|
# seeding/permissions behavior, and optional MySQL wiring.
|
||||||
FROM archlinux:base
|
|
||||||
|
|
||||||
RUN pacman -Syu --noconfirm --needed apache php php-apache sqlite \
|
# Build: docker build --no-cache -t novaconium:latest .
|
||||||
&& pacman -Scc --noconfirm
|
|
||||||
|
|
||||||
# php-apache on Arch is built against mpm_prefork, not httpd's default
|
# Fixed: full official image tag (was missing "php:")
|
||||||
# mpm_event — swap MPMs, enable mod_rewrite, point DocumentRoot at
|
FROM php:8.5.8-apache-trixie
|
||||||
# public/, and wire in mod_php.
|
|
||||||
RUN sed -i \
|
# Pin to a specific tag (not a floating "php:apache") so a rebuild months
|
||||||
-e 's/^LoadModule mpm_event_module/#LoadModule mpm_event_module/' \
|
# from now installs the same PHP/Apache/Debian base instead of whatever
|
||||||
-e 's/^#LoadModule mpm_prefork_module/LoadModule mpm_prefork_module/' \
|
# happens to be current that day. Bump the tag above deliberately (e.g. to
|
||||||
-e '/^#LoadModule rewrite_module/s/^#//' \
|
# pick up a PHP security release), not as a side effect of an unrelated
|
||||||
-e 's#DocumentRoot "/srv/http"#DocumentRoot "/var/www/html/public"#' \
|
# rebuild.
|
||||||
-e 's#<Directory "/srv/http">#<Directory "/var/www/html/public">#' \
|
|
||||||
-e 's/^AllowOverride None/AllowOverride All/' \
|
RUN apt-get update \
|
||||||
/etc/httpd/conf/httpd.conf \
|
&& apt-get install -y --no-install-recommends libsqlite3-dev \
|
||||||
&& printf '\nLoadModule php_module modules/libphp.so\nAddHandler php-script .php\nDirectoryIndex index.php index.html\nServerName localhost\n' \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
>> /etc/httpd/conf/httpd.conf \
|
&& docker-php-ext-install pdo_sqlite pdo_mysql \
|
||||||
&& sed -i \
|
&& a2enmod rewrite
|
||||||
-e 's/^;extension=pdo_sqlite/extension=pdo_sqlite/' \
|
|
||||||
-e 's/^;extension=pdo_mysql/extension=pdo_mysql/' \
|
# Point DocumentRoot at public/ and allow .htaccess overrides there.
|
||||||
/etc/php/php.ini
|
RUN sed -ri -e 's#/var/www/html#/var/www/html/public#g' \
|
||||||
|
/etc/apache2/sites-available/*.conf \
|
||||||
|
&& sed -ri -e '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' \
|
||||||
|
/etc/apache2/apache2.conf
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
COPY novaconium/ ./novaconium/
|
COPY novaconium/ ./novaconium/
|
||||||
COPY public/ ./public/
|
COPY public/ ./public/
|
||||||
COPY App/ ./App/
|
COPY App/ ./App/
|
||||||
|
|
||||||
# Runtime-writable paths not covered by named volumes in docker-compose.yml.
|
# Pristine copy of the starter App/, kept outside /var/www/html so
|
||||||
|
# docker-entrypoint.sh can reseed a bind-mounted (but empty/missing) App/ on
|
||||||
|
# first start — see docker-entrypoint.sh and /admin/docs/docker.
|
||||||
|
RUN cp -a App/ /opt/novaconium-app-default/
|
||||||
|
|
||||||
|
# Runtime-writable paths — cache/uploads/App/data are bind-mounted from the
|
||||||
|
# host by docker-compose.yml, so docker-entrypoint.sh re-chowns them at
|
||||||
|
# every container start (a build-time chown only survives on the image
|
||||||
|
# layer, not on a host bind mount). This chown still covers a fresh
|
||||||
|
# container with no bind mounts configured at all.
|
||||||
RUN mkdir -p public/cache public/uploads data \
|
RUN mkdir -p public/cache public/uploads data \
|
||||||
&& touch novaconium/contact-log.txt \
|
&& touch novaconium/contact-log.txt \
|
||||||
&& chown -R http:http public/cache public/uploads data App novaconium/contact-log.txt
|
&& chown -R www-data:www-data public/cache public/uploads data App novaconium/contact-log.txt
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["httpd", "-D", "FOREGROUND"]
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["apache2-foreground"]
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
```
|

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