Mulligan
Some checks failed
CI / build_and_test (push) Has been cancelled

This commit is contained in:
Funky Waddle 2026-09-20 00:13:41 -05:00
parent 78bf007a43
commit 180fda0296
97 changed files with 1140 additions and 19828 deletions

View file

@ -0,0 +1,84 @@
decisions_log:
- question: What language and minimum version should the app target?
decision: PHP 8.4+
rationale: >
8.4 is as far back in PHP versions as we want to support; targeting a recent
minimum keeps the codebase modern instead of carrying compatibility baggage
for older, less-supported PHP versions.
- question: Should the backend use an existing framework (e.g. Laravel, Symfony) or a custom/home-brewed codebase?
decision: Laravel 13.x
rationale: >
Leveraging Laravel 13.x provides robust routing, authentication, service container,
and ecosystem packages that accelerate development and reduce maintenance burden.
- question: Which CSS framework should the UI be built on?
decision: Fomantic UI
rationale: >
Fomantic UI is an actively maintained fork of Semantic UI (which is no longer
maintained), and semantic class names are strongly preferred over
structural/utility class names for CSS.
- question: Which JavaScript approach/framework should the frontend use?
decision: >
Vue — but used specifically via its Components feature only (similar in spirit
to how Svelte components are used), not as a full-fledged SPA framework.
rationale: >
General preference for Vue over React or Angular. Svelte would also have been an
acceptable choice for a components-only approach, but Vue was the one picked.
- question: Which tool should manage PHP dependencies?
decision: Composer
rationale: Composer is the de facto standard package manager for PHP.
- question: >
Is SiteWeaverCMS intended to be self-hosted only for v1, or should the
"Multi-tenancy ready" note under `architecture` mean a hosted/SaaS offering
is also on the roadmap?
decision: >
Self-hosted only. Multi-tenancy has been removed from consideration entirely
(see `architecture.decisions_log`), so there is no hosted/SaaS offering on the
roadmap and no tenant-isolation/billing concerns to capture.
rationale: >
Once multi-tenancy was ruled out as an architectural direction, a hosted/SaaS
offering (which would require it) is ruled out along with it.
- question: >
When the "Automatic Basic Security Audits for Plugins" check fails (eval/exec/
remote code execution detected), what happens — is the ZIP upload rejected
outright, or is the plugin installed with an "Install - Error" status (as
implied by the Plugins List Page in `plugins_architecture`) for an Admin to
review?
decision: >
The upload is rejected outright, not installed with an "Install - Error" status.
Flow: the ZIP is unzipped, files are analyzed; if no security issues are found,
the plugin's migrations run and it is marked installed; if security issues are
found, the unzipped plugin files are immediately deleted and a
PluginSecurityException is thrown. The same audit and rejection flow applies to
Themes (throwing a ThemeSecurityException instead) — see
`plugins_architecture.decisions_log` and `themes_architecture.decisions_log`.
rationale: >
Files must be unzipped before they can be scanned, so a full "Install - Error"
list entry is unnecessary — a failed audit is treated as a hard rejection with
no trace left on disk.
- question: How will dependency injection be implemented in the app?
decision: >
Laravel's built-in service container will be used, following the framework’s
service provider pattern. This provides robust DI, lazy loading, and integration
with Eloquent, routing, and other core services.
rationale: >
Leveraging Laravel's container eliminates the need for a custom DI solution,
reduces boilerplate, and aligns with the ecosystem's conventions.
- question: What license (if any) will SiteWeaverCMS be released under (e.g. MIT, GPL, proprietary)?
decision: >
No public license for now — SiteWeaverCMS stays sole-ownership/proprietary. It
is being used to build client websites, with the clients themselves administering
the sites built for them; a public release/license may be revisited later.
rationale: >
Matches the current usage model (agency-built client sites) rather than a
publicly redistributable product, so there is no need to pick a license yet.
- question: >
Should a target audience/deployment size be stated up front, since it
influences caching, storage backend, and search decisions made later in the
spec?
decision: >
Yes — the initial target audience is self-employed business owners, small
craft makers, and small new bloggers, with room to grow toward larger sites
and capabilities over time.
rationale: >
Gives later caching/storage/search decisions a concrete initial scale to design
against, while leaving room to grow.

View file

