From 5deb298b914937fd3524a8b4e2b9d60f1932806c Mon Sep 17 00:00:00 2001 From: code Date: Tue, 14 Jul 2026 06:43:11 +0000 Subject: [PATCH] Add Lib\Session: native session wrapper with flash data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lib\Session (novaconium/lib/Session.php) is a thin, all-static wrapper around PHP's native session handling — get/set/has/remove plus CodeIgniter-style flash data (flash()/getFlash()): a value set now is readable on exactly the next request, then gone, for post/redirect/GET flows like the contact form's hand-rolled ?sent=1 (not refactored here — the original spec cites it as a motivating example, not a mandate). Lazy-start, same shape as the already-shipped Lib\Csrf, which the two classes can share a native session with in the same request without conflict. Flash data is a single per-request swap (snapshot last request's bucket, clear the stored one) rather than a sweep/expiry pass. Verified end-to-end across three separate HTTP requests sharing a cookie jar (not just in-process calls), confirming a flashed value survives exactly one subsequent request. Closes the "Session handling (with flash sessions)" backlog item in novaconium/ISSUES.md. --- AGENTS.md | 27 +++++ README.md | 5 +- novaconium/ISSUES.md | 84 +++++++------ novaconium/lib/Session.php | 114 ++++++++++++++++++ .../pages/admin/docs/_layout/layout.twig | 1 + novaconium/pages/admin/docs/index.twig | 5 +- .../pages/admin/docs/session/index.twig | 46 +++++++ 7 files changed, 243 insertions(+), 39 deletions(-) create mode 100644 novaconium/lib/Session.php create mode 100644 novaconium/pages/admin/docs/session/index.twig 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() }}DatabaseLib\Db, a thin PDO/SQLite wrapper with a plain-SQL migration convention.
  • +
  • {{ icons.book() }}DatabaseLib\Db, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.
  • +
  • {{ icons.book() }}SessionLib\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 %}