# HackmanCMS Web UI for managing Hexo sites and other server-side projects. ## Stack - PHP 8.0+, no framework - SQLite via PDO (`data/hackmancms.sqlite`) - Bootstrap 5.3 dark theme + Bootstrap Icons - Vanilla JS (no jQuery) ## Directory layout ``` web/ Apache document root (index.php front controller) web/api/ JSON API endpoints (included by front controller) web/assets/ app.css, app.js (vanilla, no bundler) lib/ PHP classes loaded via bootstrap.php lib/project-types/ one file per project type views/ PHP templates (_header.php + _footer.php wrap each page) views/project/ project page + _tab_*.php partials (one per tab) sql/ numbered migration files (001_, 002_, …) bin/ migrate.php, deploy.sh, run-schedules.php config/ config.php data/ SQLite database (gitignored) deploylocal.sh local rsync deploy with chown www-data + apache reload ``` ## Routing All requests go through `web/index.php`. Routes match URI paths with preg_match. **Public routes** (no session required): `/login`, `/api/auth`, `/api/track`. Everything else requires a valid session. The site-visit tracker (`/api/track`) must be reachable from third-party browsers hitting managed Hexo sites — that's why it's whitelisted alongside `/api/auth`. ## Versioning Same pattern as `/opt/agenda` so the footer markup is identical: - `VERSION` at the repo root holds **MAJOR.MINOR** only (e.g. `0.3`). - The deploy script (`deploylocal.sh` and `bin/deploy.sh`) computes a patch number = git commit count since the last touch of `VERSION`, so bumping the file resets to `.0` and each commit auto-increments. - The deploy script writes `web/BUILD` (gitignored) with key=value lines: ``` version=0.3.42 sha=ab12cd3 branch=main built=2026-05-03 14:22:11 ``` - `lib/bootstrap.php::buildInfo()` reads `web/BUILD` (cached static); falls back to `version=dev`, empty sha/branch/built when the file is missing. - `views/_footer.php` renders `HackmanCMS v{version} · {sha} · build {built}` matching Agenda's layout. Bump `VERSION` whenever you cut a release; redeploy and the footer updates on the next request. ## Adding a project type 1. Create `lib/project-types/MyType.php` 2. Extend `ProjectTypeBase`, implement `typeSlug()`, `typeName()`, `typeIcon()` 3. Override `tabs()`, `commands()`, `detectFromPath()` as needed 4. No registration — auto-discovered via `get_declared_classes()` on boot `ProjectTypeBase::tabs()` returns `['overview', 'files', 'recent', 'notes', 'settings']` by default — every type that doesn't override gets those four universal tabs. ### Tab IDs Universal (in default `ProjectTypeBase::tabs()`): `dashboard`, `analytics`, `files`, `notes`, `settings`. Hexo-only (in `HexoProject::tabs()`): `posts`, `config`, `run`, `themes`, `plugins`, `git`. Storage-only: `media`. Tag/category cloud lives **inside** the per-project Dashboard tab — clicking a tag links to `?tab=posts&filter=tag:` (or `category:`), and the posts panel reads that query param to filter the list. The link-checker report lives in the Settings tab. Recent files are also rendered in the project Dashboard tab. Analytics has its own tab. Each tab string maps 1:1 to a ` ServerName hackmancms.bashynx.com DocumentRoot /opt/hackmancms/web AllowOverride All Require all granted ``` The `.htaccess` in `web/` handles rewrite rules — `mod_rewrite` must be enabled. ## Local dev (bashyMint) ```bash ./bin/deploy.sh local # rsync to /var/www/hackmancms, run migrate ``` Apache vhost locally: ```apache ServerName hackmancms.local DocumentRoot /var/www/hackmancms/web AllowOverride All Require all granted ``` Add `127.0.0.1 hackmancms.local` to `/etc/hosts`. ## Scheduled builds `bin/run-schedules.php` is a cron-driven dispatcher: it iterates `scheduled_builds`, matches each row's 5-field cron expression against the current minute, and runs any that are due via the project type's whitelisted commands. Output goes to `command_history`; `last_run_at` / `last_status` are updated on the schedule row. It only fires if a host cron entry runs it every minute. As www-data on the box that serves the app: ```bash echo "* * * * * /usr/bin/php /opt/hackmancms/bin/run-schedules.php >/dev/null 2>&1" \ | sudo crontab -u www-data - ``` UI for managing schedules lives in the Settings tab of any Hexo project (see `views/project/_tab_settings.php` → "Scheduled builds" section). ## Audit log `audit_log` is the source of truth for the dashboard activity feed and `/audit`. Write entries via `Audit::log($db, $action, $project_id = null, $detail = null)` — the helper swallows DB exceptions so a logging failure can't break the calling op. **Convention: every state-changing action gets logged.** The only deliberate exception is the per-project scratchpad (auto-saves several times per minute would flood the feed; `recent_files` covers "what was the user touching" already). Currently logged action strings: | Source | Actions | |------------------------|--------------------------------------------------------------------------------------------------------| | `web/api/git.php` | `git_stage`, `git_unstage`, `git_discard`, `git_pull`, `git_push`, `git_fetch`, `git_commit` (detail=message), `git_merge` (detail=branch), `git_reset` | | `web/api/backup.php` | `backup_download` | | `web/api/links.php` | `link_scan` (detail=`broken=N of M`) | | `web/api/themes.php` | `theme_switch`, `theme_clone`, `theme_delete`, `theme_git_pull`, `theme_git_push`, `theme_git_fetch` (detail=theme name) | | `web/api/plugins.php` | `plugin_install`, `plugin_uninstall` (detail=package name) | | `web/api/files.php` | `file_write`, `file_delete` (detail=relative path) | | `web/api/upload.php` | `file_upload` (detail=relative path) | | `web/api/posts.php` | `post_create`, `post_delete`, `post_publish`, `post_duplicate` (detail=path) | | `web/api/drafts.php` | `draft_create`, `draft_update`, `draft_delete` (detail=title), `draft_publish` (detail=published path) | | `web/api/run.php` | `command_run` (detail=`cmd_id exit=N`) | | `web/api/projects.php` | `project_add`, `project_delete`, `project_rename`, `project_type_change`, `project_pin`, `project_unpin`, `project_setting` (detail=key), `scan_path_add`, `scan_path_delete` | | `web/api/schedules.php`| `schedule_create`, `schedule_update`, `schedule_delete` | | `web/api/templates.php`| `template_create`, `template_update`, `template_delete` (detail=name) | | `web/api/snippets.php` | `snippet_create`, `snippet_update`, `snippet_delete` (detail=name) | | `bin/run-schedules.php`| `scheduled_build` (detail=`cmd_id status=...`) | The activity feed renderer (`#activityFeed` in `app.js`) maps these to labels + icons in `ACTION_LABELS` / `ACTION_ICONS`. Unmapped actions still render — they just show the raw string and a fallback circle icon. **When adding a new logged action, also add it to both maps in `app.js`.** ## Site visit analytics Analytics is **server-log based**, not pixel based. HackmanCMS tails the web server's access log, parses each line, and inserts rows into `site_visits`. This means zero footprint on the managed Hexo site — no JS, no pixel, no client-side change required. **Per-project Analytics → "Server-log import setup"** captures the import config; the importer + cull track their own state. Keys in `project_settings`: | Key | Purpose | |---------------------------|------------------------------------------------------------------| | `analytics_log_path` | absolute path to the access log (e.g. `/var/log/apache2/foo_access.log`) | | `analytics_log_format` | `combined` (Apache) or `nginx` — same field layout for our parser | | `analytics_log_filter` | optional URL-path prefix; lines whose path doesn't start with it are skipped | | `analytics_last_size` | byte cursor — last position read; reset on rotation | | `analytics_last_inode` | inode of the file at last read; mismatch ⇒ rotation detected | | `analytics_imported_at` | last import timestamp (display only) | | `analytics_imported_count`| running total of rows imported (display only) | | `analytics_last_rollup` | last time the rollup + prune ran (throttled to once / 24h) | `bin/import-site-logs.php`: - Iterates active projects (or one with `--project=N`) - Opens each project's log, seeks to last byte cursor - Parses Combined Log Format (works for nginx default too) - Drops asset hits (`.css/.js/.png/...`), non-GETs, and obvious bots (`bot`, `curl`, `wget`, `headless`, ...). Keeps 2xx + 3xx + **404** so the analytics tab can surface broken-path hits. - Hashes UA + IP with a fixed salt (truncated SHA-256, 16 chars) so we can count uniques without retaining raw values - Inserts into `site_visits` (with `status` column tracked from the log line) and audit-logs as `analytics_import` - **Runs the tiered cull** at the end of each project's import (no-op if <24h since last cull) **Tiered rollup pipeline** (`maybeRollupAndPrune()` in the importer): | Age window | Storage | What's preserved | |------------------|----------------------------------|-----------------------------------| | today | `site_visits` (raw events) | full sub-hour timestamps | | 1–90 days | `site_visits` (raw) + rollups | full timestamps, plus rollups | | 91–365 days | `site_visits_hourly` + `_daily` | hour-granular path/status/referrer | | > 365 days | `site_visits_daily` only | day-granular path/status/referrer | Rollups are built nightly (throttled to once per project per 24h) by aggregating raw rows GROUP BY (hour or day, path, status, referrer). The INSERT OR REPLACE on the rollup tables' UNIQUE constraint makes the rollup **idempotent** — re-rolling a day produces the same rows. Aging-out drops raw rows >90d and hourly rows >365d. By the time a row is dropped, the equivalent aggregate is already in the next tier — no count information is lost. Audit-logged as `analytics_rollup`. **Daily-rotating salt for IP hashes.** The importer hashes IPs with `base_salt + visit_date`, so the same IP gets a different `ip_hash` on different days. Within a day, distinct counts are exact; across days, visitors look like new visitors. This makes the stored data anonymized rather than pseudonymized for GDPR purposes — once the salt has rotated past, no one (including the controller) can re-link yesterday's hashes to today's visits. Trade-off: "unique visitors over multiple days" is the sum of per-day unique counts (each visitor counted once per day they visited), not deduplicated across days. UI surfaces this in a tooltip. **Cron entry** (run on the box hosting both HackmanCMS and the web server, as a user with read access to the log files — typically root or a member of `adm`): ```bash */5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php ``` `web/api/analytics_import.php` (auth required) is the same code path with three actions: `run` (the "Import now" button), `reset` (clear cursors so the next run reimports from start), `wipe` (drop all visits + cursors). `web/api/analytics.php` aggregates over a configurable window (7/30/90/365 days) and returns: window + previous-window totals (for delta KPIs), all-time totals, top pages, top referrers, daily series (current + previous period for chart overlay), hour-of-day distribution, top 404s, and status-code mix. **Note:** `web/api/track.php` (the old 1×1 pixel endpoint) is no longer wired into the public route table in `index.php`. The file is left in place as a dormant fallback for cases where the managed site is *not* on the same box — re-add the whitelist line in `index.php` to bring it back. ## Markdown editor (Milkdown) `*.md` and `*.markdown` files open in a **Milkdown** WYSIWYG editor mounted via the Agenda-style `mk-mount.js` pattern. Milkdown is loaded as ESM from `esm.sh`'s pre-compiled `/es2022/` paths — same trick Agenda uses to keep all `@milkdown/*` sub-packages on a single shared `core` instance (otherwise ProseMirror's `SchemaReady` timer fails). The loader lives in `view.php`: ```html ``` `mk-mount.js` auto-mounts on any `