@ -0,0 +1,103 @@
decisions_log:
- question: >
Is a dedicated backend_architecture section needed (per the "e.g." placeholder
comment above)?
decision: >
No — a separate backend_architecture section is not needed. Backend detail is
already captured across the domain-specific sections (media_manager_architecture,
plugins_architecture, themes_architecture, auth_requirements), which together
provide sufficient depth on the backend.
rationale: >
Those sections already document the relevant backend concerns for their domain
(storage abstraction, migrations, Service Providers, permissions, etc.), so a
separate umbrella backend_architecture section would just duplicate them.
- question: >
Is a dedicated frontend_architecture section still needed to document how Vue
Components are organized/bundled, and how they integrate with Fomantic UI and
the templating engine?
decision: >
Yes — a `frontend_architecture` section has been added (see below), filled in
with what could be gathered from the rest of this document, with its own
`open_questions` for what couldn't be inferred.
rationale: >
Frontend integration (Vue Components + Fomantic UI + Laravel Blade templates) is enough of its own concern to warrant a dedicated section rather than
being folded into `architecture` or `ui`.
- question: >
Given the previous "Multi-tenancy ready" note, should v1's schema/routing
already carry a tenant identifier (even if unused), or should multi-tenancy be
deferred entirely to a future major version with its own migration path?
decision: >
Multi-tenancy has been removed from consideration altogether — not deferred,
not kept "ready for" — SiteWeaverCMS is a single-site, single-tenant CMS with
no planned migration path toward multi-tenancy. The "Multi-tenancy ready" note
has been removed from `notes` above, and the "Multi-tenancy" glossary term has
been removed.
rationale: >
Avoids carrying unused tenant-identifier columns/routing complexity for a
direction the project will not pursue.
- question: How will plugin and theme loading be handled?
decision: >
Plugins and themes will be loaded through service providers that register their
respective services. The plugin manager and theme manager will handle the
discovery, loading, and unloading of these components at runtime.
rationale: >
Using service providers ensures proper dependency injection for plugins and
themes, while also allowing for lazy loading and easy testing of individual
components.
- question: What approach will be used for routing?
decision: >
Laravel's routing system will handle all application and plugin routes. Plugins
can define their own route files or use Route::group to register routes, leveraging
middleware, named routes, and resource controllers provided by the framework.
rationale: >
Using Laravel’s built-in router provides a well-tested, feature-rich routing
layer that supports middleware, route caching, and easy integration with
authentication guards and API resources.
- question: How will configuration be managed?
decision: >
Configuration will be handled through a service that loads from environment
variables and .env files (if available), with support for runtime updates.
rationale: >
Using environment variables for configuration ensures secure handling of
sensitive data while allowing for easy deployment to different environments.
- question: How will services be registered?
decision: >
Services will be registered using Laravel’s service provider pattern. Core
services go into core service providers, application-level services in
AppServiceProvider, and plugin/theme-specific services into their own
service providers.
rationale: >
This follows Laravel conventions, enabling lazy loading, caching of
bindings, and integration with the framework's lifecycle events.
- question: How will caching be implemented?
decision: >
A combination of in-memory caching (for frequently accessed data) and file-based
caching (for larger content that doesn't need to be loaded on every request).
rationale: >
This hybrid approach ensures good performance for both frequently accessed
data and larger content while maintaining a reasonable memory footprint.
- question: How will security be implemented?
decision: >
Security will be implemented through a combination of input validation,
authentication, authorization, and secure storage practices. Plugins will be
subject to automated security audits during installation.
rationale: >
A layered security approach ensures that multiple attack vectors are covered,
with special attention to preventing code injection and unauthorized access.
- question: >
`in_scope` and `out_of_scope` were both still empty. What is explicitly IN scope
for v1 and what is explicitly OUT of scope for v1?
decision: >
`in_scope` and `out_of_scope` have been populated (see lists above): Page
Builder, Role Based Auth, User Management, Blog plugin, Media Manager,
Theme/Plugin Editor, i18n, Comments, Backup/Restore, and Search are in scope
for v1; multi-tenant/SaaS deployment, a plugin marketplace, and native mobile
apps are out of scope.
rationale: >
Gives a concrete, at-a-glance v1 boundary to plan milestones against instead of
inferring scope from `features_overview.notes`.
- question: What backup strategy is supported in v1?
decision: Manual trigger only via Admin UI, no automatic or scheduled backups.
rationale: >
The feature notes specify that backups are not automated; they must be triggered
manually by an admin action.

View file

@ -0,0 +1,53 @@
decisions_log:
- question: What is the password-reset/forgot-password flow (self-service email link, admin-reset only, or both)?
decision: >
A logged-in user changes their own password from their Profile page (see
"Password Reset" note above); this handles a known/current password change.
rationale: >
Reuses the Profile Management capability already planned for backend users
instead of building a separate flow for a password change.
- question: >
Is there a login rate-limiting/lockout policy (e.g. temporary lockout after N
failed attempts) to complement 2FA?
decision: >
Yes — a temporary 2-hour lockout after 3 failed login attempts, which an Admin
can manually override/unlock (see "Login Lockout Policy" note above).
rationale: >
Slows down brute-force login attempts while giving the Admin an escape hatch
for a legitimately locked-out user.
- question: >
Should the public-facing User (commenter) role require email verification before
their comments are accepted/shown?
decision: >
Email verification is required after account creation, but is not a
precondition for submitting a comment (see "Email Verification" note above).
rationale: >
Confirms the commenter's email address without adding friction/delay to the
act of leaving a comment itself.
- question: Can a site have more than 1 Admin account?
decision: >
Yes — a site can have more than one Admin account (see "Multiple Admin
Accounts" note above).
rationale: >
Matches the intended usage model where the developer holds an Admin account on
every CMS instance alongside a separate Admin account created per client.
- question: >
The Profile-page password change requires being logged in already, so it
doesn't cover a user who has forgotten their password and is locked out. Is a
true "forgot password" recovery path needed (e.g. a self-service emailed
reset link) for that case, or is recovery always Admin-assisted (an Admin
manually resets/unlocks the account)?
decision: >
Yes, a self-service Forgot Password recovery path is needed. It works via an
emailed, tokenized reset link that is valid for 1 hour; the user follows the
link to set a new password directly. Successfully completing a reset also
clears/unlocks any active Login Lockout on that account (see "Login Lockout
Policy" above), since proving ownership of the account's email is treated as
sufficient to lift the lockout.
rationale: >
Covers the case the logged-in Profile-page password change can't handle (a
forgotten password while locked out), without always requiring Admin
involvement. A 1-hour token expiration limits the exposure window for a
leaked/intercepted reset link, and clearing the lockout on successful reset
avoids forcing a legitimate user to wait out the full 2-hour lockout after
already proving account ownership via email.

View file

@ -0,0 +1,16 @@
decisions_log:
- question: How will core components be organized?
decision: >
Core components will be organized under the src/Site/ directory with clear
separation between bootstrap code, router logic, services, and other core
functionality.
rationale: >
This organization provides a clean separation of concerns and makes it easy
to locate specific components within the codebase.
- question: How will service components be structured?
decision: >
Service components will be organized under src/Site/Services/ with each
service having its own class implementing a clear interface.
rationale: >
This structure promotes loose coupling and makes it easy to swap out
implementations for testing or alternative backends.

View file

@ -0,0 +1,54 @@
decisions_log:
- question: >
`notes` here only cover Settings; no entities/columns have been sketched yet for
Users, Roles, Permissions, Pages, Posts, Media, Comments, or Content Versions
(History). Should the core schema be drafted in this section before
MILESTONES.yaml is written, given how many features depend on it?
decision: >
Yes — the core schema has been drafted above (`Database Tables`), covering
Settings, Users, Roles, Permissions, Role_Permission, User_Role, Pages,
Page_Versions, Posts, Post_Versions, Media, and Comments.
rationale: >
Gives MILESTONES.yaml concrete table/column definitions to build migrations
from, instead of inferring the schema from feature descriptions alone.
- question: >
Should a cross-cutting convention for audit/timestamp columns (`created_at`/
`updated_at`, and who created/last edited a record) be documented once entities
are sketched out, so every migration follows the same pattern?
decision: >
Partially applied so far: Pages, Page_Versions, Posts, Post_Versions, and
Comments already follow a `created_at`/`created_by`/`updated_at`/`updated_by`
pattern. Users, Roles, Permissions, Role_Permission, User_Role, and Media do
not yet have any `created_at`/`updated_at` columns (see the new suggestion
below flagging this gap).
rationale: >
Records the convention actually in use today, rather than assuming it was
applied uniformly across every table.
- question: >
Should the suggested `created_at`/`created_by`/`updated_at`/`updated_by`
columns be added to Users, Roles, Permissions, Role_Permission, User_Role, and
Media (see `suggestions` below)?
decision: >
Yes, accepted and applied — Users, Roles, Permissions, Role_Permission, and
User_Role already carried the columns above; Media has now also been given the
same `created_at`/`created_by`/`updated_at`/`updated_by` columns, so every
table in the schema now follows the convention. `User_Totp`, `User_password_reset`,
and `User_email_verification` are purposely missing the `created_by` and `updated_by`
fields, as these are system generated and not tied to specific users.
rationale: >
Closes the last gap (Media) so audit/timestamp tracking is consistent across
every table, matching the convention already used on Pages/Page_Versions/
Posts/Post_Versions/Comments.
- question: What database engine will be used?
decision: >
Support for MySQL/MariaDB through PDO connections.
rationale: >
Supporting multiple engines provides flexibility for different deployment
scenarios while maintaining a consistent interface through PDO.
- question: How will data migrations be handled?
decision: >
Data migrations will be implemented using Laravel Eloquent migrations, with each plugin having
its own migration files.
rationale: >
This approach allows plugins to manage their own database schema changes
while providing a centralized migration management system.

View file

@ -0,0 +1,16 @@
decisions_log:
- question: What are the deployment requirements?
decision: >
The application requires PHP 8.4+, Composer, and a web server (Apache/Nginx).
Database configuration will be handled through environment variables.
rationale: >
These requirements ensure compatibility with modern PHP features while
providing flexibility in hosting environments.
- question: How will environment-specific configuration be handled?
decision: >
In Dev environment, there will be a .env file with credentials for a locally hosted Infisical instance.
In Prod environment, the Infisical credentials will come from Nginx `fastcgi_param` parameters.
All other needed environment variables (Database credentials, maintenance setting, etc) will come from the Infisical instance
rationale: >
This approach allows for easy deployment to different environments while
keeping sensitive data out of version control.

View file

@ -0,0 +1,15 @@
decisions_log:
- question: >
No PSR-4 (or equivalent) autoloading/namespace convention has been documented
for `app/`, `site/plugins/*`, and `site/themes/*`. Should that be captured here
before MILESTONES.yaml is written?
decision: >
Yes. PSR-4 namespace mapping:
- `app` => `SiteWeaver\Core`
- `site/plugins` => `SiteWeaver\Plugins`
- `site/themes` => `SiteWeaver\Themes`
- `site/admin_themes` => `SiteWeaver\AdminThemes`
- `tests` => `SiteWeaver\Tests`
rationale: >
Gives every directory tree a predictable, standard PSR-4 namespace so
Composer autoloading and MILESTONES.yaml file paths can be derived consistently.

View file

@ -0,0 +1,27 @@
decisions_log:
- question: How should plugins be installable by end users?
decision: >
Plugins are installable via ZIP file upload to the system.
rationale: >
ZIP upload keeps module installation accessible to admins without CLI or
Composer access.
- question: >
The `features` list itself was empty while all the actual feature content lived
in `notes`. Should `features` be populated with a concise catalog for
at-a-glance scanning?
decision: >
Yes — `features` has been populated. User Management and Role Based Auth
Management were also added to the list above, since they were documented in
`app.notes`/`auth_requirements`/`scope.in_scope` but missing from this catalog.
rationale: >
Keeps `features` a complete, scannable catalog rather than a partial list that
only covers items that happened to also get `notes`.
- question: >
Akismet was the only spam-protection measure named for the Comments System;
should a fallback be added in case Akismet is unavailable or insufficient?
decision: >
Yes — honeypot and rate-limiting have been added alongside Akismet (see
`notes.Comments System` above).
rationale: >
Layers a bot-trap and a submission-frequency cap on top of the third-party
Akismet service so spam protection doesn't have a single point of failure.

View file

@ -0,0 +1,38 @@
decisions_log:
- question: >
Which build tool bundles the Vue Components and produces the `public/build`
output (e.g. Vite, Webpack, esbuild)?
decision: Vite
rationale: >
Chosen as the build tool despite being new to the author, as a deliberate
opportunity to learn it.
- question: >
How are Vue components mounted into PHP-rendered templates, and how is data
passed from PHP to a mounted component?
decision: >
Components are referenced directly in server-rendered markup as a custom HTML
element tag matching the component name (e.g. `<AlertBox message="Something
went wrong">`); data is passed in as HTML attributes, which Vue exposes to the
component's script block as props.
rationale: >
Matches Vue's own documented Single-File Component usage pattern, keeping the
PHP-side markup declarative rather than needing a separate bootstrap script to
wire up mount points and data.
- question: >
Is there a single global Vue application instance mounting multiple components
per page, or does each interactive widget get its own independent Vue instance?
decision: Each interactive widget gets its own independent Vue instance.
rationale: >
Keeps each widget (Theme/Plugin Editor, Focal Point Selector, Faceted Search
filter panel, etc.) self-contained rather than coordinated through one shared
page-level app instance.
- question: >
Will Vue Components be authored as Single-File Components (`.vue` files needing
a build step) or as plain JS component definitions, and where do their source
files live?
decision: >
Single-File Components (`.vue` files), living under `assets/js/components`
within each theme.
rationale: >
Keeps component source files colocated with the theme that uses them, following
Vue's standard SFC authoring approach.

View file

@ -0,0 +1 @@
decisions_log: []

View file

@ -0,0 +1,21 @@
decisions_log:
- question: >
Which image library backs JIT Generation, Glide or Intervention Image? (Same
question as `tech_stack`; flagging here too since this is where the
"Glide/Intervention" note actually lived.)
decision: Glide
rationale: >
Same decision/rationale as logged in `tech_stack.decisions_log`; kept in sync
across both sections.
- question: >
The glossary's "Storage Interface / Storage Abstraction" term lists GCP as an
example backend, but this section's "Storage Abstractions" list only names Local
Disk, S3, Nextcloud, and Linode Object-Storage. Should GCP be added to the
official supported-backends list here, or removed from the glossary example to
keep the two in sync?
decision: >
GCP has been removed from the glossary example (see `glossary`); it will not be
a supported storage backend at this time.
rationale: >
Keeps the glossary example list in sync with the actual supported backends
named here, rather than implying GCP support that doesn't exist.

View file

@ -0,0 +1,15 @@
decisions_log:
- question: How will caching be implemented?
decision: >
A hybrid caching approach using in-memory caches for frequently accessed data
and file-based caching for larger content.
rationale: >
This approach balances memory usage with performance while maintaining
reasonable disk space requirements.
- question: How will database queries be optimized?
decision: >
Database queries will use prepared statements, proper indexing, and lazy loading
of related data to reduce unnecessary overhead.
rationale: >
These practices ensure efficient database operations while maintaining
security against SQL injection attacks.

View file

@ -0,0 +1,58 @@
decisions_log:
- question: Should Composer-based installation of plugins be supported alongside ZIP upload?
decision: >
No — Composer installation of plugins has been removed, at least for v1. The
only installation process now is ZIP file upload.
rationale: >
Keeps a single, admin-friendly install path consistent with the "even a non-tech
person finds inviting" goal in `app`; CLI/Composer access isn't assumed to be
available to site admins.
- question: >
What is the resolution path when the Automatic Basic Security Audit rejects a
plugin — is the upload blocked outright, or installed with an "Install - Error"
status (as implied by the Plugins List Page) for the Admin to inspect further?
decision: >
The upload is blocked outright. Since the upload has to finish before the
system can unzip and inspect its contents, the flow is: the ZIP is unzipped,
files are analyzed; if no security issues are found, the plugin's migration
files run and the plugin is marked installed; if security issues are found,
the unzipped plugin files are immediately deleted and a PluginSecurityException
is thrown. There is no "Install - Error" status resulting from a failed
security audit — that status is reserved for other install-time errors (e.g. a
migration failure).
rationale: >
A plugin that fails the security audit should leave nothing installed or
listed; only errors unrelated to security (e.g. a broken migration) warrant
surfacing an "Install - Error" entry for the Admin to inspect.
- question: >
Is there a plugin dependency/versioning mechanism (e.g. a required core version,
or dependencies between plugins) that needs to be declared in `plugin.md`?
decision: >
Yes — a Core Version dependency is declared in `plugin.md` (added to its
required metadata list in `directory_structure`), mirroring the same field
already present in `theme.md`/`admin.theme.md`.
rationale: >
Lets the install process reject a plugin that requires a newer core version
than what's installed, consistent with how themes already declare a Core
Version.
- question: What approach will be used for plugin installation?
decision: >
Plugins will be installed as ZIP files that are extracted to a plugins directory,
with the plugin's service provider registered automatically.
rationale: >
This approach provides a simple and familiar installation process for users
while ensuring proper service registration through the DI container.
- question: How will plugin dependencies be handled?
decision: >
Plugins can declare their dependencies in their metadata files. The plugin
manager will handle loading plugins in the correct order based on dependencies.
rationale: >
This ensures that required services are available when a plugin is loaded,
preventing runtime errors.
- question: How will plugin configuration be handled?
decision: >
Each plugin can register its own configuration settings, which will be
automatically merged with the main configuration system.
rationale: >
This allows plugins to maintain their own settings while integrating cleanly
with the core configuration system.

View file

@ -0,0 +1,13 @@
decisions_log:
- question: What authentication method will be used?
decision: >
Role-based authentication with support for TOTP-based 2FA.
rationale: >
This provides a good balance between security and usability while supporting
enterprise-level security requirements.
- question: How will user permissions be handled?
decision: >
A granular permission system based on roles and capabilities.
rationale: >
This allows for precise control over what actions users can perform while
maintaining a simple and understandable permission model.

View file

@ -0,0 +1,27 @@
decisions_log:
- question: >
Which image processing library will back JIT Generation — Glide or Intervention
Image?
decision: Glide
rationale: >
Chosen over Intervention Image as the JIT-generation backend (see
`media_manager_architecture.decisions_log` for the same decision applied there).
- question: >
Should Composer dependency versions be pinned to exact versions, or use caret/
tilde ranges, given the "no compatibility baggage" stance already taken on the
minimum PHP version?
decision: >
Use caret/tilde ranges, but make sure the minimums are set appropriately.
rationale: >
Caret/tilde ranges allow non-breaking dependency updates while still letting the
minimum-version floor for each package be set deliberately.
- question: >
Add a static analysis tool and a code-style tool to the stack to support the
"Quality First" pillar, since none was listed.
decision: >
Added `backend_static_analysis` (PHPStan for general code quality, Psalm for
code security — both are used, for different purposes) and
`backend_code_style_tool` (PHP CS Fixer) above.
rationale: >
PHPStan and Psalm catch different classes of issues (general type/quality bugs
vs. security-focused taint analysis), so both are kept rather than picking one.

View file

@ -0,0 +1,7 @@
decisions_log:
- question: |
Is there a target CI provider/pipeline where the Codeception suites (behavioral/
feature/integration/unit) will run automatically, and at what stage (on every
PR, pre-merge, nightly)?
decision: >
The repo will be stored on a Forgejo installation. Forgejo Actions will be the target CI provider.

View file

@ -0,0 +1,39 @@
decisions_log:
- question: >
Should the same Automatic Basic Security Audit that applies to Plugins also
apply to uploaded Themes and to any file saved via the in-place Theme Editor,
given that `theme.md`/`layout.php`/partials can contain arbitrary PHP?
decision: >
Yes — the same security audit used for Plugins (no eval, no exec, no remote
code execution) also applies to Themes, both on ZIP upload and on files saved
via the in-place Theme Editor. The only difference from the plugin flow is that
a failed audit throws a `ThemeSecurityException` instead of a
`PluginSecurityException`.
rationale: >
Themes can contain just as much arbitrary PHP as Plugins (`theme.md`,
`layout.php`, partials), so leaving them unaudited would be an inconsistent
security gap.
- question: >
Should theme files be versioned/backed up before each save through the Theme
Editor, so an accidental bad edit can be reverted without needing the full Site
Restore feature?
decision: >
Yes — versioning is implemented on each Theme Editor save (see the "Theme
Editor File Versioning" note above).
rationale: >
Gives a lightweight, per-file undo path for editor mistakes instead of forcing
a full Site Restore for a single bad save.
- question: How will theme assets be handled?
decision: >
Theme assets (CSS, JS, images) will be served directly by the web server,
with support for versioning to prevent caching issues.
rationale: >
Direct serving of assets provides better performance than PHP-based serving
while still allowing for proper version management.
- question: How will theme customization be implemented?
decision: >
Themes will include a built-in editor that allows direct modification of
template files through the admin interface.
rationale: >
This provides an easy way for users to customize their themes without needing
to modify files directly on the server.

View file

@ -0,0 +1,15 @@
decisions_log:
- question: >
Only the Theme/Plugin Editor's layout is documented here. Should the Admin
dashboard, Media Manager, Page Builder, and User Management screens each get
their own UI notes before milestones are planned, or will that detail be worked
out milestone-by-milestone?
decision: >
The Admin dashboard, Media Manager, User Management, Role Management, and Page
Builder screens are now documented above alongside the Theme/Plugin Editor (see
`notes` above), so no further per-screen UI documentation gap remains at this
time.
rationale: >
Rounds out `ui.notes` so every major screen named in `features_overview` has at
least a first-pass layout description to plan milestones against; any gaps
found later can be raised as new open_questions as they're discovered.

View file

@ -42,9 +42,14 @@ When raw ideas are shared at the start of a project, review them for gaps, ambig
Organize top-level keys by feature/domain area (e.g. `app`, `glossary`, `scope`, `features_overview`, per-domain `*_architecture`/`*_requirements` sections, `ui`, `data_model`, `testing`, etc. — adapt the exact set of sections to the project at hand). Within any section, use these conventions to carry the ideation trail: Organize top-level keys by feature/domain area (e.g. `app`, `glossary`, `scope`, `features_overview`, per-domain `*_architecture`/`*_requirements` sections, `ui`, `data_model`, `testing`, etc. — adapt the exact set of sections to the project at hand). Within any section, use these conventions to carry the ideation trail:
- `notes`: free-form rationale, considerations, examples — plain prose or a short list. - `notes`: free-form rationale, considerations, examples — plain prose or a short list.
- `decisions_log`: an append-only list of resolved questions, in the shape `{question: ..., decision: ..., rationale: ...}`, capturing what was asked, what was decided, and why — so the "why" behind a choice is never lost. - `decisions_log_ref`: a relative path to the decisions log file (see `Decisions Logs` section below)
- `open_questions`: a structured list of items still to be clarified with the user (empty list when none remain); each entry should name the question and, if relevant, the options under consideration. - `open_questions`: a structured list of items still to be clarified with the user (empty list when none remain); each entry should name the question and, if relevant, the options under consideration.
### Decisions Logs
Every top level item will contain a decisions log located in `.decisions/{top_level_item_name}_decisions.yaml`.
This decisions log is an append-only list of resolved questions, in the shape `{question: ..., decision: ..., rationale: ...}`, capturing what was asked, what was decided, and why — so the "why" behind a choice is never lost.
### MILESTONES.yaml structure ### MILESTONES.yaml structure
Write `MILESTONES.yaml` as an ordered YAML structure (e.g. a top-level `milestones:` list, each entry keyed by number/id with `goal`, `steps`, `details`, and `tests` keys). Write `MILESTONES.yaml` as an ordered YAML structure (e.g. a top-level `milestones:` list, each entry keyed by number/id with `goal`, `steps`, `details`, and `tests` keys).
@ -65,10 +70,11 @@ A capability may be:
The milestone must include all work necessary to make that capability function as a coherent whole, but no work belonging to another capability. The milestone must include all work necessary to make that capability function as a coherent whole, but no work belonging to another capability.
Milestones should be written in an order that produces the least amount of work overall. If Milestone ABC relies on work from Milestone DEF, don't build Milestone ABC with stubs, and push Milestone DEF to later (with replacing the stubs with the real thing). Order the milestones so that Milestone DEF comes first.
#### Example Milestone Schema #### Example Milestone Schema
```yaml ```yaml
milestones: milestones:
# ---- Milestone 0: Project Foundation & Setup ----
milestone_{id}_{name}: milestone_{id}_{name}:
status: [TODO|IN_PROGRESS|COMPLETE] status: [TODO|IN_PROGRESS|COMPLETE]
description: > description: >

1410
APP.yaml

File diff suppressed because it is too large Load diff

View file

@ -1,394 +0,0 @@
milestones:
milestone_0_project_foundation:
status: COMPLETE
description: >-
Set up the project structure, Composer dependencies, autoloading,
and basic bootstrap files. This establishes the base for all subsequent
development.
what_is_built:
- Directory layout (src/, config/, public/, site/, tests/)
- composer.json with required packages (composer.lock will be generated)
- Basic bootstrap script (config/bootstrap.php) initializing autoloader,
DI container, and environment variables.
- PSR-4 autoloading mapping for core, plugins, themes, admin_themes.
tests_to_run:
- Run Composer install validation test
- Verify autoloaded classes can be instantiated
artifacts:
created:
- composer.json (already present)
- src/Site/
- config/bootstrap.php
- vendor/ (via Composer)
- .gitignore entries for vendor, storage/, etc.
modified: []
deleted: []
completion_criteria:
- All files listed above exist and are syntactically correct
- composer install succeeds without errors
- Autoloader loads core namespace classes (SiteWeaver\\Core)
- Tests in this milestone pass
milestone_1_configuration:
status: COMPLETE
description: >-
Implement environment configuration loading, Infisical integration,
and .env handling. This provides secure runtime secrets and
environment-specific settings for the application.
what_is_built:
- Config class to load from env variables and Infisical SDK
- Integration with Nginx-provided env vars (INFISICAL_URL, etc.)
- Example config files: config/.env.example
- Secret retrieval logic using infisical/php-sdk
tests_to_run:
- Unit test for Config class reading env vars
- Integration test that a sample secret is fetched correctly
artifacts:
created:
- src/Site/Core/Config.php
- config/.env.example
- composer.json entry for infisical/php-sdk (if not already)
modified:
- config/bootstrap.php to load Config
deleted: []
completion_criteria:
- Config can read required secrets from env and Infisical
- Secrets are available to services via DI container
- Tests pass
milestone_2_core_infrastructure:
status: COMPLETE
description: >-
Build the custom routing engine, lightweight dependency injection container,
and service provider pattern that will be used throughout the application.
what_is_built:
- core/router/engine (custom router with middleware support)
- core/container (simple DI container)
- core/services/providers (ServiceProvider base class)
- Integration of these components in bootstrap
tests_to_run:
- Unit test for routing variable parsing and named routes
- Unit test for DI container resolution and singleton scopes
- Feature test for middleware execution order
artifacts:
created:
- src/Site/Core/Router.php
- src/Site/Core/Container.php
- src/Site/Core/ServiceProvider.php
- tests/unit/Core/
modified:
- config/bootstrap.php to register container and router
deleted: []
completion_criteria:
- Router correctly matches routes, supports middleware, named URLs
- DI container resolves dependencies and injects services
- Service providers can register routes, migrations, etc.
- Tests pass
milestone_3_authentication_authorization:
status: COMPLETE
description: >-
Implement user management, roles, permissions, login, logout, session handling,
and TOTP-based two‑factor authentication. This forms the security backbone
of the CMS.
what_is_built:
- User model, Role model, Permission model, RolePermission pivot, UserRole pivot
- AuthService (login/logout, password hashing)
- SessionService (server‑side session storage)
- TOTPService for 2FA
- Middleware: auth, role, permission checks
tests_to_run:
- Unit test for password hash and verification
- Integration test for login flow with TOTP
- Feature test for access control on admin routes
artifacts:
created:
- src/Site/Core/Models/User.php
- src/Site/Core/Services/AuthService.php
- src/Site/Core/Services/TOTPService.php
- src/Site/Core/Middleware/AuthMiddleware.php
- tests/unit/Authentication/
modified:
- config/bootstrap.php to register AuthService, SessionService, TOTPService
deleted: []
completion_criteria:
- Users can create accounts, login with password and 2FA
- Sessions persist across requests
- Role & permission checks enforce access control
- Tests pass
milestone_4_database_and_repositories:
status: TODO
description: >-
Create the core database schema defined in APP.yaml, write Phinx migrations,
and implement repository interfaces that replace the current in‑memory arrays.
All services (AuthService, SessionService, TOTPService) will use these
repositories. The migrations are already present in `database/migrations` – we
just need to verify them and ensure they run cleanly on all supported engines.
what_is_built:
- src/Site/Core/Database/Migrations/* # existing migration files
- src/Site/Core/Repositories/UserRepository.php
- src/Site/Core/Repositories/RoleRepository.php
- src/Site/Core/Repositories/PageRepository.php
- src/Site/Core/Providers/AuthServiceProvider.php # now injects UserRepository, RoleRepository, etc.
tests_to_run:
- Unit test that migrations run cleanly on SQLite/MySQL/PostgreSQL
- Integration test that AuthService can load users via the repository
- Feature test that creating a user persists to the DB and survives a restart
milestone_5_admin_dashboard_skeleton:
status: TODO
description: >-
Provide a basic admin interface with navigation, dashboard views for users,
roles, pages, posts, media, and plugins. This gives admins an entry point
to manage the CMS.
what_is_built:
- Admin layout template (layout.php)
- Dashboard controller & view templates
- Basic CRUD controllers for Users, Roles, Pages, Posts, Media, Plugins, Themes
- Routing entries for admin routes
tests_to_run:
- Feature test for accessing dashboard as Admin
- Unit test for admin route middleware enforcement
artifacts:
created:
- src/Site/Controllers/Admin/DashboardController.php
- resources/views/admin/dashboard.php
- config/routes/admin.php
- tests/feature/AdminDashboard/
modified:
- config/bootstrap.php to register routes
deleted: []
completion_criteria:
- Admin dashboard loads for users with Admin role
- CRUD pages show appropriate forms and data
- Tests pass
milestone_6_media_manager_core:
status: TODO
description: >-
Implement file upload, storage abstraction (Local Disk, S3, Nextcloud,
Linode), JIT image generation with Glide, preview cache warming, and orphaned
media watcher.
what_is_built:
- StorageInterface definition
- Concrete implementations for each backend
- MediaService handling uploads, metadata, and deletion
- JIT generation service using Glide
tests_to_run:
- Unit test for storage interface methods
- Integration test uploading image to each backend
- Feature test for preview cache warming after publishing a page
artifacts:
created:
- src/Site/Core/StorageInterface.php
- src/Site/Providers/LocalDiskStorageProvider.php
- src/Site/Providers/S3StorageProvider.php
- src/Site/Services/MediaService.php
- src/Site/Services/JITGenerationService.php
modified:
- config/bootstrap.php to register storage providers
deleted: []
completion_criteria:
- Images can be uploaded and retrieved from each backend
- JIT-generated variants are correctly cached
- Orphaned media detection works
- Tests pass
milestone_7_plugin_architecture_core:
status: TODO
description: >-
Build the plugin system that allows ZIP uploads, automatic migrations,
service provider registration, dependency resolution, and security audits.
what_is_built:
- PluginManager to discover, install, activate, deactivate plugins
- SecurityAuditService for evaluating plugins on upload
- Modular migration runner integrated with Phinx
- API for plugin developers: plugin.md metadata schema, ServiceProvider base
tests_to_run:
- Unit test for PluginManager installation flow
- Integration test that a sample plugin's migrations run
milestone_8_theme_architecture_core:
status: TODO
description: >-
Implement theme installation, activation, asset pipeline (Vite), theme editor,
and metadata handling. Themes should be selectable for frontend and admin.
what_is_built:
- ThemeManager to install/uninstall/activate themes
- Asset build step using Vite that outputs CSS/JS to public/build
- ThemeEditor component integration with Vue (SFCs)
- Theme service provider registration
tests_to_run:
- Unit test for ThemeManager activation logic
- Integration test that a theme's assets are compiled and served
- Feature test that switching themes updates the layout
artifacts:
created:
- src/Site/Services/ThemeManager.php
- resources/themes/default (sample)
- config/routes/theme.php
- tests/unit/Themes/
modified:
- config/bootstrap.php to register ThemeManager
deleted: []
completion_criteria:
- Themes can be installed via ZIP, activated, and provide assets
- Switching themes updates frontend/admin rendering
- Tests pass
milestone_9_page_builder_core:
status: TODO
description: >-
Provide CRUD for pages, versioning, publishing workflow, navigation inclusion,
and a rich text editor (clean HTML output). Pages are stored with locale‑specific
JSON content.
what_is_built:
- Page model, PageVersion model
- PageController handling create/edit/delete/publish
- WYSIWYG editor integration (e.g., TinyMCE or custom)
- NavigationService to generate site navigation based on pages
tests_to_run:
- Unit test for page versioning logic
- Integration test that a published page appears at its slug URL
- Feature test that navigation includes active pages
artifacts:
created:
- src/Site/Models/Page.php
- src/Site/Models/PageVersion.php
- src/Site/Controllers/PageController.php
- resources/views/page_builder/*.php
modified:
- config/routes/frontend.php (add page route)
deleted: []
completion_criteria:
- Pages can be created, edited, versioned, and published
- Published pages are publicly accessible via slug URLs
- Navigation reflects included pages
- Tests pass
milestone_10_blog_plugin_core:
status: TODO
description: >-
Implement the built‑in blog plugin with CRUD for posts, categories, tags,
comments, spam protection (Akismet, honeypot, rate limiting), and translation.
what_is_built:
- BlogPost model, BlogPostVersion, Category, Tag models
- BlogController handling post management
- CommentService with Akismet integration
- SpamProtection middleware
- Admin UI for managing posts/categories/tags/comments
tests_to_run:
- Unit test for comment spam detection logic
- Integration test that a new post is published and accessible
- Feature test that comments can be added and moderated
milestone_11_search_functionality_core:
status: TODO
description: >-
Provide full‑text search for posts and pages, faceted filters (categories,
tags, date ranges), and dynamic filtering UI. Results are paginated.
what_is_built:
- SearchService using database full-text indexes or external engine
- FacetController handling filter submissions
- Views for search results with pagination
tests_to_run:
- Unit test for search query building
- Integration test that searching by keyword returns expected posts/pages
- Feature test that applying facets narrows results correctly
milestone_12_i18n_system_core:
status: TODO
description: >-
Implement translation engine, locale detection from URL, content localization,
and the t() helper for UI strings. Supports multiple languages per site.
what_is_built:
- LocaleService to detect locale via URL or Accept‑Language header
- TranslationService backed by database table translations (or JSON)
- t() helper function exposed globally
- Integration of translation into page and blog content storage (locale columns/JSON)
tests_to_run:
- Unit test for locale detection logic
- Integration test that translated strings appear in UI
- Feature test that switching locale changes displayed content
milestone_13_backup_restore_core:
status: TODO
description: >-
Provide manual backup of site files and database, and restore functionality.
Backups are ZIP archives containing the CMS directory and SQL dump. Restores
replace current data with a chosen backup.
what_is_built:
- BackupService to create zip + DB dump (sqldump)
- RestoreService to unpack archive and import SQL
- Admin UI triggers for backup/restore actions
tests_to_run:
- Unit test for backup file generation
- Integration test that restore restores data correctly
milestone_14_performance_optimization_core:
status: TODO
description: >-
Implement caching strategy (in‑memory + file), lazy loading of related data,
and query optimization. This ensures fast response times for high traffic.
what_is_built:
- CacheService with in-memory (array) fallback and file-based storage
- QueryBuilder extensions to eager load relationships
- Configuration options for cache TTLs
tests_to_run:
- Unit test for caching logic
milestone_15_security_hardening_core:
status: TODO
description: >-
Strengthen application security by enforcing input validation, limiting
file uploads, protecting against XSS/CSRF, and handling lockout policies.
what_is_built:
- InputValidator service for form data
- CSRF token middleware
- File upload size/type checks
- Login attempt tracking & lockout enforcement
tests_to_run:
milestone_16_testing_infrastructure:
status: TODO
description: >-
Set up Codeception test suite, including unit, feature, integration,
and behavior tests. Configure CI to run these tests automatically.
what_is_built:
- codeception.yml configuration
- base TestCase classes
- sample unit & feature tests for core services
- CI workflow file (forgejo-actions.yml)
tests_to_run:
milestone_17_ci_integration:
status: TODO
description: >-
Configure Forgejo Actions to automatically run tests on pull requests,
pre‑merge, and nightly. Ensure the build pipeline covers linting, static analysis,
and test execution.
what_is_built:
- forgejo-actions.yml workflow file
- Dockerfile or CI image with required PHP, Composer, Node (for Vite)
- Linting steps using PHP CS Fixer, PHPStan, Psalm
tests_to_run:
milestone_18_full_verification:
status: TODO
description: >-
Run the complete test suite, perform end‑to‑end verification of all features,
confirm no regressions, and validate deployment readiness.
what_is_built:
- None (verification only)
tests_to_run:
- All unit, feature, integration, behavior tests
- End-to-end smoke tests covering login, page creation, blog post publishing, media upload, etc.
artifacts:
created: []
modified: []
deleted: []
completion_criteria:
- Every test passes without flakiness
- Manual end‑to‑end checks succeed
- Application is ready for release

View file

@ -1,11 +0,0 @@
<?php
/**
* File: _bootstrap.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Bootstrap file for the application.
*/
require __DIR__ . '/vendor/autoload.php';

View file

@ -1,17 +0,0 @@
actor: Tester
paths:
tests: tests
output: tests/_output
data: tests/_data
support: tests/_support
bootstrap: _bootstrap.php
settings:
colors: true
suites:
unit:
actor: UnitTester
path: unit
class_name: UnitSuite
modules:
enabled:
- Asserts

View file

@ -1,53 +0,0 @@
{
"name": "funky/siteweaver-cms",
"type": "project",
"require": {
"php": "^8.4",
"league/glide": "^4.1",
"spomky-labs/otphp": "^11.5",
"aws/aws-sdk-php": "^3.394",
"sabre/dav": "^4.7",
"league/container": "^5.2",
"ezyang/htmlpurifier": "^4.19",
"phpmailer/phpmailer": "^7.1",
"guzzlehttp/guzzle": "^7.8",
"symfony/http-foundation": "^6.3",
"infisical/php-sdk": "^0.0.2",
"ext-dom": "*",
"ext-pdo": "*"
},
"require-dev": {
"codeception/codeception": "^5.3",
"phpstan/phpstan": "^2.2",
"vimeo/psalm": "^6.16",
"friendsofphp/php-cs-fixer": "^3.95",
"robmorgan/phinx": "^0.16.12",
"codeception/module-asserts": "^3.3",
"codeception/module-phpbrowser": "*",
"vlucas/phpdotenv": "^5.7"
},
"autoload": {
"psr-4": {
"SiteWeaver\\": "src/Site/",
"SiteWeaver\\Plugins\\": "site/plugins/",
"SiteWeaver\\Themes\\": "site/themes/",
"SiteWeaver\\AdminThemes\\": "site/admin_themes/",
"Intervention\\": "src/Intervention/"
},
"classmap": []
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"migrate:up": "php vendor/bin/phinx:migrations:up",
"migrate:down": "php vendor/bin/phinx:migrations:down",
"migrate:status": "php vendor/bin/phinx:migrations:status",
"lint": "php vendor/bin/phpstan-analyze src/",
"psalm": "php vendor/bin/phpslam src/",
"test": "php vendor/bin/codecept run",
"style": "php vendor/bin/php-cs-fixer fix --dry-run src/ && php vendor/bin/php-cs-fixer validate src/"
}
}

10543
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +0,0 @@
# Example .env file for SiteWeaverCMS
#
# Required Infisical integration variables (set by your Nginx config or deployment environment)
INFISICAL_URL="https://your.infisical.instance"
INFISICAL_ENV="dev"
INFISICAL_PROJECT_ID="YOUR_PROJECT_ID"
# Optional client credentials for machine authentication
INFISICAL_CLIENT_ID="YOUR_CLIENT_ID"
INFISICAL_CLIENT_SECRET="YOUR_CLIENT_SECRET"
# Other application-specific environment variables can be added below
# e.g., DATABASE_URL, APP_ENV, etc.

View file

@ -1,39 +0,0 @@
<?php
/**
* File: config/bootstrap.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Bootstrap configuration and DI container.
*/
declare(strict_types=1);
use SiteWeaver\Core\Container;
use SiteWeaver\Core\Providers\ConfigServiceProvider;
use SiteWeaver\Core\Providers\DatabaseServiceProvider;
use SiteWeaver\Core\Providers\Repositories\PermissionRepositoryServiceProvider;
use SiteWeaver\Core\Providers\Repositories\RoleRepositoryServiceProvider;
use SiteWeaver\Core\Providers\Repositories\UserRepositoryServiceProvider;
use SiteWeaver\Core\Providers\SessionServiceProvider;
use SiteWeaver\Core\Providers\TOTPServiceProvider;
use SiteWeaver\Core\Providers\AuthServiceProvider;
use SiteWeaver\Core\Providers\RouterServiceProvider;
require __DIR__ . '/../vendor/autoload.php';
$container = new Container();
// Register providers in dependency order
$container->addServiceProvider(new ConfigServiceProvider($container));
$container->addServiceProvider(new DatabaseServiceProvider($container));
$container->addServiceProvider(new UserRepositoryServiceProvider($container));
$container->addServiceProvider(new RoleRepositoryServiceProvider($container));
$container->addServiceProvider(new PermissionRepositoryServiceProvider($container));
$container->addServiceProvider(new SessionServiceProvider($container));
$container->addServiceProvider(new TOTPServiceProvider($container));
$container->addServiceProvider(new AuthServiceProvider($container));
$container->addServiceProvider(new RouterServiceProvider($container));
return $container;

View file

@ -1,50 +0,0 @@
<?php
/**
* 20260904071635_users.php
*
* Author: Funky Waddle
* Date: 2026-09-04
*
* Purpose: Migration that creates the `users` table storing user account records.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class Users extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('users');
$table->addColumn('username', 'string', ['limit' => 255, 'null'=> false])
->addColumn('password', 'string', ['limit' => 255, 'null' => false])
->addColumn('f_name', 'string', ['limit' => 255, 'null' => false])
->addColumn('l_name', 'string', ['limit' => 255, 'null' => true])
->addColumn('email', 'string', ['limit' => 255, 'null' => false])
->addColumn('avatar', 'string', ['limit' => 255, 'null' => true])
->addColumn('failed_login_attempts', 'integer', ['null' => false, 'default' => 0])
->addColumn('locked_until', 'datetime', ['null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->addIndex('username', ['unique' => true])
->addIndex('email', ['unique' => true])
->create();
}
}

View file

@ -1,42 +0,0 @@
<?php
/**
* 20260904080444_site_settings.php
*
* Author: Funky Waddle
* Date: 2026-09-04
*
* Purpose: Migration that creates the `site_settings` table storing global site configuration key/value pairs.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class SiteSettings extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('site_settings');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('setting', 'string', ['limit' => 255, 'null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,43 +0,0 @@
<?php
/**
* 20260905005507_roles.php
*
* Author: Funky Waddle
* Date: 2026-09-05
*
* Purpose: Migration that creates the `roles` table storing user role definitions.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class Roles extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('roles');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('description', 'string', ['null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->addIndex('name', ['unique' => true])
->create();
}
}

View file

@ -1,40 +0,0 @@
<?php
/**
* 20260905050041_user_totp.php
*
* Author: Funky Waddle
* Date: 2026-09-05
*
* Purpose: Migration that creates the `user_totp` table storing TOTP secrets for two-factor authentication.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class UserTotp extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('user_totp');
$table->addColumn('user_id', 'integer', ['null'=> false])
->addColumn('secret', 'string', ['limit' => 32, 'null' => false])
->addColumn('enabled', 'boolean', ['null' => false, 'default' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('updated_at', 'datetime', ['null' => true])
->addForeignKey('user_id', 'users', 'id')
->create();
}
}

View file

@ -1,40 +0,0 @@
<?php
/**
* 20260905051452_user_password_reset.php
*
* Author: Funky Waddle
* Date: 2026-09-05
*
* Purpose: Migration that creates the `user_password_reset` table storing password reset tokens.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class UserPasswordReset extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('user_password_reset');
$table->addColumn('user_id', 'integer', ['null'=> false])
->addColumn('token', 'string', ['limit' => 42, 'null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('expires_at', 'datetime', ['null' => false])
->addForeignKey('user_id', 'users', 'id')
->addIndex('token', ["unique" => true])
->create();
}
}

View file

@ -1,40 +0,0 @@
<?php
/**
* 20260905231800_user_email_verification.php
*
* Author: Funky Waddle
* Date: 2026-09-05
*
* Purpose: Migration that creates the `user_email_verification` table storing email verification tokens.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class UserEmailVerification extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('user_email_verification');
$table->addColumn('user_id', 'integer', ['null'=> false])
->addColumn('token', 'string', ['limit' => 42, 'null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('expires_at', 'datetime', ['null' => false])
->addForeignKey('user_id', 'users', 'id')
->addIndex('token', ["unique" => true])
->create();
}
}

View file

@ -1,45 +0,0 @@
<?php
/**
* 20260906020100_permissions.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `permissions` table storing granular permission definitions.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class Permissions extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('permissions');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('type', 'string', ['limit' => 255, 'null' => true])
->addColumn('action', 'string', ['limit' => 255, 'null' => true])
->addColumn('description', 'string', ['limit' => 255, 'null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->addIndex('name', ['unique' => true])
->create();
}
}

View file

@ -1,44 +0,0 @@
<?php
/**
* 20260906020200_role_permission.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `role_permission` junction table linking roles to permissions.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RolePermission extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('role_permission', ['id' => false, 'primary_key' => ['role_id', 'permission_id']]);
$table->addColumn('role_id', 'integer', ['null'=> false])
->addColumn('permission_id', 'integer', ['null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('role_id', 'roles', 'id')
->addForeignKey('permission_id', 'permissions', 'id')
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,44 +0,0 @@
<?php
/**
* 20260906020300_user_role.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `user_role` junction table linking users to roles.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class UserRole extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('user_role', ['id' => false, 'primary_key' => ['user_id', 'role_id']]);
$table->addColumn('user_id', 'integer', ['null'=> false])
->addColumn('role_id', 'integer', ['null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('user_id', 'users', 'id')
->addForeignKey('role_id', 'roles', 'id')
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,44 +0,0 @@
<?php
/**
* 20260906020400_pages.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `pages` table storing CMS page records.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class Pages extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('pages');
$table->addColumn('slug', 'string', ['limit' => 255, 'null'=> true])
->addColumn('title', 'string', ['limit' => 255, 'null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addColumn('published_at', 'datetime', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->addIndex('slug', ['unique' => true])
->create();
}
}

View file

@ -1,45 +0,0 @@
<?php
/**
* 20260906020500_page_versions.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `page_versions` table storing versioned content revisions of pages.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class PageVersions extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('page_versions');
$table->addColumn('page_id', 'integer', ['null'=> false])
->addColumn('version_number', 'integer', ['null' => false])
->addColumn('locale', 'string', ['limit' => 3, 'null' => false])
->addColumn('content', 'text', ['null' => false])
->addColumn('meta_json', 'text', ['null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => true])
->addColumn('is_published', 'boolean', ['null' => false, 'default' => false])
->addColumn('published_at', 'datetime', ['null' => true])
->addForeignKey('page_id', 'pages', 'id')
->addForeignKey('created_by', 'users','id')
->create();
}
}

View file

@ -1,42 +0,0 @@
<?php
/**
* 20260906020600_blog_settings.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_settings` table storing blog-specific configuration key/value pairs.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogSettings extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_settings');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('setting', 'string', ['limit' => 255, 'null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,44 +0,0 @@
<?php
/**
* 20260906020700_blog_posts.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_posts` table storing blog post records.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogPosts extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_posts');
$table->addColumn('slug', 'string', ['limit' => 255, 'null'=> true])
->addColumn('title', 'string', ['limit' => 255, 'null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addColumn('published_at', 'datetime', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->addIndex('slug', ['unique' => true])
->create();
}
}

View file

@ -1,46 +0,0 @@
<?php
/**
* 20260906020800_blog_post_versions.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_post_versions` table storing versioned content revisions of blog posts.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogPostVersions extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_post_versions');
$table->addColumn('post_id', 'integer', ['null'=> false])
->addColumn('version_number', 'integer', ['null' => false])
->addColumn('locale', 'string', ['limit' => 3, 'null' => false])
->addColumn('extract', 'text', ['null' => true])
->addColumn('content', 'text', ['null' => false])
->addColumn('meta_json', 'text', ['null' => true])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => true])
->addColumn('is_published', 'boolean', ['null' => false, 'default' => false])
->addColumn('published_at', 'datetime', ['null' => true])
->addForeignKey('post_id', 'blog_posts', 'id')
->addForeignKey('created_by', 'users','id')
->create();
}
}

View file

@ -1,39 +0,0 @@
<?php
/**
* 20260906020900_blog_categories.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_categories` table storing blog category taxonomy.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogCategories extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_categories');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('slug', 'string', ['limit' => 255, 'null' => false])
->addColumn('description', 'text', ['null' => true])
->addIndex('name', ['unique' => true])
->addIndex('slug', ['unique' => true])
->create();
}
}

View file

@ -1,37 +0,0 @@
<?php
/**
* 20260906021000_blog_tags.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_tags` table storing blog tag taxonomy.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogTags extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_tags');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('description', 'text', ['null' => true])
->addIndex('name', ['unique' => true])
->create();
}
}

View file

@ -1,44 +0,0 @@
<?php
/**
* 20260906021100_blog_post_categories.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_post_categories` junction table linking blog posts to categories.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogPostCategories extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_post_categories', ['id' => false, 'primary_key' => ['post_id', 'category_id']]);
$table->addColumn('post_id', 'integer', ['null'=> false])
->addColumn('category_id', 'integer', ['null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('post_id', 'blog_posts', 'id')
->addForeignKey('category_id', 'blog_categories', 'id')
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,44 +0,0 @@
<?php
/**
* 20260906021200_blog_post_tags.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_post_tags` junction table linking blog posts to tags.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogPostTags extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_post_tags', ['id' => false, 'primary_key' => ['post_id', 'tag_id']]);
$table->addColumn('post_id', 'integer', ['null'=> false])
->addColumn('tag_id', 'integer', ['null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('post_id', 'blog_posts', 'id')
->addForeignKey('tag_id', 'blog_tags', 'id')
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,44 +0,0 @@
<?php
/**
* 20260906021300_blog_comments.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `blog_comments` table storing comments on blog posts.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class BlogComments extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('blog_comments');
$table->addColumn('post_id', 'integer', ['null'=> false])
->addColumn('user_id', 'integer', ['null' => false])
->addColumn('content', 'text', ['null' => false])
->addColumn('likely_spam', 'boolean', ['null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('post_id', 'blog_posts', 'id')
->addForeignKey('user_id', 'users', 'id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,46 +0,0 @@
<?php
/**
* 20260906021400_media.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `media` table storing uploaded media file records.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class Media extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('media');
$table->addColumn('filename', 'string', ['limit' => 255, 'null'=> false])
->addColumn('path', 'string', ['limit' => 255, 'null' => false])
->addColumn('storage_backend', 'string', ['limit' => 255, 'null' => false])
->addColumn('mime_type', 'string', ['limit' => 75, 'null' => false])
->addColumn('height', 'integer', ['null' => false])
->addColumn('width', 'integer', ['null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,45 +0,0 @@
<?php
/**
* 20260906021500_media_metadata.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `media_metadata` table storing localized metadata for media files.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class MediaMetadata extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('media_metadata');
$table->addColumn('media_id', 'integer', ['null'=> false])
->addColumn('name', 'string', ['limit' => 255, 'null' => false])
->addColumn('alt', 'text', ['null' => true, 'default' => null])
->addColumn('locale', 'string', ['limit' => 3, 'null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('media_id', 'media', 'id')
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,49 +0,0 @@
<?php
/**
* 20260906021600_plugins.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `plugins` table storing installed plugin records.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class Plugins extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('plugins');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('version', 'string', ['limit' => 10, 'null' => false])
->addColumn('core_version', 'string', ['limit' => 10, 'null' => false])
// Plain string (not Phinx's 'enum' type) - Phinx's SQLite adapter does not
// support the 'enum' column type at all, and this project must support
// SQLite3/MySQL/MariaDB/PostgreSQL uniformly (see `tech_stack.database` in
// APP.yaml). Allowed values ('Active', 'Deactivated', 'Error') are validated
// in the application layer (see `SiteWeaver\Plugin\PluginModel`).
->addColumn('status', 'string', ['limit' => 20, 'null' => false, 'default' => 'Deactivated'])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,42 +0,0 @@
<?php
/**
* 20260906021700_frontend_themes.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `frontend_themes` table storing frontend theme records.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class FrontendThemes extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('frontend_themes');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('enabled', 'boolean', ['null' => false, 'default' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,42 +0,0 @@
<?php
/**
* 20260906021800_admin_themes.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `admin_themes` table storing admin theme records.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AdminThemes extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('admin_themes');
$table->addColumn('name', 'string', ['limit' => 255, 'null'=> false])
->addColumn('enabled', 'boolean', ['null' => false, 'default' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default'=>"CURRENT_TIMESTAMP"])
->addColumn('created_by', 'integer', ['null' => false])
->addColumn('updated_at', 'datetime', ['null' => true])
->addColumn('updated_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users','id')
->addForeignKey('updated_by', 'users','id')
->create();
}
}

View file

@ -1,40 +0,0 @@
<?php
/**
* 20260906030000_pages_add_builder_columns.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Additive migration for the Page Builder screen (`ui.notes` ->
* "Page Builder" in APP.yaml), which needs to store the "Include in Site
* Navigation" checkbox and the custom-content-vs-plugin-output dropdown
* choice. Does not alter any existing column on `pages`.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class PagesAddBuilderColumns extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('pages');
$table->addColumn('include_in_nav', 'boolean', ['null' => false, 'default' => false])
->addColumn('content_type', 'string', ['limit' => 20, 'null' => false, 'default' => 'content', 'comment' => "'content' (WYSIWYG-authored) or 'plugin' (rendered by a plugin's output)"])
->addColumn('plugin_slug', 'string', ['limit' => 255, 'null' => true, 'comment' => 'which active plugin provides the output, when content_type=plugin'])
->update();
}
}

View file

@ -1,43 +0,0 @@
<?php
/**
* 20260906030100_i18n_strings.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `i18n_strings` table backing the
* database-backed i18n Translation Engine's `t('key')` helper (see
* `app.notes` -> "i18n Translation Engine" and `scope_v1_boundaries.i18n_translation_workflow`
* in APP.yaml). Each row is a single UI string translated into a single locale.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class I18nStrings extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('i18n_strings');
$table->addColumn('key', 'string', ['limit' => 255, 'null' => false, 'comment' => "the t('key') lookup key"])
->addColumn('locale', 'string', ['limit' => 10, 'null' => false])
->addColumn('value', 'text', ['null' => false])
->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
->addColumn('updated_at', 'datetime', ['null' => true])
->addIndex(['key', 'locale'], ['unique' => true])
->create();
}
}

View file

@ -1,43 +0,0 @@
<?php
/**
* 20260906040000_media_add_folder_and_focal_point.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Additive migration for the Media Manager (`media_manager_architecture`
* in APP.yaml), which needs folder organization and a Focal Point Selector.
* Adds a nullable `folder` column (folder-organization convention: a simple
* path string, e.g. "products/2026") and `focal_x`/`focal_y` ratio columns
* (0..1, default 0.5/0.5 = center) used by the JIT Image Service to build
* focal-point-aware crop parameters. Does not alter any existing column on
* `media`.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class MediaAddFolderAndFocalPoint extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('media');
$table->addColumn('folder', 'string', ['limit' => 255, 'null' => true, 'default' => null, 'comment' => 'folder organization path, e.g. products/2026'])
->addColumn('focal_x', 'float', ['null' => false, 'default' => 0.5, 'comment' => 'focal point X ratio (0..1) used by JIT focal-crop generation'])
->addColumn('focal_y', 'float', ['null' => false, 'default' => 0.5, 'comment' => 'focal point Y ratio (0..1) used by JIT focal-crop generation'])
->update();
}
}

View file

@ -1,42 +0,0 @@
<?php
/**
* 20260906040100_plugin_migrations.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `plugin_migrations` table, used by the
* custom `PluginMigrationRunner` (`plugins_architecture` -> "Modular DB
* Migrations" in APP.yaml) to track which of a plugin's own migration files
* have already been run, so install/uninstall can run/reverse only the
* migrations belonging to that plugin's `migrations/` folder.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class PluginMigrations extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('plugin_migrations');
$table->addColumn('plugin_name', 'string', ['limit' => 255, 'null' => false])
->addColumn('migration', 'string', ['limit' => 255, 'null' => false])
->addColumn('applied_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
->addIndex(['plugin_name', 'migration'], ['unique' => true])
->create();
}
}

View file

@ -1,46 +0,0 @@
<?php
/**
* 20260906040200_theme_file_versions.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Migration that creates the `theme_file_versions` table backing
* "Theme Editor File Versioning" (`themes_architecture` in APP.yaml): every
* save made through the in-place Theme Editor stores the prior file content
* here first, so an accidental bad edit can be reverted without needing the
* full Site Restore feature.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class ThemeFileVersions extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('theme_file_versions');
$table->addColumn('theme_type', 'string', ['limit' => 20, 'null' => false, 'comment' => "'frontend' or 'admin'"])
->addColumn('theme_name', 'string', ['limit' => 255, 'null' => false])
->addColumn('file_path', 'string', ['limit' => 500, 'null' => false, 'comment' => 'path relative to the theme root'])
->addColumn('content', 'text', ['null' => false, 'comment' => 'file content as it was BEFORE the save that created this version'])
->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
->addColumn('created_by', 'integer', ['null' => true])
->addForeignKey('created_by', 'users', 'id')
->addIndex(['theme_type', 'theme_name', 'file_path'])
->create();
}
}

View file

@ -1,43 +0,0 @@
<?php
/**
* 20260906050000_pages_add_is_home.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Additive migration allowing a Page to be marked as the site's
* "home page" - when a request comes in for the URL root
* (`https://domain.tld/`), that Page is rendered instead of a 404. Only one
* Page may be the home page at a time; that invariant is enforced in
* `PageService`, not at the schema level, since portable partial-unique
* constraints aren't supported consistently across the multi-database
* targets in `tech_stack` (SQLite/MySQL-MariaDB/PostgreSQL). Does not alter
* any existing column on `pages`.
*/
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class PagesAddIsHome extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$table = $this->table('pages');
$table->addColumn('is_home', 'boolean', ['null' => false, 'default' => false, 'comment' => 'Rendered at the URL root (/) when true; only one Page may have this set'])
->addIndex(['is_home'])
->update();
}
}

View file

@ -1,37 +0,0 @@
<?php
/**
* BlogCategoriesSeed.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Seeds the mandatory "Uncategorized" `blog_categories` row (per
* `plugins_architecture.notes` in APP.yaml - "A 'Uncategorized' blog category
* will exist"). Every blog post must belong to at least one category, and
* `BlogPostService` falls back to this one when none is explicitly chosen.
*/
declare(strict_types=1);
use Phinx\Seed\AbstractSeed;
final class BlogCategoriesSeed extends AbstractSeed
{
public function run(): void
{
$exists = $this->fetchRow(
"SELECT id FROM blog_categories WHERE slug = 'uncategorized'",
);
if ($exists) {
return;
}
$this->table('blog_categories')->insert([
'name' => 'Uncategorized',
'slug' => 'uncategorized',
'description' => 'Default category for blog posts that have not been assigned a more specific category.',
])->saveData();
}
}

View file

@ -1,52 +0,0 @@
<?php
/**
* DefaultThemesSeed.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Registers the bundled `SiteWeaver2026` frontend theme in
* `frontend_themes` as the active theme, so a fresh install has a working
* default theme out of the box (per `themes_architecture.notes` -> "Default
* theme (provided by default)..." in APP.yaml). Depends on
* `RolesAndPermissionsSeed` for the primary Admin user id (`created_by` is a
* NOT NULL FK on `frontend_themes`).
*/
declare(strict_types=1);
use Phinx\Seed\AbstractSeed;
final class DefaultThemesSeed extends AbstractSeed
{
public function getDependencies(): array
{
return [RolesAndPermissionsSeed::class];
}
public function run(): void
{
$existing = $this->fetchRow(
"SELECT id FROM frontend_themes WHERE name = 'SiteWeaver2026'",
);
if ($existing !== false) {
return;
}
$admin = $this->fetchRow('SELECT id FROM users ORDER BY id ASC LIMIT 1');
if ($admin === false) {
// RolesAndPermissionsSeed should have already created the
// primary Admin user; nothing to attribute this row to yet.
return;
}
$this->table('frontend_themes')->insert([
'name' => 'SiteWeaver2026',
'enabled' => true,
'created_at' => date('Y-m-d H:i:s'),
'created_by' => (int) $admin['id'],
])->saveData();
}
}

View file

@ -1,240 +0,0 @@
<?php
/**
* RolesAndPermissionsSeed.php
*
* Author: Funky Waddle
* Date: 2026-09-06
*
* Purpose: Seeds the default roles (Admin, Editor, Author, User), the
* baseline permission set, and their `role_permission` assignments, adapted
* from `data_model.example_seed_data_for_roles` in APP.yaml. Since `roles`/
* `permissions`/`users` all carry a NOT NULL `created_by` FK back to `users`,
* this also bootstraps the very first (primary) Admin user account if the
* `users` table is empty - the "primary Admin account" protected from edit/
* delete per `auth_requirements`. Its temporary password should be changed
* immediately after first login.
*/
declare(strict_types=1);
use Phinx\Seed\AbstractSeed;
final class RolesAndPermissionsSeed extends AbstractSeed
{
public function getDependencies(): array
{
return [];
}
public function run(): void
{
$adminUserId = $this->ensurePrimaryAdminUser();
$roleIds = $this->ensureRoles($adminUserId);
$permissionIds = $this->ensurePermissions($adminUserId);
$this->ensureUserHasRole($adminUserId, $roleIds['Admin'], $adminUserId);
$this->assignPermissions($adminUserId, $roleIds, $permissionIds, 'Admin', array_keys($permissionIds));
$this->assignPermissions($adminUserId, $roleIds, $permissionIds, 'Editor', [
'publish:page', 'unpublish:page', 'edit:page', 'delete:page',
'view:draft', 'edit:draft',
'view:blog_post', 'edit:blog_post', 'delete:blog_post', 'publish:blog_post',
'view:media', 'edit:media', 'delete:media',
]);
$this->assignPermissions($adminUserId, $roleIds, $permissionIds, 'Author', [
'publish:page', 'edit:page', 'edit:draft', 'publish:draft',
'view:blog_post', 'edit:blog_post', 'publish:blog_post',
'view:media', 'edit:media',
]);
// 'User' (public commenter role) intentionally gets no backend permissions.
}
private function ensurePrimaryAdminUser(): int
{
$existing = $this->fetchRow('SELECT id FROM users ORDER BY id ASC LIMIT 1');
if ($existing !== false) {
return (int) $existing['id'];
}
$now = date('Y-m-d H:i:s');
// `created_by` is a NOT NULL self-referencing FK; the very first user
// in the system has no other creator, so the row is inserted with a
// placeholder and immediately corrected to reference itself.
$this->execute(
'INSERT INTO users (username, password, f_name, l_name, email, failed_login_attempts, created_at, created_by)
VALUES (?, ?, ?, ?, ?, 0, ?, 0)',
[
'admin',
password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT),
'Site',
'Admin',
'admin@siteweaver.local',
$now,
],
);
$adminUserId = (int) $this->getAdapter()->getConnection()->lastInsertId();
$this->execute('UPDATE users SET created_by = ? WHERE id = ?', [$adminUserId, $adminUserId]);
return $adminUserId;
}
private function ensureUserHasRole(int $userId, int $roleId, int $adminUserId): void
{
$exists = $this->fetchRow("SELECT 1 FROM user_role WHERE user_id = $userId AND role_id = $roleId");
if ($exists !== false) {
return;
}
$this->execute(
'INSERT INTO user_role (user_id, role_id, created_at, created_by) VALUES (?, ?, ?, ?)',
[$userId, $roleId, date('Y-m-d H:i:s'), $adminUserId],
);
}
/**
* @return array<string, int> role name => id
*/
private function ensureRoles(int $adminUserId): array
{
$definitions = [
'Admin' => 'Full access to all CMS features and settings.',
'Editor' => 'Can create, edit, and delete content, but cannot change site settings.',
'Author' => 'Can create and edit their own content; can view drafts.',
'User' => 'Basic frontend read-only access to published content. No backend admin access. Can comment on Blog Posts.',
];
$ids = [];
foreach ($definitions as $name => $description) {
$row = $this->fetchRow("SELECT id FROM roles WHERE name = '" . $name . "'");
if ($row !== false) {
$ids[$name] = (int) $row['id'];
continue;
}
$this->execute(
'INSERT INTO roles (name, description, created_at, created_by) VALUES (?, ?, ?, ?)',
[$name, $description, date('Y-m-d H:i:s'), $adminUserId],
);
$ids[$name] = (int) $this->getAdapter()->getConnection()->lastInsertId();
}
return $ids;
}
/**
* @return array<string, int> permission name => id
*/
private function ensurePermissions(int $adminUserId): array
{
// Common CMS permissions (per `data_model.example_seed_data_for_roles`)
// plus the granular View/Edit/Delete-per-resource permissions called
// for by `auth_requirements`'s "Granular Permissions" note, for every
// resource named there: Page, Plugin, Theme, Blog Post, User.
$definitions = [
['manage:site', null, 'configure', 'Change site-wide settings (e.g., logo, navigation).'],
['publish:page', 'page', 'publish', 'Publish a page to the public site.'],
['unpublish:page', 'page', 'unpublish', 'Remove a page from public view.'],
['edit:page', 'page', 'edit', 'Modify an existing page.'],
['delete:page', 'page', 'delete', 'Permanently delete a page.'],
['view:draft', 'draft', 'view', 'Read unpublished drafts.'],
['edit:draft', 'draft', 'edit', 'Edit an unpublished draft.'],
['publish:draft', 'draft', 'publish', 'Publish a draft to the public site.'],
['manage:user', null, 'admin', 'Create, edit, disable, or delete user accounts.'],
['view:user', 'user', 'view', 'View user accounts.'],
['edit:user', 'user', 'edit', 'Edit user accounts.'],
['delete:user', 'user', 'delete', 'Delete user accounts.'],
['manage:role', null, 'admin', 'Create, edit, or delete roles and their permissions.'],
['view:plugin', 'plugin', 'view', 'View installed plugins.'],
['edit:plugin', 'plugin', 'edit', 'Configure/activate/deactivate plugins.'],
['delete:plugin', 'plugin', 'delete', 'Uninstall/delete plugins.'],
['view:theme', 'theme', 'view', 'View installed themes.'],
['edit:theme', 'theme', 'edit', 'Edit/activate theme files and settings.'],
['delete:theme', 'theme', 'delete', 'Delete themes.'],
['view:blog_post', 'blog_post', 'view', 'View blog posts (including drafts by others, if granted).'],
['edit:blog_post', 'blog_post', 'edit', 'Create/edit blog posts.'],
['delete:blog_post', 'blog_post', 'delete', "Delete blog posts, including the author's own published posts."],
['publish:blog_post', 'blog_post', 'publish', 'Publish a blog post to the public site.'],
['view:media', 'media', 'view', 'View media library items.'],
['edit:media', 'media', 'edit', 'Upload/edit media library items.'],
['delete:media', 'media', 'delete', 'Delete media library items.'],
];
$ids = [];
foreach ($definitions as [$name, $type, $action, $description]) {
$row = $this->fetchRow("SELECT id FROM permissions WHERE name = '" . $name . "'");
if ($row !== false) {
$ids[$name] = (int) $row['id'];
continue;
}
$this->execute(
'INSERT INTO permissions (name, type, action, description, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)',
[$name, $type, $action, $description, date('Y-m-d H:i:s'), $adminUserId],
);
$ids[$name] = (int) $this->getAdapter()->getConnection()->lastInsertId();
}
return $ids;
}
/**
* @param array<string, int> $roleIds
* @param array<string, int> $permissionIds
* @param string[] $permissionNames
*/
private function assignPermissions(
int $adminUserId,
array $roleIds,
array $permissionIds,
string $roleName,
array $permissionNames,
): void {
if (!isset($roleIds[$roleName])) {
return;
}
$roleId = $roleIds[$roleName];
foreach ($permissionNames as $permissionName) {
if (!isset($permissionIds[$permissionName])) {
continue;
}
$permissionId = $permissionIds[$permissionName];
$exists = $this->fetchRow(
"SELECT 1 FROM role_permission WHERE role_id = $roleId AND permission_id = $permissionId",
);
if ($exists !== false) {
continue;
}
$this->execute(
'INSERT INTO role_permission (role_id, permission_id, created_at, created_by) VALUES (?, ?, ?, ?)',
[$roleId, $permissionId, date('Y-m-d H:i:s'), $adminUserId],
);
}
}
}

