No description
  • PHP 94.9%
  • CSS 5.1%
Find a file
Pascal Dissieux e3352223de add editorconfig
2026-04-14 12:54:02 +02:00
public Add some stuff 2026-04-14 01:27:05 +02:00
src add src dir 2026-04-14 12:49:02 +02:00
.editorconfig add editorconfig 2026-04-14 12:54:02 +02:00
README.md first commit 2026-04-14 01:20:55 +02:00

Seidos

Internal web application for managing users, roles, privileges, customers, and mail accounts. Built as a hand-rolled PHP 8.3+ MVC framework — no external web framework, custom IoC container, custom router.


Table of Contents

  1. Architecture Overview
  2. Request Lifecycle
  3. Authentication & Session
  4. Authorization
  5. Layers in Detail
  6. Entities & Data Model
  7. Admin Panel
  8. Security Model
  9. Configuration
  10. Known Issues / TODOs

Architecture Overview

public/index.php
    └── App::run()
            ├── Router::find()          maps URI → [ControllerClass, method]
            ├── Csrf::validate()        blocks forged POST/DELETE
            ├── Container::get()        autowires the controller
            └── $controller->$method() returns Response

Layers (all under src/):

Layer Directory Responsibility
Entry point public/index.php Bootstrap, error wrapper
App / Router Core/App.php, Http/Router.php Dispatch request to controller
Controllers Controllers/ Auth checks, delegate to service, return Response
Services Services/ Business logic, orchestrate repositories
Repositories Repositories/ All SQL, return plain arrays
Definitions Definitions/ Static UI metadata (columns, actions, form fields)
Views Views/ Assemble HTML from templates, init layout data
Templates Templates/ Pure HTML string builders
Http Http/ Request, Response, Session, CSRF, Router, Exceptions
Core Core/ App, Config, Database, Helper, Normalizer, LDAP
Container Container/ PSR-11 autowiring DI container

Request Lifecycle

Browser → public/index.php
  1.  Load config/config.php and config/routes.php
  2.  new Request()          — wraps $_SERVER / $_GET / $_POST / php://input
  3.  new App($config, $routes, $request)
  4.  App::run()
        a. Session::start()
        b. Router::find($request)
             - static routes: O(1) lookup by method + depth + path
             - dynamic routes: regex match by method + depth
             - throws NotFoundException if no match
        c. CSRF check for POST/PUT/PATCH/DELETE
             - reads csrf_token from POST body OR X-CSRF-TOKEN header
             - throws CsrfException if invalid
        d. Container::get('Seidos\Controllers\FooController')
             - autowires all constructor dependencies recursively
             - Config and Request are pre-registered
        e. $controller->$method()  →  Response
        f. Response::send()        →  http_response_code + headers + echo body
  5.  Any Throwable is caught in index.php → ExceptionHandler::handle()
        - HTTP exceptions: use their own code + hint
        - Other exceptions: 500, empty hint, stack trace if APP_DEBUG=true
        - API requests (XHR/JSON Accept): JSON error body
        - Browser requests: ErrorView HTML page

Authentication & Session

Login flow (POST /login)

AuthController::login()
  → AuthService::authenticate($username, $password)
        1. UserRepository::findAuthBundleByLogin()   — fetch id, enabled, password_hash
        2. Check enabled flag
        3. UserRepository::isLocked($id)             — check locked_until timestamp
        4. password_verify($password, $hash)
           - On failure: increaseFailedLoginAttempts()
                → updateFailedLoginAttempt()
                → setBanTime() if threshold crossed (3 / 5 / 7 / 15 attempts)
           - On success: resetFailedLoginAttempts()
        5. password_needs_rehash() → updatePasswordHash() if needed (Argon2ID)
        6. UserRepository::getRoleForUser()          — fetch role name
        7. UserRepository::getPrivilegesForUser()    — fetch privilege names
        8. new SessionUser($id, $username, $role, $privileges[])
        9. Session::setUser($user)                   — regenerate session ID
  → AuditlogService::log('login' / 'login_failed')
  → redirect to Session::getIntendedUrl() (default: /dashboard)

Lockout thresholds

Failed attempts Lockout
3 30 minutes
5 1 hour
7 2 hours
15 24 hours

Lockout is stored as a Unix timestamp in users.locked_until.

Session storage

SessionUser is serialised to $_SESSION['user'] as:

['id' => int, 'name' => string, 'role' => string, 'privs' => string[]]

Session cookie is HttpOnly, SameSite=Strict, Secure when HTTPS is detected.

