diff --git a/AGENTS.md b/AGENTS.md index 56a7cfb..6f95fee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,33 @@ tables of its own yet — if a future framework feature needs a shipped migration, extend this to the same App-over-novaconium two-root scan `Overlay.php` already does for pages/lib, don't invent a second mechanism. +`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around +native PHP sessions (`session_start()`/`$_SESSION`, not a custom store), +all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()` +until the first real call to a `Session` method. Its `ensureSession()` is a +**deliberate duplicate** of `Csrf::ensureSession()` (same cookie params, +same `session_status()` guard) rather than a shared helper — keeps `Csrf` +standalone with zero new dependencies on a class that didn't exist when it +shipped, same tolerance for small duplication already established by the +config-load block duplicated across `bootstrap.php`/`bin/clear-cache.php`/ +`Lib\Db::config()`. Both classes touching the same native session in the +same request is safe either way, since `session_start()` silently no-ops +if a session is already active — there's no ordering requirement between +`Csrf::token()`/`::verify()` and any `Session` method. + +Flash data (`Session::flash()`/`::getFlash()`) is one swap, not a +sweep/expiry pass: the first `Session` method call in a request snapshots +whatever was flashed on the *previous* request into an in-memory static +(`self::$currentFlash`) for that request's `getFlash()` reads, then +immediately empties the stored flash bucket so `flash()` calls made +*during* the current request start filling a fresh bucket for the request +after this one. This relies on static properties not persisting across +requests (true under `php -S`, mod_php, and PHP-FPM alike — each request +gets fresh PHP state regardless of worker-process reuse) — don't add any +caching/memoization to `Session` that assumes static state survives +between requests, since none of it does. See `/admin/docs/session` for a +worked flash example. + ## Running it ``` diff --git a/README.md b/README.md index 705290e..d28ecbc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages - **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form. - **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`. - **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`. +- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`. - **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it. ## Getting started @@ -77,12 +78,14 @@ php novaconium/bin/create-static-page.php blog/my-new-post ## Documentation -The full framework documentation — routing, sidecars, libraries, layouts, static caching, SEO, Matomo analytics, admin authentication, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights: +The full framework documentation — routing, sidecars, libraries, database, session, layouts, static caching, SEO, Matomo analytics, admin authentication, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights: - [Getting started](http://127.0.0.1:8000/admin/docs/getting-started) - [Routing](http://127.0.0.1:8000/admin/docs/routing) - [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars) - [Libraries](http://127.0.0.1:8000/admin/docs/libraries) +- [Database](http://127.0.0.1:8000/admin/docs/database) +- [Session](http://127.0.0.1:8000/admin/docs/session) - [Layouts](http://127.0.0.1:8000/admin/docs/layouts) - [Static caching](http://127.0.0.1:8000/admin/docs/caching) - [SEO](http://127.0.0.1:8000/admin/docs/seo) diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md index 7da695a..a626710 100644 --- a/novaconium/ISSUES.md +++ b/novaconium/ISSUES.md @@ -54,55 +54,37 @@ expected vs. actual behavior. For features, include the motivating use case.> Suggested build order (foundations first, since admin login builds on two of the others): -1. **Session handling** — no dependencies. -2. **Blog tags/categories** — no dependencies, but now needs a metadata +1. **Blog tags/categories** — no dependencies, but now needs a metadata source design decision first (`PostRepository` was removed when `hello-world`/`second-post` became plain Twig pages — see the entry below), so worth doing first or together with Blog RSS feed. -3. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest +2. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest once tags/categories exist. -4. **Internal search** — SQLite groundwork it needed is done; ready to +3. **Internal search** — SQLite groundwork it needed is done; ready to build. -5. **XML sitemap** — no hard dependency, but shares crawling logic with +4. **XML sitemap** — no hard dependency, but shares crawling logic with Internal search, so easiest right after (or alongside) it. -6. **Media/file manager** — no hard dependency; usable standalone, though +5. **Media/file manager** — no hard dependency; usable standalone, though best gated behind admin login once that exists. -7. **Draft pages (admin-only preview)** — no hard dependency; the admin +6. **Draft pages (admin-only preview)** — no hard dependency; the admin authentication it reuses already shipped (see `/admin/docs/admin-auth`). -8. **Syntax highlighting on code blocks** — no hard dependency on the +7. **Syntax highlighting on code blocks** — no hard dependency on the copy button (see Done — shipped 2026-07-14), but touches the same `
` markup it did.
-9. **Admin login & user management** — SQLite groundwork it needed is
- done; still needs session handling (logged-in state).
-10. **Ecommerce functionality** — needs session handling (cart) and admin
- login & user management (order/product admin, and customer accounts);
- SQLite groundwork it needed is done.
-11. **Paywall functionality** — needs everything Ecommerce needs, plus
+8. **Admin login & user management** — SQLite groundwork and session
+ handling it needed are both done; ready to build.
+9. **Ecommerce functionality** — needs admin login & user management
+ (order/product admin, and customer accounts); SQLite groundwork and
+ session handling (cart) it needed are both done.
+10. **Paywall functionality** — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel.
-MySQL support shipped 2026-07-14 — see Done.
+MySQL support and Session handling (with flash sessions) shipped
+2026-07-14 — see Done.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
-### Session handling (with flash sessions)
-
-- **Type:** Feature
-- **Status:** Backlog
-- **Priority:** High
-- **Added:** 2026-07-12
-
-A `Lib\`/framework-core wrapper around PHP's native session handling
-(`session_start()` etc., not a custom session store) so sidecars and the
-future admin login have a consistent way to read/write session data
-instead of touching `$_SESSION` directly. Include CodeIgniter-style flash
-data — a value set now that survives exactly one subsequent request (e.g.
-`$session->flash('message', 'Saved.')` readable on the next request only,
-via a set-now/expire-after-read scheme) — for post/redirect/GET flows like
-`App/pages/contact/index.php` already does manually with `?sent=1`.
-Foundational alongside SQLite groundwork — no dependencies of its own, but
-admin login depends on it.
-
### Blog tags/categories
- **Type:** Feature
@@ -287,7 +269,7 @@ full of stray markup.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
-- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions)
+- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
- **Added:** 2026-07-12
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
@@ -306,7 +288,7 @@ with the new mechanism, not layering on top of it.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
-- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions), Admin login & user management
+- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- **Added:** 2026-07-12
Product catalog, cart, checkout, and order storage — a `products` /
@@ -326,7 +308,7 @@ planning it all up front here.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
-- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions), Admin login & user management
+- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- **Added:** 2026-07-12
Subscription/membership content gating, similar to OnlyFans/Patreon:
@@ -349,6 +331,36 @@ _Nothing yet._
## Done
+### Session handling (with flash sessions)
+
+- **Type:** Feature
+- **Status:** Done
+- **Priority:** High
+- **Added:** 2026-07-12
+- **Shipped:** 2026-07-14
+
+A `Lib\` wrapper around PHP's native session handling (`session_start()`,
+`$_SESSION`, not a custom session store) — `Lib\Session`
+(`novaconium/lib/Session.php`), all-static and lazy-start, same shape as
+the already-shipped `Lib\Csrf` (which also touches the native session; the
+two coexist in the same request without conflict). Ships
+`get()`/`set()`/`has()`/`remove()` plus CodeIgniter-style flash data
+(`flash()`/`getFlash()`) — a value set now that's readable on exactly the
+next request, then gone, for post/redirect/GET flows like
+`App/pages/contact/index.php`'s hand-rolled `?sent=1` (not refactored to
+use it in this change — cited in the original spec as the motivating
+example, not a mandate to touch working demo code). The flash mechanism is
+a single per-request swap (snapshot last request's flash bucket into an
+in-memory static on first touch, then clear the stored bucket so this
+request's `flash()` calls fill a fresh one for the request after), not a
+separate expiry/sweep pass — verified end-to-end across three real,
+separate HTTP requests sharing a cookie jar (not three calls in one PHP
+process), confirming a flashed value appears on exactly the next request
+and is gone on the one after. Foundational alongside SQLite groundwork —
+admin login (next up) depends on it for logged-in state. Documented at
+`/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db`
+sections.
+
### MySQL support
- **Type:** Feature
diff --git a/novaconium/lib/Session.php b/novaconium/lib/Session.php
new file mode 100644
index 0000000..fafeee2
--- /dev/null
+++ b/novaconium/lib/Session.php
@@ -0,0 +1,114 @@
+ */
+ private static array $currentFlash = [];
+
+ public static function get(string $key, mixed $default = null): mixed
+ {
+ self::ensureSession();
+
+ return $_SESSION[$key] ?? $default;
+ }
+
+ public static function set(string $key, mixed $value): void
+ {
+ self::ensureSession();
+
+ $_SESSION[$key] = $value;
+ }
+
+ public static function has(string $key): bool
+ {
+ self::ensureSession();
+
+ return isset($_SESSION[$key]);
+ }
+
+ public static function remove(string $key): void
+ {
+ self::ensureSession();
+
+ unset($_SESSION[$key]);
+ }
+
+ /**
+ * Stores $value so it's readable via getFlash($key) on the next
+ * request only, then gone — regardless of whether getFlash() was
+ * actually called on that next request.
+ */
+ public static function flash(string $key, mixed $value): void
+ {
+ self::ensureSession();
+
+ $_SESSION[self::FLASH_KEY][$key] = $value;
+ }
+
+ /**
+ * Reads a value flashed on the previous request. Never reflects a
+ * value flashed during this same request — that value will be
+ * readable on the next request instead.
+ */
+ public static function getFlash(string $key, mixed $default = null): mixed
+ {
+ self::ensureSession();
+
+ return self::$currentFlash[$key] ?? $default;
+ }
+
+ private static function ensureSession(): void
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ // Must be called before session_start() — after is a silent no-op.
+ session_set_cookie_params([
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
+ ]);
+
+ session_start();
+ }
+
+ // Runs once per request, on whichever Session method is called
+ // first: snapshot last request's flash bucket for this request's
+ // getFlash() reads, then immediately reset the session's bucket so
+ // flash() calls made during this request go to a fresh bucket —
+ // the one the *next* request will snapshot. This single swap is
+ // the entire flash mechanism; no separate expiry/sweep step needed,
+ // since static properties don't persist across requests.
+ if (!self::$flashLoaded) {
+ self::$currentFlash = $_SESSION[self::FLASH_KEY] ?? [];
+ $_SESSION[self::FLASH_KEY] = [];
+ self::$flashLoaded = true;
+ }
+ }
+}
diff --git a/novaconium/pages/admin/docs/_layout/layout.twig b/novaconium/pages/admin/docs/_layout/layout.twig
index ce9686d..8e1d59e 100644
--- a/novaconium/pages/admin/docs/_layout/layout.twig
+++ b/novaconium/pages/admin/docs/_layout/layout.twig
@@ -14,6 +14,7 @@
{{ icons.book() }}Libraries
{{ icons.book() }}Configuration
{{ icons.book() }}Database
+ {{ icons.book() }}Session
{{ icons.lock() }}Admin authentication
{{ icons.book() }}Layouts
{{ icons.book() }}Static caching
diff --git a/novaconium/pages/admin/docs/index.twig b/novaconium/pages/admin/docs/index.twig
index 13b6e4a..4cc0e0c 100644
--- a/novaconium/pages/admin/docs/index.twig
+++ b/novaconium/pages/admin/docs/index.twig
@@ -4,7 +4,7 @@
{% block title %}Docs{% endblock %}
-{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, layouts, caching, styling.{% endblock %}
+{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, layouts, caching, styling.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
@@ -18,7 +18,8 @@
{{ icons.email() }}Forms — building a custom form with a sidecar, using the novaconium validation/spam libraries.
{{ icons.book() }}Libraries — plain PHP classes under Lib\.
{{ icons.book() }}Configuration — override framework settings from App/config.php.
- {{ icons.book() }}Database — Lib\Db, a thin PDO/SQLite wrapper with a plain-SQL migration convention.
+ {{ icons.book() }}Database — Lib\Db, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.
+ {{ icons.book() }}Session — Lib\Session, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.
{{ icons.lock() }}Admin authentication — gate /admin/* behind HTTP Basic Auth, reusable for any future admin page.
{{ icons.book() }}Layouts — pages and layouts are overridable, just like Lib\.
{{ icons.book() }}Static caching — how sidecar-less pages get served as static HTML.
diff --git a/novaconium/pages/admin/docs/session/index.twig b/novaconium/pages/admin/docs/session/index.twig
new file mode 100644
index 0000000..ab72fa9
--- /dev/null
+++ b/novaconium/pages/admin/docs/session/index.twig
@@ -0,0 +1,46 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% import '_layout/icons.twig' as icons %}
+
+{% block title %}Session{% endblock %}
+
+{% block description %}Lib\Session — a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+ Session
+
+ Lib\Session (novaconium/lib/Session.php) is a thin wrapper around PHP's native session handling — plain session_start()/$_SESSION, not a custom session store — so sidecars have a consistent get/set API instead of touching $_SESSION directly. It's a {{ icons.book() }}Lib\ class like Input/Csrf/Mailer, so a project can override it entirely by dropping its own App/lib/Session.php.
+
+ Using it
+
+ use Lib\Session;
+
+Session::set('user_id', 42);
+$userId = Session::get('user_id'); // 42
+$loggedIn = Session::has('user_id'); // true
+Session::remove('user_id');
+
+ The session is started lazily — nothing calls session_start() until the first real call to any Session method, so a page that never touches Session never gets a session cookie. {{ icons.book() }}Lib\Csrf uses the exact same lazy-start mechanism to run its own session-token CSRF protection — both classes can touch the same native session in the same request without conflict, since session_start() is only ever actually called once (PHP no-ops a second call).
+
+ Flash data
+
+ A flashed value is readable on exactly the next request, then gone — useful for post/redirect/GET flows (a "message sent" banner after a redirect) without a query-string flag like ?sent=1:
+
+ use Lib\Session;
+use App\Response;
+
+// In the sidecar handling the POST:
+Session::flash('message', 'Sent! We\'ll be in touch soon.');
+return Response::redirect('/contact');
+
+// In the sidecar handling the following GET (the redirect target):
+return [
+ 'flashMessage' => Session::getFlash('message'),
+];
+
+ Session::getFlash($key, $default = null) returns the value on the request immediately after flash() was called, and the default on every request after that — regardless of whether getFlash() was actually called on that one request in between. A value flashed during the current request is never visible to getFlash() during that same request; it becomes visible on the next one.
+
+ Mechanically, this is a single swap rather than a separate expiry/sweep step: the first time any Session method runs in a request, it snapshots whatever was flashed on the previous request into an in-memory value for that request's getFlash() calls, then immediately clears the stored flash bucket so flash() calls made during the current request start filling a fresh bucket — the one the next request will snapshot in turn.
+{% endblock %}