View file

@ -1,28 +0,0 @@
<?php
/**
* File: public/index.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Front controller entry point that outputs a simple message.
*/
declare(strict_types=1);
/** @var SiteWeaver\Core\Container $container */
$container = require __DIR__ . '/../config/bootstrap.php';
$maintenanceMode = filter_var(
$container->get('config')->get('MAINTENANCE_MODE'),
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE
);
if ($maintenanceMode) {
header('Location: /maintenance.php');
exit; // stop further output
}
// Placeholder: output a simple message.
echo "SiteWeaverCMS running.";

View file

@ -1,57 +0,0 @@
<?php
// maintenance.php - simple maintenance mode page
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Maintenance Mode</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg-color: #f9fafb;
--text-color: #333;
--accent-color: #007bff;
--heading-font: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
margin: 0;
padding: 0;
font-family: var(--heading-font);
background: var(--bg-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.card {
text-align: center;
max-width: 480px;
padding: 2rem;
border-radius: 8px;
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,.1);
}
h1 {
margin-top: 0;
color: var(--accent-color);
}
p {
line-height: 1.5;
}
a {
color: var(--accent-color);
text-decoration: none;
}
</style>
</head>
<body>
<div class="card">
<h1>Site Under Maintenance</h1>
<p>We’re currently performing scheduled maintenance.</p>
<p>Please check back soon.</p>
<p><a href="/">Return to Home</a></p>
</div>
</body>
</html>

