Add content index: keywords, tags/categories, search, and XML sitemap
Ships Blog tags/categories, Internal search, and XML sitemap together as
one shared mechanism, plus a new meta keywords request, rather than three
separate ones - the "worth deciding together" call ISSUES.md made when
XML sitemap was first written.
Content stays in files. Per-page metadata is four Twig blocks in the
root layout, the same override mechanism already used for
title/description/og_* - keywords (rendered), tags/changefreq/priority
(not rendered, harvested only). App\ContentIndexer crawls every routable
page (Overlay::listPageDirs(), new) and pulls each block via
Renderer::renderForIndex() (new) calling Twig's own renderBlock() API,
not regex-parsing .twig source, so overrides and layout inheritance
resolve exactly like a real render. Rendered HTML is stripped and
indexed into a SQLite FTS5 table for search.
Off by default (content_index_enabled) - same posture as Matomo/admin
auth, since this is a real SQLite dependency plenty of sites won't want.
Verified true zero footprint when disabled: no data/novaconium.sqlite
gets created, all three consumer routes 404 like they don't exist.
content_index_auto (default true) reindexes lazily on first stale touch
of a consumer route, never on a normal page view; both that path and the
explicit `php novaconium/bin/index-content.php` share one reindex().
Two real bugs caught by testing, not review: a reentrancy bug where
/search's own sidecar calling ensureFresh() during the crawl triggered a
nested reindex() mid-transaction (fixed with a static re-entrancy guard),
and a wrong PDO constant in the search sidecar. Also fixed two unrelated
pre-existing bugs found while building this: an unescaped {{ }} in
admin/docs/sidecars that fataled Twig on any real render of that page,
and a stale "no database layer yet" claim there and in Lib\Input's
docblock, left over from before Lib\Db shipped.
Lib\Db's migrations_dir now accepts an ordered list of roots, not just
one path, so the content index's schema could ship as a framework
migration (novaconium/migrations/) without colliding with project
migrations in App/migrations/ - the two-root extension point flagged in
AGENTS.md when SQLite groundwork shipped. Migrations are tracked by path
relative to the repo root rather than bare filename so two roots with a
same-named file can't shadow each other.
New consumer routes: novaconium/pages/sitemap.xml/ and
novaconium/pages/search/ (framework defaults), App/pages/blog/tag/[tag]/
(project-owned, since blog/ is project content - the existing hand
-written post array in App/pages/blog/index.php is untouched). Added
tags to the 4 existing blog posts as a real demonstration.
Closes the Blog tags/categories, Internal search, and XML sitemap
backlog items in novaconium/ISSUES.md.
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</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/layouts">{{ icons.book() }}Layouts</a></li>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Content index{% endblock %}
|
||||
|
||||
{% block description %}The shared crawler behind /sitemap.xml, /search, and blog tag browsing.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Content index</h1>
|
||||
|
||||
<p>Three features — <a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}/sitemap.xml</a>, <a class="icon-link" href="/search">{{ icons.search() }}/search</a>, and blog <a class="icon-link" href="/admin/docs/seo">{{ icons.tag() }}tag browsing</a> — share one underlying mechanism rather than three separate ones: a crawler (<code>App\ContentIndexer</code>, <code>novaconium/src/ContentIndexer.php</code>) that renders every routable page, pulls its metadata, and stores it in SQLite.</p>
|
||||
|
||||
<p>Content itself stays in files — plain Twig pages, same as everywhere else in this framework. Nothing here moves a page's body into a database; only metadata is extracted and indexed.</p>
|
||||
|
||||
<h2>Off by default</h2>
|
||||
|
||||
<p>All three features depend on <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}SQLite</a> — a real dependency plenty of sites built on this framework won't want at all, the same reasoning that keeps Matomo and admin authentication off until a project opts in. <code>content_index_enabled</code> (default <code>false</code>) gates the whole subsystem:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'content_index_enabled' => true,
|
||||
];</code></pre>
|
||||
|
||||
<p>When it's off, <code>/sitemap.xml</code>, <code>/search</code>, and every <code>/blog/tag/<tag></code> route return a plain <code>404</code> — exactly as if they didn't exist — and nothing ever touches <code>Lib\Db</code> because of this feature. <code>data/novaconium.sqlite</code> isn't created just because the code is present in the codebase; each consumer checks the flag before constructing anything that would open a connection.</p>
|
||||
|
||||
<h2>Declaring metadata on a page</h2>
|
||||
|
||||
<p>Four Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> next to the rest of the <a href="/admin/docs/seo">SEO blocks</a> — same override mechanism, just harvested rather than always rendered:</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Block</th><th>Default</th><th>Used for</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>keywords</code></td><td>empty</td><td>rendered as <code><meta name="keywords"></code> on every page (see <a href="/admin/docs/seo">SEO</a>)</td></tr>
|
||||
<tr><td><code>tags</code></td><td>empty</td><td>comma-separated; indexed into <code>content_tags</code> for tag browsing</td></tr>
|
||||
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<pre><code>{% verbatim %}{% block tags %}yellow, the best, summer, hot{% endblock %}
|
||||
{% block changefreq %}weekly{% endblock %}
|
||||
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>See any post under <code>App/pages/blog/</code> for a working <code>tags</code> example.</p>
|
||||
|
||||
<h2>How the crawl works</h2>
|
||||
|
||||
<p><code>ContentIndexer::reindex()</code> walks every page under both page roots (<code>Overlay::listPageDirs()</code>, skipping <code>_</code>-prefixed, <code>404</code>, and <code>[param]</code>-wildcard directories — a wildcard route's concrete values aren't knowable without a data source, so dynamic routes aren't crawled yet), renders each one via <code>Renderer::renderForIndex()</code> (the same sidecar-then-Twig path a real request takes, minus HTTP output and the static cache write), and pulls each metadata block via Twig's own <code>renderBlock()</code> API — not by parsing <code>.twig</code> source — so App-over-novaconium overrides and layout inheritance resolve exactly the way they do for a real visit.</p>
|
||||
|
||||
<p>A page is skipped entirely (not stored) if it's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a>, or if its resolved <code>robots</code> block contains <code>noindex</code> — the same convention <a href="/admin/docs/seo">SEO</a> already documents for admin/internal pages. The rendered HTML is stripped with <code>strip_tags()</code> for a plain-text copy stored in a SQLite <a href="https://sqlite.org/fts5.html">FTS5</a> virtual table for search.</p>
|
||||
|
||||
<p>Every reindex is a full rebuild, not incremental — all three tables are truncated and repopulated inside one transaction, simple and correct at this scale rather than trying to diff what changed. Because the crawl renders every page's sidecar as a plain <code>GET</code> (forcing <code>$_SERVER['REQUEST_METHOD']</code> to <code>'GET'</code> for the duration, regardless of what triggered the reindex, and restoring it afterward), a POST-guarded sidecar action is never accidentally triggered by indexing — the same HTTP-safe-method hygiene any GET handler is already expected to have.</p>
|
||||
|
||||
<h2>When it runs</h2>
|
||||
|
||||
<p>Two paths, both calling the same <code>reindex()</code>:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Lazy (default):</strong> <code>ContentIndexer::ensureFresh()</code>, called from the <code>/sitemap.xml</code>, <code>/search</code>, and tag-browsing sidecars themselves — never from a normal page view, so browsing the rest of the site never pays any indexing cost. It compares the newest source-file mtime across every page (a cheap stat pass, no rendering) against the last indexed time, and only reindexes if something actually changed. Set <code>content_index_auto</code> to <code>false</code> to disable this and rely only on the CLI below.</li>
|
||||
<li><strong>Explicit:</strong> <code>php novaconium/bin/index-content.php</code> — same shape as <code>bin/migrate.php</code>, for a deploy step. Always reindexes (ignores <code>content_index_auto</code>); exits immediately if <code>content_index_enabled</code> is <code>false</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Search</h2>
|
||||
|
||||
<p><code>/search?q=...</code> runs an FTS5 <code>MATCH</code> query. The search term is wrapped as a quoted phrase (embedded <code>"</code> doubled) before binding — parameter binding stops SQL injection, but the bound value is still parsed as its own FTS5 query-language expression, so an unescaped <code>"</code> or FTS operator in user input could otherwise throw a syntax error or search for something unintended.</p>
|
||||
|
||||
<h2>Sitemap</h2>
|
||||
|
||||
<p><code>/sitemap.xml</code> lists every indexed page's <code><loc></code>, <code><lastmod></code> (the page's own source file mtime), <code><changefreq></code>, and <code><priority></code>.</p>
|
||||
|
||||
<h2>Blog tag browsing</h2>
|
||||
|
||||
<p><code>App/pages/blog/tag/[tag]/index.php</code> — project-owned, since <code>blog/</code> itself is project content — uses the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture to read the tag from the URL and queries <code>content_tags</code> joined to <code>content_pages</code>. <code>App/pages/blog/index.php</code>'s own hand-written post list is untouched by any of this — it stays the source of truth for the main blog listing; <code>content_tags</code> is a derived index built from each post's own <code>tags</code> block, not a replacement for it.</p>
|
||||
|
||||
<h2>Migrations, and the two-root scan</h2>
|
||||
|
||||
<p>The schema (<code>content_pages</code>, <code>content_tags</code>, <code>content_search</code>, <code>content_index_meta</code>) ships as a framework migration, <code>novaconium/migrations/0001_create_content_index.sql</code> — the first framework-owned migration, and the reason <a href="/admin/docs/database">{{ icons.book() }}<code>migrations_dir</code></a> now accepts an ordered list of roots instead of a single path: the default connection's <code>migrations_dir</code> is <code>[novaconium/migrations, App/migrations]</code>, so framework migrations always apply before a project's own on the same connection.</p>
|
||||
{% endblock %}
|
||||
@@ -41,7 +41,10 @@ $legacyRows = Db::query('SELECT * FROM widgets', [], 'legacy')->fetchAll();</
|
||||
'default' => [
|
||||
'driver' => 'sqlite',
|
||||
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||
'migrations_dir' => __DIR__ . '/../App/migrations',
|
||||
'migrations_dir' => [
|
||||
__DIR__ . '/migrations',
|
||||
__DIR__ . '/../App/migrations',
|
||||
],
|
||||
],
|
||||
],</code></pre>
|
||||
|
||||
@@ -66,13 +69,13 @@ return [
|
||||
|
||||
<p><strong>This is the one config key in the project that doesn't follow the usual shallow-merge rule.</strong> Every other <code>App/config.php</code> key replaces the framework default outright (see <a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a>) — but a plain shallow merge on <code>db_connections</code> would let the snippet above silently delete the framework's <code>default</code> connection just by adding <code>legacy</code>. So <code>Lib\Db</code> merges <code>db_connections</code> one level deeper, by connection name: the example above ends up with both <code>default</code> (SQLite, from the framework) and <code>legacy</code> (MySQL, from <code>App/config.php</code>) configured at once. To actually replace <code>default</code>, redeclare a <code>default</code> key yourself.</p>
|
||||
|
||||
<p>Only <code>'sqlite'</code> and <code>'mysql'</code> are implemented as <code>driver</code> values. <code>migrations_dir</code> is optional per connection — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for).</p>
|
||||
<p>Only <code>'sqlite'</code> and <code>'mysql'</code> are implemented as <code>driver</code> values. <code>migrations_dir</code> is optional per connection — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for) — and accepts either a single path (<code>legacy</code>'s example above) or an ordered list of roots (the default connection's example above), each scanned for its own <code>*.sql</code> files.</p>
|
||||
|
||||
<p>The default connection's <code>path</code> lives in a top-level <code>data/</code> directory — a sibling of <code>App/</code>, <code>novaconium/</code>, and <code>public/</code>, not nested inside any of them. This is deliberate: it can't live under <code>public/</code> (would be directly web-accessible), and it can't live under <code>novaconium/</code> either, since <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}updating the framework</a> means overwriting that whole directory — anything persisted there would be destroyed by the next update. <code>data/</code> is project-owned, like <code>App/</code>, and untouched by a framework update. Its contents (<code>*.sqlite</code> and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a <code>.gitkeep</code> is tracked so the directory exists in a fresh clone.</p>
|
||||
|
||||
<h2>Migrations</h2>
|
||||
|
||||
<p>Plain <code>.sql</code> files under each connection's own <code>migrations_dir</code>, applied in filename order — name them with a numeric prefix to control ordering:</p>
|
||||
<p>Plain <code>.sql</code> files under each connection's own <code>migrations_dir</code> root(s), applied in filename order within each root — name them with a numeric prefix to control ordering:</p>
|
||||
|
||||
<pre><code>-- App/migrations/0001_create_posts.sql
|
||||
CREATE TABLE posts (
|
||||
@@ -81,9 +84,9 @@ CREATE TABLE posts (
|
||||
body TEXT NOT NULL
|
||||
);</code></pre>
|
||||
|
||||
<p>Each file is tracked by filename in that connection's own <code>schema_migrations</code> table (created automatically in that connection's database) and only ever run once — <code>default</code> and <code>legacy</code> each track their own applied migrations independently. Migrations for a given connection apply automatically the first time it's used in a process — zero-config, the same "just works" philosophy as static caching — or explicitly for every configured connection at once, without serving a request first:</p>
|
||||
<p>Each file is tracked by its path relative to the repo root (e.g. <code>novaconium/migrations/0001_x.sql</code>) in that connection's own <code>schema_migrations</code> table (created automatically in that connection's database) and only ever run once — <code>default</code> and <code>legacy</code> each track their own applied migrations independently. Tracking the repo-relative path rather than the bare filename matters once a connection has more than one <code>migrations_dir</code> root: two roots can each contain a same-named file (e.g. a framework <code>0001_...sql</code> and an unrelated project <code>0001_...sql</code>) without one being mistaken for the other already having run. Migrations for a given connection apply automatically the first time it's used in a process — zero-config, the same "just works" philosophy as static caching — or explicitly for every configured connection at once, without serving a request first:</p>
|
||||
|
||||
<pre><code>php novaconium/bin/migrate.php</code></pre>
|
||||
|
||||
<p>Point two connections' <code>migrations_dir</code> at different directories (e.g. <code>App/migrations/</code> for <code>default</code>, <code>App/migrations/legacy/</code> for <code>legacy</code>) if their SQL genuinely diverges between drivers; otherwise the same directory works for both as long as the SQL in it is portable. The framework itself ships no core tables yet, so there's no second <code>novaconium/migrations/</code> root to merge in — if a future framework feature needs a shipped migration (e.g. <a href="https://git.4lt.ca/4lt/novaconium/issues">admin login's user table</a>), this can extend to the same App-over-novaconium two-root scan used for pages and lib.</p>
|
||||
<p>When a connection has multiple <code>migrations_dir</code> roots, each root is fully processed in the order given — the default connection's own framework root (<code>novaconium/migrations/</code>) always applies completely before its project root (<code>App/migrations/</code>), not interleaved by filename across the two. <code>novaconium/migrations/0001_create_content_index.sql</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>) is the first framework-shipped migration — the reason this two-root support exists at all, extending the same App-over-novaconium override pattern used for pages and lib to migrations too. Point two connections' <code>migrations_dir</code> at different directories (e.g. <code>App/migrations/</code> for <code>default</code>, <code>App/migrations/legacy/</code> for <code>legacy</code>) if their SQL genuinely diverges between drivers; otherwise the same directory works for both as long as the SQL in it is portable.</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block title %}Docs{% endblock %}
|
||||
|
||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
|
||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a> — override framework settings from <code>App/config.php</code>.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a> — the shared crawler behind <code>/sitemap.xml</code>, <code>/search</code>, and blog tag browsing. Off by default.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</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/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block docs_content %}
|
||||
<h1>SEO boilerplate</h1>
|
||||
|
||||
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code><head></code>: viewport, description, robots, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code><head></code>.</p>
|
||||
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code><head></code>: viewport, description, robots, keywords, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code><head></code>. Three more blocks — <code>tags</code>, <code>changefreq</code>, <code>priority</code> — are declared the same way but never rendered into the page at all; see <a href="/admin/docs/content-index">Content index</a> for what reads them.</p>
|
||||
|
||||
<h2>Blocks you can override</h2>
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code><title></code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
|
||||
<tr><td><code>description</code></td><td>generic site description</td><td><code><meta name="description"></code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
|
||||
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code><meta name="robots"></code></td></tr>
|
||||
<tr><td><code>keywords</code></td><td>empty</td><td><code><meta name="keywords"></code></td></tr>
|
||||
<tr><td><code>tags</code></td><td>empty</td><td>not rendered — comma-separated, harvested by <a href="/admin/docs/content-index">the content index</a> for blog tag browsing</td></tr>
|
||||
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||
<tr><td><code>priority</code></td><td><code>0.5</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code><link rel="canonical"></code>, and reused by <code>og:url</code></td></tr>
|
||||
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code><meta property="og:type"></code></td></tr>
|
||||
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta property="og:title"></code></td></tr>
|
||||
@@ -60,6 +64,10 @@
|
||||
{% block description %}One or two sentences describing this page.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block keywords %}{% endblock %}
|
||||
{% block tags %}{% endblock %}
|
||||
{% block changefreq %}monthly{% endblock %}
|
||||
{% block priority %}0.5{% endblock %}
|
||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||
|
||||
{% block og_type %}website{% endblock %}
|
||||
|
||||
@@ -63,7 +63,7 @@ $all = Input::post(); // the whole cleaned $_POST array</code><
|
||||
|
||||
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
|
||||
|
||||
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries (PDO prepared statements). There's no database layer in this framework yet (SQLite groundwork is Backlog — see <code>novaconium/ISSUES.md</code>); when one lands, use prepared statements exclusively. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
||||
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ '{{ }}' }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries. <a href="/admin/docs/database">Lib\Db</a>'s <code>query()</code> method uses PDO prepared statements exclusively for exactly this reason. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
||||
|
||||
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later. See <code>novaconium/pages/admin/password-hash/index.php</code> for the one place this framework does that on purpose.</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user