Logout (GET /logout)

Clears CSRF token, wipes $_SESSION, expires the cookie, calls session_destroy(). Redirects to /login.


Authorization

Three guard methods on BaseController:

Method Condition
requireLogin() Redirects to /login if not authenticated (stores intended URL)
requireAdmin() Same as above + requires role === 'globaladmin'
requirePrivilege(string ...$p) Authenticated + has ALL listed privileges
requireAnyPrivilege(string ...$p) Authenticated + has AT LEAST ONE privilege

Privileges are plain strings stored in the session array. SessionUser::hasPrivilege() does a strict in_array check.


Layers in Detail

Container (Container/Container.php)

PSR-11 compliant DI container with full autowiring:

  • set(id, Closure) — lazy singleton factory
  • set(id, object) — pre-built instance
  • get(id) — returns instance, builds it if needed
  • Constructor autowiring via ReflectionClass, supports named types, union types
  • Detects circular dependencies

Router (Http/Router.php)

Routes registered as [method][depth][path] = [ControllerClass, method].

  • Static routes: exact string match, O(1) lookup
  • Dynamic routes: compiled to named-capture regex, matched by path depth
  • {param} placeholders captured as dynamic_params in the handler array

Route definitions live in config/routes.php using short class names (namespace prepended by App::run()).

Request (Http/Request.php)

Wraps $_SERVER, $_GET, $_POST and php://input (JSON). Key methods:

  • post(key), get(key), json(key) — typed accessors with default
  • header(name) — reads HTTP_* server vars
  • isApi() — true if Accept: application/json or X-Requested-With: XMLHttpRequest
  • Only GET, POST, HEAD are allowed; others throw MethodNotAllowedException

Response (Http/Response.php)

Fluent builder. Sent via send() which calls http_response_code(), emits headers, echoes body.

  • rejson(code, data) — JSON response with Content-Type: application/json
  • redirect(code, url) — sets Location header
  • html(html, code) — sets HTML content type

CSRF (Http/Csrf.php)

Token stored in $_SESSION['csrf_token'], generated with random_bytes(32). Validated with hash_equals (timing-safe). Can be read from POST csrf_token or X-CSRF-TOKEN header. Cleared after successful login.

Database (Core/Database.php)

Lazy PDO connection. Supports MySQL, PostgreSQL, SQLite (via driver config key). Error mode is ERRMODE_EXCEPTION.

  • getConnection() — returns (or creates) PDO singleton
  • fetchOne(sql, params) — prepared statement → single row or null
  • fetchValue(sql, params) — prepared statement → scalar or null
  • fetchAll(sql, params) — prepared statement → array of rows
  • getEnumValues(table, column) — parses MySQL ENUM definition string

Config (Core/Config.php)

Immutable wrapper around the config array. get(key1, key2, ...) traverses nested keys, returns null if any segment is missing.

Normalizer (Core/Normalizer.php)

Static utility class for coercing and validating values before DB storage. Methods: Int, Boolean, Decimal, Name, Slug, Text, RichText, Email, IPv4, IPv6, IP, Date, DateTime, Time, Filepath, Array. All throw InvalidArgumentException on bad input.

LDAP (Core/LDAP.php)

Thin wrapper over PHP's ldap_* functions. Lazy-connects using config array keys: host, port, user, pass, version, tls, base. Methods: search, fetchOne, fetchValue, create, update, delete, disconnect.

Definitions (Definitions/)

Pure-static declarative metadata classes. Each entity has one Definition that declares:

  • STRINGS — singular/plural labels for UI and JS config
  • TABLE_COLUMNS — columns shown in the data table, with formatting options
  • ACTIONS — toolbar buttons with scope (none / single / any)
  • FIELD_DEFINITIONS — all possible form fields with type, label, validation hints
  • FORM_FIELDS — which fields appear in each action's modal

Nothing in a Definition ever touches the database or runs code.

Services (Services/)

Sit between controllers and repositories. BaseService provides generic create, update, delete backed by BaseRepository. Entity services override these or add custom methods.

Notable:

  • UserService::create() — validates username uniqueness, hashes password with Argon2ID, validates role_id
  • UserService::changePassword() — validates new/confirm match, hashes with Argon2ID
  • AuditlogService::log() — writes an audit record; changes serialised as JSON
  • AuthService — standalone, does not extend BaseService; handles the full login/lockout cycle

Repositories (Repositories/)