View file

@ -1,61 +0,0 @@
<?php
/**
* File: src/Site/Core/Config.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Configuration service that loads environment variables and optionally fetches secrets from Infisical.
*/
declare(strict_types=1);
namespace SiteWeaver\Core;
/**
* Configuration service that loads environment variables and optionally fetches secrets from Infisical.
*/
class Config
{
/** @var array<string, mixed> */
private array $values = [];
public function __construct() {}
public function get(string $key, mixed $default = null): mixed
{
return $this->values[$key] ?? $default;
}
/**
* Explicitly set or overwrite a configuration value.
*
* @param string $key The configuration key.
* @param mixed $value The new value.
*/
public function set(string $key, mixed $value, bool $override = false): void
{
if ($override || !array_key_exists($key, $this->values)) {
// Allow null values to be stored as well.
if ($key !== null) {
$this->values[$key] = $value;
}
}
}
/**
* Return all configuration values.
*/
public function all(): array
{
return $this->values;
}
public function load(array $settings): void {
foreach($settings as $key => $value) {
if ($key !== null && $value !== null && !array_key_exists($key, $this->values)) {
$this->values[$key] = $value;
}
}
}
}

View file

@ -1,38 +0,0 @@
<?php
/**
* File: src/Site/Core/Container.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* A thin wrapper around League\Container\Container to enable future custom
* functionality while preserving the existing API.
*/
declare(strict_types=1);
namespace SiteWeaver\Core;
use League\Container\Container as LeagueContainer;
/**
* Custom DI container that extends League's container.
* It can be extended with additional methods without affecting
* existing code that relies on the League implementation.
*/
class Container extends LeagueContainer
{
/**
* Construct a new custom container.
*
* @param array<string, mixed> $services Optional initial service definitions
*/
public function __construct(array $services = [])
{
parent::__construct();
foreach ($services as $key=>$svc) {
$this->add($key, $svc);
}
}
}

View file

@ -1,78 +0,0 @@
<?php
/**
* File: src/Site/Core/Middleware/AuthMiddleware.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Middleware that ensures a user is authenticated before accessing protected routes.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Middleware;
use SiteWeaver\Core\Services\AuthService;
/**
* Simple authentication middleware.
*/
class AuthMiddleware
{
private AuthService $auth;
public function __construct(AuthService $auth)
{
$this->auth = $auth;
}
/**
* Execute the middleware. Throws RuntimeException if not authenticated.
*/
public function handle(): void
{
if (!$this->auth->isAuthenticated()) {
throw new \RuntimeException('Unauthorized');
}
}
/**
* Optional role check. Throws RuntimeException if the current user does not have the role.
*/
public function authorizeRole(string $roleName): void
{
$user = $this->auth->getCurrentUser();
if (!$user) {
throw new \RuntimeException('Unauthorized');
}
foreach ($user->getRoles() as $role) {
if ($role->name === $roleName) {
return;
}
}
throw new \RuntimeException("Forbidden: missing role {$roleName}");
}
/**
* Optional permission check.
*/
public function authorizePermission(string $permissionName): void
{
$user = $this->auth->getCurrentUser();
if (!$user) {
throw new \RuntimeException('Unauthorized');
}
foreach ($user->getRoles() as $role) {
foreach ($role->getPermissions() as $perm) {
if ($perm->name === $permissionName) {
return;
}
}
}
throw new \RuntimeException("Forbidden: missing permission {$permissionName}");
}
}