All SQL lives here. Use PDO prepared statements throughout. BaseRepository provides:

  • getOptions()SELECT id AS value, name AS label FROM table (for dropdowns)
  • deleteById(id) — generic delete

Each entity repository extends BaseRepository and implements protected function db(): Database.

Views (Views/)

Each view extends BaseView. init(array $data) populates layout properties from $data and from $this->definition (if set). Views that have no Definition (e.g. AuthView) work fine — definition-dependent code is guarded.

Render methods assemble: head() . startBody() . startTopbar() . template->show() . endBody().

JSConfigGenerator renders a <script> block injecting entity strings into window.AppConfig for use by frontend JS modules.

Templates (Templates/)

Pure PHP string builders — no output buffering, no includes. Each returns an HTML string. Shared components:

  • Head<head> with CSS links, CSRF meta tag
  • Topbar — top navigation bar with username and logout
  • Sidebar — left navigation, permission-aware (hides links the user can't access)
  • Table — data table with column formatting (badge, tag, code, bold, date, boolean, link)
  • Toolbar — action buttons + search field + modal forms
  • Action — inline row action buttons (edit, delete, etc.)

Entities & Data Model

users

Column Type Notes
id INT PK
username VARCHAR unique login name
fullname VARCHAR display name
role_id FK → roles
password_hash VARCHAR Argon2ID
primary_email VARCHAR
enabled TINYINT(1) 0 = disabled
last_login DATETIME
failed_attempts INT reset on success
locked_until INT Unix timestamp
created_at / updated_at DATETIME

roles

Many-to-one with users. Has name, description. Linked to privileges via role_privileges.

privileges

Named capabilities (plain strings). Checked via SessionUser::hasPrivilege().

role_privileges

Junction table: role_id, privilege_id.

customers

External customers: name, domain, contact_email, enabled.

accounts

Mail accounts: username, domain_id, customer_id, enabled. Linked to domains and customers.

auditlog

Column Notes
user_id FK → users (nullable — system actions)
action string, e.g. 'login', 'create', 'delete'
entity_type e.g. 'user', 'customer'
entity_id nullable int
changes JSON blob
ip_address string
created_at DATETIME

Admin Panel

All admin routes require requireAdmin() (role === 'globaladmin').

Each admin entity (users, roles, privileges) follows the same pattern:

GET  /admin/{entity}         → show{Entity}()   render HTML page
POST /admin/{entity}/get     → apiGet()          return JSON list
POST /admin/{entity}/create  → apiCreate()       create via service, audit
POST /admin/{entity}/update  → apiUpdate()       update via service, audit
POST /admin/{entity}/delete  → apiDelete()       delete via service, audit

All mutation endpoints expect a JSON body: [{"id": N, "data": {...}}, ...] (array of operations). Results are returned as {"results": [{status, message, id}, ...]} with HTTP 207 for mixed outcomes.

The frontend JS reads the Definition-derived config from window.AppConfig and drives the table, toolbar, and modals dynamically.


Security Model

Concern Mechanism
CSRF Session token, hash_equals, cleared after login
SQL injection PDO prepared statements throughout
XSS (output) Helper::html() / htmlspecialchars() on all user data in templates; BaseComponent::h() used in table/form components
Session fixation session_regenerate_id(true) on login
Password storage Argon2ID via password_hash(), auto-rehash on next login
Brute force Failed-attempt counter + progressive lockout
Method restriction Only GET/POST/HEAD accepted; others throw 405
Debug mode Stack traces only shown when APP_DEBUG=true

Configuration

config/config.php — returns an array consumed by Config. Keys:

Key Purpose
app_name Application title shown in UI
debug true = show stack traces on errors
database.driver/host/name/user/pass/port PDO connection
ldap.host/port/user/pass/version/tls/base LDAP connection
redis.host/port Redis (future use)

Security note: config/config.php contains plain-text database and LDAP credentials. This file must not be committed to version control. Use environment variables or a .env-based loader instead. Also ensure debug is false in production.


Known Issues / TODOs

  • Http/ErrorHandler.php — file exists but is empty; unused.
  • Actions/BaseActions.php and Actions/UserActions.php — stub classes with no implementation.
  • Entities/UserEntity.php — defined but never used; repositories return plain arrays.
  • Helper::json_exit() uses array_any() which requires PHP 8.4+. Will fail on PHP 8.3.
  • Services/HomeService.php — referenced in routes/memory notes but does not exist.
  • LDAP credentials in config/config.php are plain text and should be moved to environment variables.
  • debug => true is set in the committed config — must be false in production.