View file

@ -1,37 +0,0 @@
<?php
/**
* File: src/Site/Core/Models/Permission.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Permission model representing a single permission string (e.g., 'edit_posts').
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Models;
/**
* Permission entity.
*/
class Permission
{
/** @var int Unique identifier. */
public int $id;
/** @var string Name of the permission. */
public string $name;
/**
* Create a new permission.
*
* @param int $id Unique ID.
* @param string $name Permission name.
*/
public function __construct(int $id, string $name)
{
$this->id = $id;
$this->name = $name;
}
}

View file

@ -1,60 +0,0 @@
<?php
/**
* File: src/Site/Core/Models/Role.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Role model representing a set of permissions assigned to users.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Models;
use SiteWeaver\Core\Models\Permission;
/**
* Role entity containing a name and associated permissions.
*/
class Role
{
/** @var int Unique identifier. */
public int $id;
/** @var string Human‑readable role name (e.g., 'admin'). */
public string $name;
/** @var Permission[] Permissions granted to this role. */
private array $permissions = [];
/**
* Create a new role.
*
* @param int $id Unique ID.
* @param string $name Name of the role.
*/
public function __construct(int $id, string $name)
{
$this->id = $id;
$this->name = $name;
}
/**
* Attach a permission to this role.
*/
public function addPermission(Permission $permission): void
{
$this->permissions[$permission->id] = $permission;
}
/**
* Retrieve all permissions for this role.
*
* @return Permission[]
*/
public function getPermissions(): array
{
return array_values($this->permissions);
}
}

View file

@ -1,121 +0,0 @@
<?php
/**
* File: src/Site/Core/Models/User.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* User model representing a site user, holding credentials and role assignments.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Models;
use SiteWeaver\Core\Models\Role;
/**
* User entity with authentication related data.
*/
class User
{
/** @var int Unique identifier. */
public int $id;
/** @var string Username used for login. */
public string $username;
/** @var string Email address. */
public string $email;
/** @var string Hashed password (bcrypt). */
private string $passwordHash;
/** @var string|null TOTP secret for 2FA, if enabled. */
private ?string $totpSecret = null;
/**
* Set the password hash directly (used when loading from DB).
*/
public function setHashedPassword(string $hash): void
{
$this->passwordHash = $hash;
}
/**
* Get the hashed password.
*/
public function getPasswordHash(): string
{
return $this->passwordHash;
}
/** @var Role[] List of roles assigned to the user. */
private array $roles = [];
/**
* Create a new user instance.
*
* @param int $id Unique ID.
* @param string $username Username.
* @param string $email Email address.
* @param string $password Plain text password; it will be hashed internally.
*/
public function __construct(int $id, string $username, string $email, string $password)
{
$this->id = $id;
$this->username = $username;
$this->email = $email;
$this->setPassword($password);
}
/**
* Verify a plain text password against the stored hash.
*/
public function verifyPassword(string $plain): bool
{
return password_verify($plain, $this->passwordHash);
}
/**
* Set (and hash) a new password.
*/
public function setPassword(string $plain): void
{
$this->passwordHash = password_hash($plain, PASSWORD_DEFAULT);
}
/**
* Enable or update TOTP secret for this user.
*/
public function setTotpSecret(?string $secret): void
{
$this->totpSecret = $secret;
}
/**
* Retrieve the current TOTP secret, if any.
*/
public function getTotpSecret(): ?string
{
return $this->totpSecret;
}
/**
* Assign a role to the user.
*/
public function addRole(Role $role): void
{
$this->roles[$role->id] = $role;
}
/**
* Retrieve all roles for this user.
*
* @return Role[]
*/
public function getRoles(): array
{
return array_values($this->roles);
}
}

View file

@ -1,58 +0,0 @@
<?php
/**
* File: src/Site/Core/Providers/AuthServiceProvider.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Handles instantiating the AuthService and registering it with the global container.
*/
namespace SiteWeaver\Core\Providers;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use SiteWeaver\Core\Config;
use SiteWeaver\Core\Repositories\UserRepository;
use SiteWeaver\Core\ServiceProvider;
use SiteWeaver\Core\Services\AuthService;
use SiteWeaver\Core\Services\SessionService;
use SiteWeaver\Core\Services\TOTPService;
class AuthServiceProvider extends ServiceProvider
{
/**
* @inheritDoc
*/
public function register(): void
{
try {
/** @var Config $config */
$config = $this->getContainer()->get('config');
/** @var SessionService $session */
$session = $this->getContainer()->get('session');
/** @var TOTPService $totp */
$totp = $this->getContainer()->get('totp');
/** @var UserRepository $user_repo */
$user_repo = $this->getContainer()->get('user_repo');
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
throw new \RuntimeException($e->getMessage());
}
$auth = new AuthService($config, $session, $user_repo);
$auth->setTOTPService($totp);
// Register auth as a shared instance
$this->getContainer()->addShared('auth', $auth);
}
public function provides(string $id): bool
{
return $id == 'auth';
}
}

View file

@ -1,71 +0,0 @@
<?php
/**
* File: src/Site/Core/Providers/ConfigServiceProvider.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Service Provider that loads configuration from .env, Infisical, and merges environment variables into the Config service.
*/
namespace SiteWeaver\Core\Providers;
use SiteWeaver\Core\Config;
use SiteWeaver\Core\ServiceProvider;
use Dotenv\Dotenv;
use Infisical\SDK\InfisicalSDK;
use Infisical\SDK\Models\ListSecretsParameters;
class ConfigServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->load_env();
$mergedEnv = $_ENV + $_SERVER;
$config = new Config();
$config->load($mergedEnv);
// Register config as a shared instance
$this->getContainer()->addShared('config', $config);
}
public function provides(string $id): bool
{
return $id == 'config';
}
private function load_env(): void {
// Load .env file if there is one
$dotenv = Dotenv::createImmutable(dirname(__DIR__, 4));
$dotenv->safeLoad();
// Load Infisical vars if set
$url = $_ENV['INFISICAL_URL'] ?? null;
$machine_id = $_ENV['INFISICAL_MACHINE_ID'] ?? null;
$secret_id = $_ENV['INFISICAL_SECRET_ID'] ?? null;
$project_id = $_ENV['INFISICAL_PROJECT_ID'] ?? null;
$env = $_ENV['INFISICAL_ENV'] ?? null;
if (!empty($url)) {
$sdk = new InfisicalSDK($url);
$response = $sdk->auth()->universalAuth()->login($machine_id, $secret_id);
$params = new ListSecretsParameters(
environment: $env,
secretPath: "/",
projectId: $project_id
);
$secrets = $sdk->secrets()->list($params);
foreach ($secrets as $secret) {
putenv("{$secret->secretKey}={$secret->secretValue}");
$_ENV[$secret->secretKey] = $secret->secretValue;
}
}
}
}

View file

@ -1,57 +0,0 @@
<?php
/**
* File: src/Site/Core/Providers/DatabaseServiceProvider.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Registers a shared PDO instance in the container using database credentials from Config.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Providers;
use PDO;
use SiteWeaver\Core\ServiceProvider;
use SiteWeaver\Core\Config;
class DatabaseServiceProvider extends ServiceProvider
{
public function register(): void
{
/** @var Config $config */
$config = $this->getContainer()->get('config');
// Build DSN from config values (default to MySQL)
$driver = $config->get('DB_DRIVER', 'mysql');
$host = $config->get('DB_HOST', 'localhost');
$database = $config->get('DB_NAME', 'siteweaver');
$port = $config->get('DB_PORT', '3306');
$charset = $config->get('DB_CHARSET', 'utf8mb4');
// Build DSN string
if ($driver === 'sqlite') {
// For SQLite, the host is the file path (or :memory:)
$dsn = sprintf('sqlite:%s', $database);
} else {
$dsn = sprintf('%s:host=%s;port=%s;dbname=%s;charset=%s', $driver, $port, $host, $database, $charset);
}
// Credentials
$username = $config->get('DB_USER');
$password = $config->get('DB_PASSWORD');
// Create PDO instance with error mode exception
$pdo = new PDO($dsn, $username, $password ?? '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Register as shared service
$this->getContainer()->addShared('db', $pdo);
}
public function provides(string $id): bool
{
return $id == 'db';
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace SiteWeaver\Core\Providers\Repositories;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use SiteWeaver\Core\Repositories\PermissionRepository;
use SiteWeaver\Core\ServiceProvider;
class PermissionRepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
try {
$db = $this->getContainer()->get('db');
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
throw new \RuntimeException($e->getMessage());
}
$permission_repo = new PermissionRepository($db);
$this->getContainer()->addShared('permission_repo', $permission_repo);
}
public function provides(string $id): bool
{
return $id == 'permission_repo';
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace SiteWeaver\Core\Providers\Repositories;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use SiteWeaver\Core\Repositories\RoleRepository;
use SiteWeaver\Core\ServiceProvider;
class RoleRepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
try {
$db = $this->getContainer()->get('db');
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
throw new \RuntimeException($e->getMessage());
}
$role_repo = new RoleRepository($db);
$this->getContainer()->addShared('role_repo', $role_repo);
}
public function provides(string $id): bool
{
return $id == 'role_repo';
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace SiteWeaver\Core\Providers\Repositories;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use SiteWeaver\Core\Repositories\UserRepository;
use SiteWeaver\Core\ServiceProvider;
class UserRepositoryServiceProvider extends ServiceProvider
{
/**
* @inheritDoc
*/
public function register(): void
{
try {
$db = $this->getContainer()->get('db');
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
throw new \RuntimeException($e->getMessage());
}
$user_repo = new UserRepository($db);
$this->getContainer()->addShared('user_repo', $user_repo);
}
public function provides(string $id): bool
{
return $id == 'user_repo';
}
}

View file

@ -1,30 +0,0 @@
<?php
/**
* File: src/Site/Core/Providers/RouterServiceProvider.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Service Provider that registers the Router in the container.
*/
namespace SiteWeaver\Core\Providers;
use SiteWeaver\Core\ServiceProvider;
use SiteWeaver\Core\Router;
class RouterServiceProvider extends ServiceProvider
{
/** @inheritDoc */
public function register(): void
{
$router = new Router();
$this->getContainer()->add('router', $router);
}
public function provides(string $id): bool
{
return $id == 'router';
}
}

View file

@ -1,32 +0,0 @@
<?php
/**
* File: src/Site/Core/Providers/SessionServiceProvider.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Service Provider that registers the SessionService in the container.
*/
namespace SiteWeaver\Core\Providers;
use SiteWeaver\Core\ServiceProvider;
use SiteWeaver\Core\Services\SessionService;
class SessionServiceProvider extends ServiceProvider
{
/** @inheritDoc */
public function register(): void
{
$session = new SessionService();
$session->start();
$this->getContainer()->addShared('session', $session);
}
public function provides(string $id): bool
{
return $id == 'session';
}
}

View file

@ -1,31 +0,0 @@
<?php
/**
* File: src/Site/Core/Providers/TOTPServiceProvider.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Service Provider that registers the TOTPService in the container.
*/
namespace SiteWeaver\Core\Providers;
use SiteWeaver\Core\ServiceProvider;
use SiteWeaver\Core\Services\TOTPService;
class TOTPServiceProvider extends ServiceProvider
{
/** @inheritDoc */
public function register(): void
{
$session = new TOTPService();
$this->getContainer()->add('totp', $session);
}
public function provides(string $id): bool
{
return $id == 'totp';
}
}

View file

@ -1,66 +0,0 @@
<?php
/**
* File: src/Site/Core/Repositories/PermissionRepository.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Repository for accessing permission data from the database.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Repositories;
use PDO;
use SiteWeaver\Core\Models\Permission;
class PermissionRepository
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function findById(int $id): ?Permission
{
$stmt = $this->pdo->prepare('SELECT * FROM permissions WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$perm = new Permission();
$perm->id = (int)$row['id'];
$perm->name = $row['name'];
return $perm;
}
public function findAll(): array
{
$stmt = $this->pdo->query('SELECT * FROM permissions');
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$perms = [];
foreach ($rows as $row) {
$perm = new Permission();
$perm->id = (int)$row['id'];
$perm->name = $row['name'];
$perms[] = $perm;
}
return $perms;
}
public function create(Permission $permission, int $createdById): void
{
$stmt = $this->pdo->prepare('INSERT INTO permissions (name, created_by) VALUES (:name, :created_by)');
$stmt->execute([
':name' => $permission->name,
':created_by' => $createdById,
]);
$id = (int)$this->pdo->lastInsertId();
$permission->id = $id;
}
}

View file

@ -1,66 +0,0 @@
<?php
/**
* File: src/Site/Core/Repositories/RoleRepository.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Repository for accessing role data from the database.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Repositories;
use PDO;
use SiteWeaver\Core\Models\Role;
class RoleRepository
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function findById(int $id): ?Role
{
$stmt = $this->pdo->prepare('SELECT * FROM roles WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$role = new Role();
$role->id = (int)$row['id'];
$role->name = $row['name'];
return $role;
}
public function findAll(): array
{
$stmt = $this->pdo->query('SELECT * FROM roles');
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$roles = [];
foreach ($rows as $row) {
$role = new Role();
$role->id = (int)$row['id'];
$role->name = $row['name'];
$roles[] = $role;
}
return $roles;
}
public function create(Role $role, int $createdById): void
{
$stmt = $this->pdo->prepare('INSERT INTO roles (name, created_by) VALUES (:name, :created_by)');
$stmt->execute([
':name' => $role->name,
':created_by' => $createdById,
]);
$id = (int)$this->pdo->lastInsertId();
$role->id = $id;
}
}

View file

@ -1,92 +0,0 @@
<?php
/**
* File: src/Site/Core/Repositories/UserRepository.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Repository for accessing user data from the database.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Repositories;
use PDO;
use SiteWeaver\Core\Models\User;
class UserRepository
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Find a user by username.
*/
public function findByUsername(string $username): ?User
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE username = :username LIMIT 1');
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
// Create User object without rehashing password.
$user = new User((int)$row['id'], $row['username'], $row['email'], '');
$user->setHashedPassword($row['password_hash']);
if (!empty($row['totp_secret'])) {
$user->setTotpSecret($row['totp_secret']);
}
return $user;
}
/**
* Find a user by ID.
*/
public function findById(int $id): ?User
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id LIMIT 1');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$user = new User((int)$row['id'], $row['username'], $row['email'], '');
$user->setHashedPassword($row['password_hash']);
if (!empty($row['totp_secret'])) {
$user->setTotpSecret($row['totp_secret']);
}
return $user;
}
/**
* Create a new user record.
*/
public function create(User $user): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO users (username, email, password_hash, totp_secret) VALUES (:username, :email, :password_hash, :totp_secret)'
);
$stmt->execute([
':username' => $user->username,
':email' => $user->email,
':password_hash' => $user->getPasswordHash(),
':totp_secret' => $user->getTotpSecret() ?? null,
]);
// Set the ID on the user object.
$userId = (int)$this->pdo->lastInsertId();
// Use reflection to set private id property.
$reflection = new \ReflectionClass($user);
if ($reflection->hasProperty('id')) {
$property = $reflection->getProperty('id');
$property->setAccessible(true);
$property->setValue($user, $userId);
}
}
}

View file

@ -1,112 +0,0 @@
<?php
/**
* File: src/Site/Core/Router.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Very small custom router.
*/
declare(strict_types=1);
namespace SiteWeaver\Core;
/**
* Very small custom router.
*/
class Router
{
/** @var array<int, array{method:string,path:string,handler:callable,middleware?:array<callable>}> */
private array $routes = [];
/** @var array<string,string> mapping of route name to path pattern */
private array $namedRoutes = [];
public function get(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('GET', $path, $handler, $middleware, $name);
}
public function post(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('POST', $path, $handler, $middleware, $name);
}
public function put(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('PUT', $path, $handler, $middleware, $name);
}
public function delete(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('DELETE', $path, $handler, $middleware, $name);
}
private function addRoute(
string $method,
string $path,
callable $handler,
array $middleware,
?string $name = null
): void {
$this->routes[] = [
'method' => strtoupper($method),
'path' => $path,
'handler' => $handler,
'middleware' => $middleware,
];
if ($name !== null) {
$this->namedRoutes[$name] = $path;
}
}
/**
* Dispatch a request.
*
* @return array{handler:callable,middleware:array<callable>,params:array<string,string>}|null
*/
public function dispatch(string $method, string $uri): ?array
{
foreach ($this->routes as $route) {
if (strtoupper($method) !== strtoupper($route['method'])) {
continue;
}
// Build regex for path pattern
$pattern = preg_replace('#\{([^}]+)\}#', '(?P<$1>[^/]+)', $route['path']);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $uri, $matches)) {
// filter named groups
$params = array_filter(
$matches,
fn ($k) => is_string($k),
ARRAY_FILTER_USE_KEY
);
return [
'handler' => $route['handler'],
'middleware' => $route['middleware'] ?? [],
'params' => $params,
];
}
}
return null;
}
/**
* Generate a URL for a named route.
*/
public function url(string $name, array $params = []): string
{
if (!isset($this->namedRoutes[$name])) {
throw new \InvalidArgumentException("Route name {$name} not defined");
}
$path = $this->namedRoutes[$name];
foreach ($params as $key => $value) {
// replace both required and optional placeholders
$path = str_replace('{' . $key . '}', (string)$value, $path);
$path = str_replace('{?' . $key . '}', (string)$value, $path);
}
return $path;
}
}

View file

@ -1,37 +0,0 @@
<?php
/**
* File: src/Site/Core/ServiceProvider.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Base class for all service providers.
*/
declare(strict_types=1);
namespace SiteWeaver\Core;
use League\Container\ServiceProvider\AbstractServiceProvider;
/**
* Base class for all service providers.
*/
abstract class ServiceProvider extends AbstractServiceProvider
{
public function __construct(Container $container)
{
$this->setContainer($container);
}
/**
* Register bindings in the container.
*/
abstract public function register(): void;
/**
* Optional boot method called after all providers are registered.
*/
public function boot(): void {}
}

View file

@ -1,134 +0,0 @@
<?php
/**
* File: src/Site/Core/Services/AuthService.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Handles user authentication, password verification, session management and optional TOTP 2FA.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Services;
use SiteWeaver\Core\Config;
use SiteWeaver\Core\Models\User;
use SiteWeaver\Core\Models\Role;
use SiteWeaver\Core\Repositories\UserRepository;
/**
* AuthService responsible for registering users, logging in/out,
* managing sessions and integrating with TOTP for 2FA.
*/
class AuthService
{
private Config $config;
private SessionService $session;
private UserRepository $userRepo;
private array $users = [];
private ?TOTPService $totpService = null;
public function __construct(Config $config, SessionService $session, UserRepository $user_repo)
{
$this->config = $config;
$this->session = $session;
$this->userRepo = $user_repo;
}
/**
* Attach a TOTP service for 2FA support.
*/
public function setTOTPService(TOTPService $service): void
{
$this->totpService = $service;
}
/**
* Register a new user and store it in memory.
*
* @return User The newly created user instance.
*/
public function register(string $username, string $email, string $password): User
{
if ($this->userRepo->findByUsername($username) !== null) {
throw new \RuntimeException("User {$username} already exists");
}
// ID will be set by the repository
$user = new User(0, $username, $email, $password);
$this->userRepo->create($user);
return $user;
}
/**
* Attempt to log a user in. If the user has TOTP enabled, an OTP token must be supplied.
*
* @param string $username Username.
* @param string $password Plain text password.
* @param string|null $otp One‑time password; required if 2FA is enabled for the account.
*
* @return bool True on successful login, false otherwise.
*/
public function login(string $username, string $password, ?string $otp = null): bool
{
$user = $this->userRepo->findByUsername($username);
if (!$user) {
return false;
}
if (!$user->verifyPassword($password)) {
return false;
}
// TOTP check if enabled
$secret = $user->getTotpSecret();
if ($secret !== null) {
if ($this->totpService === null || $otp === null) {
return false; // 2FA required but not provided or service missing
}
if (!$this->totpService->verify($secret, $otp)) {
return false;
}
}
// Successful login – store user id in session
$this->session->set('user_id', $user->id);
return true;
}
/**
* Log the current user out by clearing the session.
*/
public function logout(): void
{
$this->session->remove('user_id');
}
/**
* Retrieve the currently authenticated user, if any.
*
* @return User|null
*/
public function getCurrentUser(): ?User
{
$id = $this->session->get('user_id');
if ($id === null) {
return null;
}
return $this->userRepo->findById((int)$id);
}
/**
* Simple helper to check whether a user is authenticated.
*/
public function isAuthenticated(): bool
{
return $this->getCurrentUser() !== null;
}
/** Additional helper methods for role/permission checks could be added here. */
}

View file

@ -1,72 +0,0 @@
<?php
/**
* File: src/Site/Core/Services/SessionService.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Simple wrapper around PHP's native session handling.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Services;
/**
* Session service that encapsulates PHP session functions.
*/
class SessionService
{
/**
* Ensure a session is started.
*/
public function start(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
/**
* Store a value in the session.
*
* @param string $key Session key.
* @param mixed $value Value to store.
*/
public function set(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}
/**
* Retrieve a value from the session.
*
* @param string $key Session key.
* @param mixed $default Default if key not found.
*/
public function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}
/**
* Remove a value from the session.
*
* @param string $key Session key.
*/
public function remove(string $key): void
{
unset($_SESSION[$key]);
}
/**
* Destroy the entire session.
*/
public function destroy(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
$_SESSION = [];
}
}
}

View file

@ -1,73 +0,0 @@
<?php
/**
* File: src/Site/Core/Services/TOTPService.php
*
* Author: Funky Waddle
* Date: 2026-09-11
*
* Purpose:
* Provides Time‑Based One‑Time Password (TOTP) generation and verification.
*/
declare(strict_types=1);
namespace SiteWeaver\Core\Services;
use OTPHP\TOTP;
use Psr\Clock\ClockInterface;
/**
* TOTP service using spomky-labs/otphp library.
*/
class TOTPService
{
private ClockInterface $clock;
private ?string $secret;
public function __construct() {
$this->clock = new class implements ClockInterface {
public function now(): \DateTimeImmutable { return new \DateTimeImmutable(); }
};
$this->secret = null;
}
/**
* Generate a new random secret suitable for TOTP.
*
* @return string Base32 encoded secret.
*/
public function generateSecret(): string
{
$totp = TOTP::create(clock: $this->clock);
$this->secret = $totp->getSecret();
return $this->secret;
}
/**
* Verify an OTP code against the given secret.
*
* @param string $secret The shared secret (Base32).
* @param string $otp One‑time password to verify.
*
* @return bool True if valid, false otherwise.
*/
public function verify(string $secret, string $otp): bool
{
$totp = TOTP::create($secret, clock: $this->clock);
return $totp->verify($otp);
}
/**
* Build a provisioning URI that can be scanned by authenticator apps.
*
* @param string $accountName The account name (e.g., username).
* @param string $issuer Service provider name, default is SiteWeaver.
*
* @return string URL to encode in QR code.
*/
public function getProvisioningUri(string $accountName, string $issuer = 'SiteWeaver'): string
{
$totp = TOTP::create($this->secret, clock: $this->clock)->withLabel($accountName)->withIssuer($issuer);
return $totp->getProvisioningUri();
}
}

View file

@ -1,11 +0,0 @@
<?php
/**
* File: tests/_bootstrap.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Bootstrap file for Codeception tests.
*/
require __DIR__ . '/../config/bootstrap.php';

View file

@ -1,35 +0,0 @@
<?php
/**
* File: tests/_support/UnitTester.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Unit tester support class for Codeception.
*/
declare(strict_types=1);
/**
* Inherited Methods
* @method void wantTo($text)
* @method void wantToTest($text)
* @method void execute($callable)
* @method void expectTo($prediction)
* @method void expect($prediction)
* @method void amGoingTo($argumentation)
* @method void am($role)
* @method void lookForwardTo($achieveValue)
* @method void comment($description)
* @method void pause($vars = [])
*
* @SuppressWarnings(PHPMD)
*/
class UnitTester extends \Codeception\Actor
{
use _generated\UnitTesterActions;
/**
* Define custom actions here
*/
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,105 +0,0 @@
<?php
/**
* File: tests/unit/Core/AuthMiddlewareRolePermissionTest.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Unit tests for AuthMiddleware role and permission checks.
*/
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\Repositories\UserRepository;
use SiteWeaver\Core\Services\AuthService;
use SiteWeaver\Core\Services\SessionService;
use SiteWeaver\Core\Models\User;
use SiteWeaver\Core\Models\Role;
use SiteWeaver\Core\Models\Permission;
use SiteWeaver\Core\Middleware\AuthMiddleware;
use SiteWeaver\Core\Config;
use PHPUnit\Framework\TestCase;
final class AuthMiddlewareRolePermissionTest extends TestCase
{
private AuthService $auth;
private SessionService $session;
private UserRepository $user_repo;
protected function setUp(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$config = new Config();
$this->session = $this->createMock(SessionService::class);
$this->user_repo = $this->createMock(UserRepository::class);
$this->auth = new AuthService($config, $this->session, $this->user_repo);
}
protected function tearDown(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
}
public function testAuthorizeRoleAndPermission(): void
{
// Create role and permission
$role = new Role(1, 'Admin');
$permEditPage = new Permission(1, 'edit:page');
// Add permission to role
$role->addPermission($permEditPage);
// Register user and assign role
$user = $this->auth->register('dave', 'dave@example.com', 'pass');
$user->addRole($role);
$mockUser = new \SiteWeaver\Core\Models\User(1, 'dave', 'dave@example.com', 'pass');
$mockUser->setHashedPassword(password_hash('pass', PASSWORD_DEFAULT));
$this->user_repo->method('findByUsername')->willReturn($user);
$this->user_repo->method('findById')->willReturn($user);
$this->session->method('get')->willReturn(1);
// Log in user
$this->auth->login('dave', 'pass');
$middleware = new AuthMiddleware($this->auth);
// Authorize role - should pass
$middleware->authorizeRole('Admin');
// Authorize permission - should pass
$middleware->authorizePermission('edit:page');
// Now test missing role
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Forbidden: missing role Editor');
$middleware->authorizeRole('Editor'); // not assigned
}
public function testAuthorizePermissionMissing(): void
{
// Create user without permissions
$user = $this->auth->register('erin', 'erin@example.com', 'pass');
$this->auth->login('erin', 'pass');
$mockUser = new \SiteWeaver\Core\Models\User(1, 'erin', 'erin@example.com', 'pass');
$mockUser->setHashedPassword(password_hash('pass', PASSWORD_DEFAULT));
$this->user_repo->method('findByUsername')->willReturn($mockUser);
$this->user_repo->method('findById')->willReturn($mockUser);
$this->session->method('get')->willReturn(1);
$middleware = new AuthMiddleware($this->auth);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Forbidden: missing permission delete:page');
$middleware->authorizePermission('delete:page');
}
}

View file

@ -1,128 +0,0 @@
<?php
/**
* File: tests/unit/Core/AuthServiceTest.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Unit tests for AuthService, SessionService and TOTPService.
*/
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\Config;
use SiteWeaver\Core\Repositories\UserRepository;
use SiteWeaver\Core\Services\AuthService;
use SiteWeaver\Core\Services\SessionService;
use SiteWeaver\Core\Services\TOTPService;
use SiteWeaver\Core\Models\User;
use PHPUnit\Framework\TestCase;
// Dummy TOTP service for testing
class DummyTOTPService extends TOTPService {
public function verify(string $secret, string $otp): bool { return true; }
}
final class AuthServiceTest extends TestCase
{
private Config $config;
private SessionService $session;
private AuthService $auth;
protected function setUp(): void
{
$this->session = new SessionService();
$this->session->start();
$this->config = new Config();
$this->user_repo = $this->createMock(UserRepository::class);
$this->auth = new AuthService($this->config, $this->session, $this->user_repo);
}
protected function tearDown(): void
{
$this->session->destroy();
}
public function testRegisterAndLoginWithPassword(): void
{
$user = $this->auth->register('alice', 'alice@example.com', 'secret123');
$this->assertInstanceOf(User::class, $user);
$mockUser = new \SiteWeaver\Core\Models\User(1, 'alice', 'alice@example.com', 'secret123');
$mockUser->setHashedPassword(password_hash('secret123', PASSWORD_DEFAULT));
$this->user_repo->method('findByUsername')->willReturn($mockUser);
$this->user_repo->method('findById')->willReturn($mockUser);
// login should succeed with correct password
$result = $this->auth->login('alice', 'secret123');
$this->assertTrue($result);
}
public function testLoginWithTOTP(): void
{
// register user and enable TOTP
$user = new \SiteWeaver\Core\Models\User(1, 'bob', 'bob@example.com', 'password');
$user->setHashedPassword(password_hash('password', PASSWORD_DEFAULT));
$user->setTotpSecret('dummysecret');
$this->user_repo->method('findByUsername')->willReturn($user);
$this->user_repo->method('findById')->willReturn($user);
// set dummy TOTP service that always verifies
$totp = new DummyTOTPService();
$this->auth->setTOTPService($totp);
// login without OTP should fail
$this->assertFalse($this->auth->login('bob', 'password'));
// login with OTP should succeed
$this->assertTrue($this->auth->login('bob', 'password', '123456'));
}
public function testAuthMiddlewareAccessControl(): void
{
// register and log in a user
$user = new \SiteWeaver\Core\Models\User(1, 'carol', 'carol@example.com', 'pass');
$user->setHashedPassword(password_hash('pass', PASSWORD_DEFAULT));
$this->user_repo->method('findByUsername')->willReturn($user);
$this->user_repo->method('findById')->willReturn($user);
$this->auth->login('carol', 'pass');
// instantiate middleware
$middleware = new \SiteWeaver\Core\Middleware\AuthMiddleware($this->auth);
// should not throw exception for authenticated user
$this->expectNotToPerformAssertions();
$middleware->handle();
// logout user by removing session key
$this->session->remove('user_id');
// handle should now throw RuntimeException
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Unauthorized');
$middleware->handle();
}
public function testLogout(): void
{
// ensure user is logged in
$user = new \SiteWeaver\Core\Models\User(1, 'alice', 'alice@example.com', 'secret123');
$user->setHashedPassword(password_hash('secret123', PASSWORD_DEFAULT));
$this->user_repo->method('findByUsername')->willReturn($user);
$this->user_repo->method('findById')->willReturn($user);
$this->auth->login('alice', 'secret123');
$this->assertTrue($this->auth->isAuthenticated());
// logout should clear session
$this->auth->logout();
$this->assertFalse($this->auth->isAuthenticated());
}
}

View file

@ -1,82 +0,0 @@
<?php
/**
* File: tests/unit/Core/ConfigTest.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Unit tests for the Config service.
*/
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\Config;
use PHPUnit\Framework\TestCase;
final class ConfigTest extends TestCase
{
public function testLoadsEnvironmentVariables(): void
{
$env = [
'APP_ENV' => 'testing',
'DB_HOST' => 'localhost',
'DEBUG' => true,
];
$config = new Config();
$config->load($env);
$this->assertSame('testing', $config->get('APP_ENV'));
$this->assertSame('localhost', $config->get('DB_HOST'));
$this->assertTrue($config->get('DEBUG'));
// unknown key returns null
$this->assertNull($config->get('NON_EXISTENT_KEY'));
}
public function testAllReturnsMergedArray(): void
{
$env = [
'FOO' => 'bar',
'BAZ' => 123,
];
$config = new Config();
$config->load($env);
$this->assertSame($env, $config->all());
}
public function testNoOverrideByDefault(): void {
$env = [
'FOO' => 'bar',
'BAZ' => 123,
];
$config = new Config();
$config->load($env);
$config->set('FOO', 'asdf');
$this->assertSame($env, $config->all());
}
public function testForcedOverride(): void {
$env = [
'FOO' => 'bar',
'BAZ' => 123,
];
$config = new Config();
$config->load($env);
$config->set('FOO', 'asdf', true);
$new_env = [
'FOO' => 'asdf',
'BAZ' => 123,
];
$this->assertSame($new_env, $config->all());
}
}

View file

@ -1,42 +0,0 @@
<?php
/**
* File: tests/unit/Core/RouterTest.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Unit tests for the Router component.
*/
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\Router;
use PHPUnit\Framework\TestCase;
final class RouterTest extends TestCase
{
public function testRouteRegistrationAndDispatch(): void
{
$router = new Router();
$router->get('/users/{id}', fn($params) => 'handler', [], 'user_show');
$result = $router->dispatch('GET', '/users/42');
$this->assertNotNull($result);
$handler = $result['handler'];
$this->assertIsCallable($handler);
$this->assertSame('handler', ($handler)(['id' => '42']));
$this->assertEquals(['id' => '42'], $result['params']);
}
public function testUrlGeneration(): void
{
$router = new Router();
$router->get('/posts/{slug}', fn() => null, [], 'post_show');
$url = $router->url('post_show', ['slug' => 'hello-world']);
$this->assertSame('/posts/hello-world', $url);
}
}

View file

@ -1,40 +0,0 @@
<?php
/**
* File: tests/unit/Core/ServiceProviderTest.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Unit tests for the ServiceProvider base class.
*/
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\ServiceProvider;
use SiteWeaver\Core\Container;
use PHPUnit\Framework\TestCase;
final class DummyProvider extends ServiceProvider {
public function register(): void {
$this->container->add('dummy', fn() => 'value');
}
public function provides(string $id): bool
{
return $id == 'dummy';
}
}
final class ServiceProviderTest extends TestCase
{
public function testRegisterAddsService(): void
{
$container = new Container();
$provider = new DummyProvider($container);
$provider->register();
$this->assertSame('value', $container->get('dummy'));
}
}

View file

@ -1,47 +0,0 @@
<?php
/**
* File: tests/unit/Core/SessionServiceTest.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Unit tests for SessionService.
*/
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\Services\SessionService;
use PHPUnit\Framework\TestCase;
final class SessionServiceTest extends TestCase
{
private SessionService $session;
protected function setUp(): void
{
$this->session = new SessionService();
$this->session->start();
}
protected function tearDown(): void
{
$this->session->destroy();
}
public function testSetGetRemoveDestroy(): void
{
// set value
$this->session->set('foo', 'bar');
$this->assertSame('bar', $this->session->get('foo'));
// remove key
$this->session->remove('foo');
$this->assertNull($this->session->get('foo'));
// destroy session
$this->session->set('baz', 'qux');
$this->session->destroy();
$this->assertEmpty($_SESSION);
}
}

View file

@ -1,100 +0,0 @@
<?php
/**
* File: tests/unit/Core/TOTPServiceTest.php
*
* Author: Funky Waddle
* Date: 2026-09-12
*
* Purpose:
* Unit tests for the TOTPService.
*/
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\Services\TOTPService;
use OTPHP\TOTP;
use Psr\Clock\ClockInterface;
use PHPUnit\Framework\TestCase;
final class TOTPServiceTest extends TestCase
{
private ?ClockInterface $clock;
protected function setUp(): void
{
$this->clock = new class implements ClockInterface {
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
};
}
protected function tearDown(): void
{
$this->clock = null;
}
public function testGenerateAndVerify(): void
{
$totpService = new TOTPService();
$secret = $totpService->generateSecret();
// Generate a current OTP using the secret and clock
$otp = TOTP::create($secret, clock: $this->clock)->now();
$this->assertTrue($totpService->verify($secret, $otp));
}
public function testGenerateSecretUniqueness(): void
{
$totpService = new TOTPService();
$secret1 = $totpService->generateSecret();
$secret2 = $totpService->generateSecret();
$this->assertNotEquals($secret1, $secret2);
$this->assertNotEmpty($secret1);
$this->assertNotEmpty($secret2);
}
public function testVerifyWithWrongOTP(): void
{
$totpService = new TOTPService();
$secret = $totpService->generateSecret();
// correct OTP
$otpCorrect = TOTP::create($secret, clock: $this->clock)->now();
$this->assertTrue($totpService->verify($secret, $otpCorrect));
// wrong OTP
$wrongOtp = '000000';
$this->assertFalse($totpService->verify($secret, $wrongOtp));
}
public function testGetProvisioningUri(): void
{
$totpService = new TOTPService();
$secret = $totpService->generateSecret();
// Use a known account name and issuer
$accountName = 'alice@example.com';
$issuer = 'SiteWeaverCMS';
$uri = $totpService->getProvisioningUri($accountName, $issuer);
// The URI should start with otpauth://totp/
$this->assertStringStartsWith('otpauth://totp/', $uri);
// It should contain the issuer and account name (URL‑encoded)
$expectedIssuer = rawurlencode($issuer);
$expectedAccount = rawurlencode($accountName);
$this->assertStringContainsString($expectedIssuer, $uri);
$this->assertStringContainsString($expectedAccount, $uri);
// Compare with the library's own provisioning URI
$libUri = TOTP::create($secret, clock: $this->clock)
->withLabel($accountName)
->withIssuer($issuer)
->getProvisioningUri();
$this->assertEquals($libUri, $uri);
}
}

View file

@ -1,16 +0,0 @@
<?php
/**
* File: tests/unit/UnitTester.php
*
* Author: Funky Waddle
* Date: 2026-09-10
*
* Purpose:
* Test actor used by Codeception unit suite.
*/
namespace Tests\unit;
class UnitTester extends \Codeception\Actor {
use \Codeception\AssertThrows;
}