This commit is contained in:
Bashy 2026-05-03 20:56:15 +03:00
commit 60ca58f5ba
80 changed files with 9458 additions and 0 deletions

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# Local SQLite database + WAL/SHM files
data/*.sqlite
data/*.sqlite-shm
data/*.sqlite-wal
# Generated by deploy scripts — version/sha/branch/built fingerprint for the footer
web/BUILD
# Local config overrides (never commit secrets)
config/config.local.php
.env
.env.local
# Logs
*.log
logs/
# Editor / IDE
.DS_Store
.vscode/
.idea/
*.swp
*.swo
*~
# Composer (if/when used)
vendor/
composer.lock
# npm (if a project type ever needs it locally)
node_modules/
# PHP-CGI temp / coverage
.phpunit.result.cache
.phpunit.cache/
# Backups produced locally by the backup endpoint when invoked from CLI
*.bak
*.zip

374
CLAUDE.md Normal file
View file

@ -0,0 +1,374 @@
# 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:<value>` (or `category:<value>`),
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 `<?php elseif ($tab === 'X')` branch in
`views/project/view.php` and (for non-trivial tabs) a `views/project/_tab_X.php`
include. Adding a tab string to a type's `tabs()` without a matching branch
renders an empty pane — update `view.php` and add a `_tab_X.php` partial.
The Files tab respects `?path=...` for deep-linking (used by the theme
manager's "Edit files" button).
## SQL migrations
Files in `sql/` run in sort order. `DB::autoMigrate()` is called on every bootstrap — idempotent, uses `schema_migrations` table.
**Never edit an applied migration.** Add a new file instead.
Tables in play (beyond core `users`/`projects`/`scan_paths`/`settings`):
`project_settings`, `command_history`, `audit_log`, `post_templates`,
`snippets`, `drafts`, `recent_files`, `scheduled_builds`, `link_check_runs`,
`link_check_results`, `site_visits`, `site_visits_hourly`,
`site_visits_daily`, plus a `scratchpad TEXT` column on `projects` (added in
`004_scratchpad.sql`).
## Deployment
| Target | Command | What it does |
|---------|--------------------------|--------------|
| local | `sudo ./deploylocal.sh` | rsync to `/var/www/hackmancms`, chown www-data, migrate, reload Apache (port 8082) |
| local (minimal) | `./bin/deploy.sh local` | rsync to `/var/www/hackmancms`, migrate (no chown/reload) |
| prod | `./bin/deploy.sh prod` | SSH git pull + migrate on `bashy@37.205.12.57:/opt/hackmancms` |
`deploylocal.sh` is the one to use day-to-day on bashyMint — it sets www-data ownership
so the schedules cron and npm/git child processes can write to project paths and to
`data/hackmancms.sqlite`. Plain `./bin/deploy.sh local` exists for parity with prod.
On the server: Apache doc root = `/opt/hackmancms/web`. SQLite at `/opt/hackmancms/data/hackmancms.sqlite`.
Web server process needs write access to `/opt/hackmancms/data/`.
## Apache vhost (server, /opt/hackmancms)
```apache
<VirtualHost *:80>
ServerName hackmancms.bashynx.com
DocumentRoot /opt/hackmancms/web
<Directory /opt/hackmancms/web>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
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
<VirtualHost *:80>
ServerName hackmancms.local
DocumentRoot /var/www/hackmancms/web
<Directory /var/www/hackmancms/web>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
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 |
| 190 days | `site_visits` (raw) + rollups | full timestamps, plus rollups |
| 91365 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
<script type="module">
const H = "https://esm.sh/@milkdown/";
const V = "@7.20.0/es2022/";
Promise.all([
import(H + "core" + V + "core.mjs"),
import(H + "preset-commonmark" + V + "preset-commonmark.mjs"),
import(H + "preset-gfm" + V + "preset-gfm.mjs"),
import(H + "plugin-history" + V + "plugin-history.mjs"),
import(H + "utils" + V + "utils.mjs"),
]).then(([core, cm, gfmPkg, hist, utils]) => {
window.MilkdownKit = { Editor: core.Editor, ... };
window.dispatchEvent(new CustomEvent("milkdown-ready"));
});
</script>
<script src="/assets/js/milkdown-mount.js"></script>
```
`mk-mount.js` auto-mounts on any `<textarea class="mk-mount">` it sees
(MutationObserver), wraps it with a toolbar (Bold/Italic/.../MD-toggle) and
exposes `ta.mkMount = { getContent, setContent, getMode, setMode }`.
**Front matter is NOT fed through Milkdown.** The earlier Milkdown
`plugin-frontmatter` approach mangled YAML on save — Milkdown was rendering
`---\n…\n---` as horizontal rules + paragraph text and round-tripping it
back as bullet lists. We now strip FM in `_openMdEditorTab()` via
`_parseMdSource()` and edit it in a separate small CodeMirror toggled by an
**FM** button injected next to mk-mount's MD button. On save, the FM string
is re-merged with the body. A `tab.hadFm` flag prevents an empty `---`
block from being added to files that didn't originally have one.
**Image URL handling — non-destructive.** Markdown source is left unchanged
(`images/foo.png` stays as-is, so Hexo sees what you typed). A
`MutationObserver` on the rendered ProseMirror DOM rewrites each `<img>`'s
`src` to `/api/files?project_id=<pid>&action=serve&path=source/<relpath>`
just for display. ProseMirror's model is untouched, so `getMarkdown()` on
save returns the original relative paths. See `_attachImgSrcRewriter()`
and `_resolveImagePathForEditor()`.
**Photos banner.** `photos:` (inline `[a, b]`, block list `\n- a\n- b`, or
single-line `photos: foo.jpg`) is parsed off the FM and rendered as a banner
of `<img>` elements **inside** `.ie-mk-body`, prepended above the
ProseMirror content so it reads like the rendered Hexo post. CSS sizes
banner images to 75% width, natural aspect, centered.
**Sizing — JS-driven.** Percentage `min-height` doesn't cascade reliably
through Milkdown's `.milkdown``.editor``.ProseMirror` wrappers, so
`_reflowMdEditor()` measures the live pane (via `ResizeObserver` on
`.md-editor-mount`) and writes explicit pixel `style.height` to the wrap
and body, plus `min-height` on the inner ProseMirror. This is what makes
the body's `overflow-y: auto` fire reliably so long posts scroll within
the canvas.
**Floating Save + Ctrl-S.** Save is a pinned floating pill at the
bottom-right of the pane, hidden via `.d-none` until `tab.dirty` is set
(`_markDirty(id)``_updateSaveBtnState(id)`). Ctrl/Cmd-S anywhere in the
pane saves. The tab strip dirty dot is driven by the same flag.
**Kebab in mk-mount toolbar.** `_injectMkToolbarExtras` appends a
`mk-pane-kebab` dropdown (Delete + Publish for drafts) to the right end of
mk-mount's toolbar after `mk-ready`, so destructive actions live inside the
editor's own row instead of squatting between the file-tab strip and the
canvas. The plain-file (non-md) editor uses the same floating-Save pattern
and exposes Edit/Delete on each row of the file list (no in-editor kebab).
## Editor tab persistence
Open file/post editor tabs are saved to `localStorage` per-project,
per-page (keys `hackmancms_tabs_<pid>_files` and `hackmancms_tabs_<pid>_posts`),
and replayed on page load. Drafts (DB-backed, kind=`draft`) are excluded
from persistence.
## Security notes
- File browser validates all paths stay within the project root (realpath check)
- Command runner only executes the exact `cmd` string defined in the project type class — no user input reaches the shell
- Sessions: httponly + strict mode; regenerated on login

114
README.md Normal file
View file

@ -0,0 +1,114 @@
# HackmanCMS
Lightweight web UI for managing Hexo blogs and other server-side projects.
## Features
- **Dashboard** with project cards, disk-usage badges, recent-activity feed
- **Plugin-based project types** — drop a PHP file into `lib/project-types/` to add a new type
- **Per-project sidebar** with grouped, collapsible navigation; **versioning** shown in the footer
- **Posts editor (Hexo)** — Milkdown WYSIWYG with MD-source toggle, in-canvas photos banner from front-matter `photos:`, image rendering for relative `images/...` paths, image-paste upload to `source/images/`, floating Save (only when dirty), Ctrl/Cmd-S, tab dirty dot, image rewriting kept out of the markdown source so files round-trip cleanly through Hexo
- **Drafts editor** — same Milkdown body editor as posts, with title/slug/folder as inline fields
- **Files editor** — CodeMirror per file; full-pane height; Edit + Delete from a kebab on each list row; floating Save when dirty
- **File browser** path-traversal safe; deep-linkable via `?tab=files&path=...`
- **Command runner** with whitelisted commands per project type
- **Scratchpad** (auto-saving notes), **recent files** panel, **disk usage**, **zip backup** (excludes `node_modules`, `public`, `.git`)
- **Broken link checker** (in Settings tab) — scans posts/pages for dead HTTP links via parallel `curl_multi` HEAD requests
- **Scheduled builds** — cron-style triggers for any project command (requires host cron entry)
- **Theme manager** (Hexo) — list/switch/clone themes, git status + push/pull on each, deep-link to file editor
- **Plugin manager** (Hexo) — list `hexo-*` deps, npm install/uninstall from the UI
- **Site visit analytics** (own tab) — tails the web server's access log into a tiered rollup pipeline (raw 90 days → hourly 365 days → daily forever) with a **daily-rotating salt for IP hashes** so post-rotation data is anonymized for GDPR purposes. Per-project page with KPIs, current-vs-previous-period chart overlay, hour-of-day distribution, top pages, top referrers, top 404s, status-code mix.
- **Tag/category filtering** — click a tag/category in the project Dashboard to jump to Posts pre-filtered
- **Editor tab persistence** — file/post tabs survive sidebar navigation and full reloads
- **Global keyboard shortcuts**`?` for help, `g d/a` to navigate, `n` for new post / `b` to trigger generate on project pages, `Ctrl/Cmd-S` to save the current editor
- **Audit log** for every state-changing action (file/post/draft writes, command runs, project changes, theme/plugin/git/analytics ops)
- Bootstrap 5.3 dark UI, no client-side build step. Vanilla JS; ESM modules pulled from CDN where needed (Milkdown).
## Built-in project types
| Type | Auto-detected by | Type-specific tabs |
|---------|----------------------------------|--------------------|
| Hexo | `_config.yml` + `source/` | Posts, Config, Run, Themes, Plugins, Git |
| Website | `index.html` or `index.php` | Git |
| Storage | `uploads/`, `files/`, `storage/` | Media |
| Generic | fallback | — |
All types share these tabs: **Dashboard**, **Analytics**, **Files**, **Notes**, **Settings**.
## Requirements
- PHP 8.0+ with `pdo_sqlite`, `zip`, and `curl` extensions
- Apache (`mod_rewrite`) or Nginx with the included `.htaccess` rewrite rules
- `git` and `npm` on PATH (for theme + plugin tabs)
- GNU coreutils `du` (for disk usage; standard on Debian/Ubuntu/Mint)
- System cron (only if scheduled builds or analytics importer are used)
- Modern browser with ES modules support (Milkdown is loaded as ESM)
## Quick start
```bash
git clone … /opt/hackmancms
php /opt/hackmancms/bin/migrate.php
```
Configure Apache to serve `/opt/hackmancms/web` (see `CLAUDE.md` for the full vhost config).
Visit the app and create your account on first load.
### Optional: enable scheduled builds
Schedules created in the UI only fire if a host cron entry runs the dispatcher
every minute. Install once, as the same user the web server runs as:
```bash
echo "* * * * * /usr/bin/php /opt/hackmancms/bin/run-schedules.php >/dev/null 2>&1" \
| sudo crontab -u www-data -
```
### Optional: enable site-visit analytics
To pull live page-view data into the Analytics tab, set the access-log path
under each project's Analytics → "Server-log import setup" panel, then install
a host cron entry (as a user that can read the log files):
```bash
*/5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php
```
The importer hashes each visitor's IP with a daily-rotating salt, builds
hourly + daily rollups, and ages out raw events past 90 days and hourly
buckets past 365 days. Daily aggregates are kept forever.
## Adding a project type
Create `lib/project-types/MyType.php`:
```php
<?php
require_once __DIR__ . '/ProjectTypeBase.php';
class MyType extends ProjectTypeBase {
public static function typeSlug(): string { return 'mytype'; }
public static function typeName(): string { return 'My Type'; }
public static function typeIcon(): string { return 'bi-star'; }
public static function tabs(): array {
return ['dashboard', 'analytics', 'files', 'run', 'notes', 'settings'];
}
public static function commands(): array {
return [
['id' => 'build', 'label' => 'Build', 'cmd' => 'make build'],
];
}
public static function detectFromPath(string $path): bool {
return file_exists($path . '/Makefile');
}
}
```
No other changes needed — it appears in the UI on next load.
## License
Internal tool, no license attached.

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.3

75
bin/deploy.sh Executable file
View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:-local}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
write_build_file() {
# $1 = repo dir, $2 = web dir
local repo="$1" webdir="$2"
local vmm vbump bnum version sha branch built
vmm=$(tr -d '[:space:]' < "$repo/VERSION" 2>/dev/null || echo '0.0')
vbump=$(git -C "$repo" log -1 --format=%H -- VERSION 2>/dev/null || echo '')
if [ -n "$vbump" ]; then
bnum=$(git -C "$repo" rev-list --count "${vbump}..HEAD" 2>/dev/null || echo '0')
else
bnum=$(git -C "$repo" rev-list --count HEAD 2>/dev/null || echo '0')
fi
version="${vmm}.${bnum}"
sha=$(git -C "$repo" rev-parse --short HEAD 2>/dev/null || echo 'unknown')
branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')
built=$(date '+%Y-%m-%d %H:%M:%S')
cat > "$webdir/BUILD" <<BUILD
version=$version
sha=$sha
branch=$branch
built=$built
BUILD
echo " ✓ BUILD: v$version · $sha · $built"
}
case "$TARGET" in
local)
DEST="/var/www/hackmancms"
echo "→ local deploy to $DEST"
sudo mkdir -p "$DEST"
sudo rsync -av --delete \
--exclude='.git' \
--exclude='data/' \
--exclude='config/config.local.php' \
--exclude='web/BUILD' \
"$REPO/" "$DEST/"
write_build_file "$REPO" "$DEST/web"
sudo php "$DEST/bin/migrate.php"
echo "→ done"
;;
prod)
# Code lives at /opt/hackmancms on the server; Apache root = /opt/hackmancms/web
echo "→ prod deploy"
ssh bashy@37.205.12.57 'set -e
cd /opt/hackmancms
git pull --ff-only
VMM=$(tr -d "[:space:]" < VERSION 2>/dev/null || echo "0.0")
VBUMP=$(git log -1 --format=%H -- VERSION 2>/dev/null || echo "")
if [ -n "$VBUMP" ]; then
BNUM=$(git rev-list --count "${VBUMP}..HEAD" 2>/dev/null || echo "0")
else
BNUM=$(git rev-list --count HEAD 2>/dev/null || echo "0")
fi
VERSION="${VMM}.${BNUM}"
SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
BUILT=$(date "+%Y-%m-%d %H:%M:%S")
printf "version=%s\nsha=%s\nbranch=%s\nbuilt=%s\n" "$VERSION" "$SHA" "$BRANCH" "$BUILT" > web/BUILD
echo " BUILD: v$VERSION · $SHA · $BUILT"
php bin/migrate.php
'
echo "→ done"
;;
*)
echo "Usage: $0 [local|prod]"
exit 1
;;
esac

289
bin/import-site-logs.php Normal file
View file

@ -0,0 +1,289 @@
<?php
/**
* Tail web server access logs into site_visits.
*
* Run as the user that can read the configured log files (typically root or
* a member of adm). Cron entry every 5 minutes, all projects:
*
* *\/5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php
*
* Or one project at a time (used by the "Import now" button):
*
* php /opt/hackmancms/bin/import-site-logs.php --project=42
*
* Tracks per-project state in project_settings:
* analytics_log_path configured log file
* analytics_log_format 'combined' | 'nginx'
* analytics_log_filter optional path prefix filter
* analytics_last_size last byte position
* analytics_last_inode last file inode (for rotation detection)
* analytics_imported_at last import timestamp
* analytics_imported_count cumulative rows imported
*/
if (php_sapi_name() !== 'cli' && empty($_GET['project_id'])) {
// Allow inclusion via API endpoint too.
}
if (!defined('ROOT')) {
define('ROOT', dirname(__DIR__));
require ROOT . '/lib/bootstrap.php';
}
$onlyProject = null;
foreach ($argv ?? [] as $a) {
if (preg_match('/^--project=(\d+)$/', $a, $m)) $onlyProject = (int)$m[1];
}
$result = importAllProjects($db, $onlyProject);
if (php_sapi_name() === 'cli') {
foreach ($result as $r) {
printf("[%s] project=%d imported=%d skipped=%d %s\n",
date('Y-m-d H:i:s'), $r['project_id'],
$r['imported'], $r['skipped'],
$r['error'] ? 'ERROR: ' . $r['error'] : '');
}
}
function importAllProjects(PDO $db, ?int $onlyProject = null): array {
$args = [];
$sql = 'SELECT id, name FROM projects WHERE is_active = 1';
if ($onlyProject !== null) { $sql .= ' AND id = ?'; $args[] = $onlyProject; }
$st = $db->prepare($sql); $st->execute($args);
$out = [];
foreach ($st->fetchAll() as $p) {
$out[] = importOne($db, (int)$p['id']);
}
return $out;
}
function importOne(PDO $db, int $pid): array {
$cfg = readProjectSettings($db, $pid);
$path = trim((string)($cfg['analytics_log_path'] ?? ''));
$fmt = (string)($cfg['analytics_log_format'] ?? 'combined');
$pre = trim((string)($cfg['analytics_log_filter'] ?? ''));
if ($path === '') return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0, 'error' => null];
if (!is_readable($path)) {
return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0,
'error' => "log not readable: $path"];
}
$stat = stat($path);
$size = $stat['size'] ?? 0;
$inode = $stat['ino'] ?? 0;
$lastSize = (int)($cfg['analytics_last_size'] ?? 0);
$lastInode = (int)($cfg['analytics_last_inode'] ?? 0);
// Detect rotation: file replaced or shrank.
if ($inode !== $lastInode || $size < $lastSize) {
$lastSize = 0;
}
if ($size === $lastSize) {
return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0, 'error' => null];
}
$fh = @fopen($path, 'rb');
if (!$fh) return ['project_id' => $pid, 'imported' => 0, 'skipped' => 0,
'error' => 'fopen failed'];
fseek($fh, $lastSize);
$imported = 0; $skipped = 0;
$ins = $db->prepare(
'INSERT INTO site_visits (project_id, path, referrer, ua_hash, ip_hash, status, visited_at)
VALUES (?, ?, ?, ?, ?, ?, ?)');
$baseSalt = 'hackmancms-site-visits';
$db->beginTransaction();
while (!feof($fh)) {
$line = fgets($fh);
if ($line === false) break;
$row = parseLogLine($line, $fmt);
if (!$row) { $skipped++; continue; }
if (!isInteresting($row, $pre)) { $skipped++; continue; }
// Rotating salt: hash with the visit's date, so the same IP gets a
// different hash on different days. Same-day uniques are exact;
// cross-day visitors look like new visitors → counts cease to be PII.
$daySalt = $baseSalt . substr($row['ts'], 0, 10);
$uaHash = $row['ua'] ? substr(hash('sha256', $daySalt . $row['ua']), 0, 16) : null;
$ipHash = $row['ip'] ? substr(hash('sha256', $daySalt . $row['ip']), 0, 16) : null;
$ins->execute([$pid, $row['path'], $row['ref'] ?: null, $uaHash, $ipHash,
$row['status'], $row['ts']]);
$imported++;
}
$newSize = ftell($fh);
fclose($fh);
$db->commit();
$totalCount = (int)($cfg['analytics_imported_count'] ?? 0) + $imported;
saveSetting($db, $pid, 'analytics_last_size', (string)$newSize);
saveSetting($db, $pid, 'analytics_last_inode', (string)$inode);
saveSetting($db, $pid, 'analytics_imported_at', date('Y-m-d H:i:s'));
saveSetting($db, $pid, 'analytics_imported_count', (string)$totalCount);
Audit::log($db, 'analytics_import', $pid, "rows=$imported skipped=$skipped");
// Roll up + age out (throttled to once per 24h per project).
$rollupStats = maybeRollupAndPrune($db, $pid, $cfg);
return ['project_id' => $pid, 'imported' => $imported, 'skipped' => $skipped,
'rollup' => $rollupStats, 'error' => null];
}
/**
* Build hourly + daily rollups for any finalized days (date < today), then
* age out raw events older than 90d and hourly buckets older than 365d.
*
* raw events 090 days full hour-level + visit-level detail
* site_visits_hourly 90365 hour buckets, pulled from raw before drop
* site_visits_daily 365+ day buckets, retained forever
*
* Throttled to once per 24h per project. Rollups are idempotent via
* UNIQUE constraint on (project, bucket, path, status, referrer) +
* INSERT OR REPLACE re-running rolls produces the same rows.
*
* Aging-out is safe: a row is only dropped from raw after the daily/hourly
* row it contributes to has been written.
*/
function maybeRollupAndPrune(PDO $db, int $pid, array $cfg): array {
$stats = [
'skipped' => false,
'hourly_buckets' => 0,
'daily_buckets' => 0,
'raw_dropped' => 0,
'hourly_dropped' => 0,
];
$last = $cfg['analytics_last_rollup'] ?? null;
if ($last && strtotime($last) > time() - 86400) {
$stats['skipped'] = true;
return $stats;
}
$today = date('Y-m-d');
// Find days in raw that aren't today; those are eligible to be rolled up.
$first = $db->prepare(
"SELECT MIN(date(visited_at)) FROM site_visits
WHERE project_id = ? AND date(visited_at) < ?");
$first->execute([$pid, $today]);
$firstDay = $first->fetchColumn();
if ($firstDay) {
$hourlyIns = $db->prepare(
"INSERT OR REPLACE INTO site_visits_hourly
(project_id, bucket_at, path, status, referrer, views, uniques)
SELECT ?, strftime('%Y-%m-%d %H:00:00', visited_at),
path, status, COALESCE(referrer, ''),
COUNT(*), COUNT(DISTINCT ip_hash)
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ?
GROUP BY strftime('%Y-%m-%d %H', visited_at), path, status, COALESCE(referrer, '')");
$dailyIns = $db->prepare(
"INSERT OR REPLACE INTO site_visits_daily
(project_id, bucket_at, path, status, referrer, views, uniques)
SELECT ?, ?, path, status, COALESCE(referrer, ''),
COUNT(*), COUNT(DISTINCT ip_hash)
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ?
GROUP BY path, status, COALESCE(referrer, '')");
$d = $firstDay;
$endDay = date('Y-m-d', strtotime($today . ' -1 day')); // yesterday
while ($d <= $endDay) {
$hourlyIns->execute([$pid, $pid, $d]);
$stats['hourly_buckets'] += $hourlyIns->rowCount();
$dailyIns->execute([$pid, $d, $pid, $d]);
$stats['daily_buckets'] += $dailyIns->rowCount();
$d = date('Y-m-d', strtotime($d . ' +1 day'));
}
}
// Raw older than 90 days — already preserved in daily + hourly rollups.
$st = $db->prepare(
"DELETE FROM site_visits
WHERE project_id = ? AND visited_at < datetime('now', '-90 days')");
$st->execute([$pid]);
$stats['raw_dropped'] = $st->rowCount();
// Hourly older than 365 days — already preserved in daily rollups.
$st = $db->prepare(
"DELETE FROM site_visits_hourly
WHERE project_id = ? AND bucket_at < datetime('now', '-365 days')");
$st->execute([$pid]);
$stats['hourly_dropped'] = $st->rowCount();
saveSetting($db, $pid, 'analytics_last_rollup', date('Y-m-d H:i:s'));
if ($stats['hourly_buckets'] || $stats['daily_buckets']
|| $stats['raw_dropped'] || $stats['hourly_dropped']) {
Audit::log($db, 'analytics_rollup', $pid, json_encode($stats));
}
return $stats;
}
function readProjectSettings(PDO $db, int $pid): array {
$st = $db->prepare('SELECT key, value FROM project_settings WHERE project_id = ?');
$st->execute([$pid]);
$out = [];
foreach ($st->fetchAll() as $r) $out[$r['key']] = $r['value'];
return $out;
}
function saveSetting(PDO $db, int $pid, string $key, ?string $value): void {
$db->prepare('INSERT OR REPLACE INTO project_settings (project_id, key, value)
VALUES (?, ?, ?)')->execute([$pid, $key, $value]);
}
/**
* Parse a single log line. Combined log format and nginx default share the same
* field layout IP - - [ts] "REQ" status size "ref" "ua" so one regex covers
* both for our purposes.
*/
function parseLogLine(string $line, string $fmt): ?array {
$line = rtrim($line);
if ($line === '') return null;
// Combined / nginx default.
if (!preg_match(
'/^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) [^"]*" (\d+) \S+ "([^"]*)" "([^"]*)"/',
$line, $m
)) return null;
[, $ip, $tsRaw, $method, $url, $status, $ref, $ua] = $m;
$ts = parseLogDate($tsRaw);
if (!$ts) return null;
$path = parse_url($url, PHP_URL_PATH) ?: $url;
return [
'ip' => $ip,
'ts' => $ts,
'method' => strtoupper($method),
'path' => $path,
'status' => (int)$status,
'ref' => $ref === '-' ? '' : $ref,
'ua' => $ua === '-' ? '' : $ua,
];
}
function parseLogDate(string $raw): ?string {
// 03/May/2026:08:34:56 +0200
$dt = DateTime::createFromFormat('d/M/Y:H:i:s O', $raw);
return $dt ? $dt->format('Y-m-d H:i:s') : null;
}
function isInteresting(array $row, string $pathPrefix): bool {
if ($row['method'] !== 'GET') return false;
// Keep 2xx, 3xx, AND 404 (we want to surface broken-link hits separately).
// Drop 5xx, 401/403/etc.
if ($row['status'] < 200) return false;
if ($row['status'] >= 400 && $row['status'] !== 404) return false;
if ($pathPrefix !== '' && !str_starts_with($row['path'], $pathPrefix)) return false;
// Skip asset extensions
$ext = strtolower((string)pathinfo($row['path'], PATHINFO_EXTENSION));
static $asset = ['css','js','png','jpg','jpeg','gif','webp','svg','ico',
'woff','woff2','ttf','eot','otf','map','mp4','webm',
'mp3','wav','ogg','pdf','zip','tar','gz','xml','txt'];
if (in_array($ext, $asset, true)) return false;
// Skip obvious bots
$ua = strtolower((string)$row['ua']);
foreach (['bot','crawl','spider','curl','wget','headless','scrapy','go-http','python-requests','okhttp'] as $needle) {
if (str_contains($ua, $needle)) return false;
}
return true;
}

10
bin/migrate.php Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env php
<?php
define('ROOT', dirname(__DIR__));
$config = require ROOT . '/config/config.php';
require_once ROOT . '/lib/DB.php';
DB::connect($config['db_path']);
$applied = DB::autoMigrate(ROOT . '/sql');
foreach ($applied as $v) echo " applied: $v\n";
if (!$applied) echo " Nothing to do.\n";

86
bin/run-schedules.php Normal file
View file

@ -0,0 +1,86 @@
<?php
/**
* Cron-driven scheduled build runner.
*
* Install once on the host (as the apache user so it can write data/):
* * * * * * /usr/bin/php /opt/hackmancms/bin/run-schedules.php
*
* Each run: enumerate enabled schedules, run any whose cron expression matches
* the current minute (and that haven't already run within the last minute),
* record output to command_history, update last_run_at/last_status.
*/
define('ROOT', dirname(__DIR__));
require ROOT . '/lib/bootstrap.php';
ProjectTypes::load();
$now = time();
$now = $now - ($now % 60); // truncate to minute boundary
$nowSql = date('Y-m-d H:i:s', $now);
$schedules = $db->query(
'SELECT s.*, p.path AS project_path, p.type AS project_type
FROM scheduled_builds s
JOIN projects p ON p.id = s.project_id
WHERE s.is_enabled = 1 AND p.is_active = 1'
)->fetchAll();
foreach ($schedules as $s) {
if (!cronMatches($s['cron'], $now)) continue;
if ($s['last_run_at'] && (strtotime($s['last_run_at']) >= $now)) continue;
$type = ProjectTypes::get($s['project_type']);
if (!$type) continue;
$cmd = null;
foreach ($type::commands() as $c) if ($c['id'] === $s['cmd_id']) { $cmd = $c; break; }
if (!$cmd) continue;
$base = realpath($s['project_path']);
if (!$base || !is_dir($base)) continue;
fwrite(STDOUT, "[$nowSql] running schedule #{$s['id']} cmd={$cmd['id']} project={$s['project_id']}\n");
exec('cd ' . escapeshellarg($base) . ' && ' . $cmd['cmd'] . ' 2>&1', $out, $rc);
$outText = implode("\n", $out);
$status = $rc === 0 ? 'ok' : ('exit=' . $rc);
$db->prepare('INSERT INTO command_history (project_id, cmd_id, cmd, output, exit_code)
VALUES (?, ?, ?, ?, ?)')
->execute([$s['project_id'], $s['cmd_id'], $cmd['cmd'], $outText, $rc]);
$db->prepare('UPDATE scheduled_builds SET last_run_at = ?, last_status = ? WHERE id = ?')
->execute([$nowSql, $status, $s['id']]);
Audit::log($db, 'scheduled_build', $s['project_id'], "{$cmd['id']} status=$status");
unset($out);
}
/** Match a 5-field cron expression against a unix timestamp. */
function cronMatches(string $expr, int $ts): bool {
$fields = preg_split('/\s+/', trim($expr));
if (count($fields) !== 5) return false;
[$min, $hour, $dom, $mon, $dow] = $fields;
$t = getdate($ts);
return cronField($min, $t['minutes'], 0, 59)
&& cronField($hour, $t['hours'], 0, 23)
&& cronField($dom, $t['mday'], 1, 31)
&& cronField($mon, $t['mon'], 1, 12)
&& cronField($dow, $t['wday'], 0, 6); // 0=Sun
}
function cronField(string $field, int $value, int $min, int $max): bool {
foreach (explode(',', $field) as $part) {
$step = 1;
if (str_contains($part, '/')) { [$part, $step] = explode('/', $part, 2); $step = max(1, (int)$step); }
if ($part === '*' || $part === '') {
if (($value - $min) % $step === 0) return true;
continue;
}
if (str_contains($part, '-')) {
[$a, $b] = array_map('intval', explode('-', $part, 2));
} else {
$a = $b = (int)$part;
}
for ($v = $a; $v <= $b; $v++) {
if ((($v - $a) % $step === 0) && $v === $value) return true;
}
}
return false;
}

7
config/config.php Normal file
View file

@ -0,0 +1,7 @@
<?php
return [
'app_name' => 'HackmanCMS',
'db_path' => dirname(__DIR__) . '/data/hackmancms.sqlite',
'session_name' => 'hackmancms',
'session_lifetime' => 86400 * 30,
];

0
data/.gitkeep Normal file
View file

52
deploylocal.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
REPO="$(cd "$(dirname "$0")" && pwd)"
DEST="/var/www/hackmancms"
PORT=8082
VHOST="/etc/apache2/sites-available/hackmancms.conf"
# ── sync ──────────────────────────────────────────────────────────────────────
echo "→ syncing to $DEST"
sudo rsync -a --delete \
--exclude='.git' \
--exclude='data/' \
--exclude='config/config.local.php' \
--exclude='deploylocal.sh' \
--exclude='web/BUILD' \
"$REPO/" "$DEST/"
# ── BUILD file ────────────────────────────────────────────────────────────────
# VERSION carries MAJOR.MINOR ("0.3"); patch number is the commit count since
# the last touch of VERSION, so bumping resets to .0 and each commit
# auto-increments. SHA + branch + ISO build timestamp ride along for the
# footer. Mirrors /opt/agenda/deploy.sh — keeps the formatting identical.
VERSION_FILE="${REPO}/VERSION"
VERSION_MM=$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]' || echo '0.0')
VERSION_BUMP_SHA=$(git -C "$REPO" log -1 --format=%H -- VERSION 2>/dev/null || echo '')
if [ -n "$VERSION_BUMP_SHA" ]; then
BUILD_NUM=$(git -C "$REPO" rev-list --count "${VERSION_BUMP_SHA}..HEAD" 2>/dev/null || echo '0')
else
BUILD_NUM=$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo '0')
fi
VERSION="${VERSION_MM}.${BUILD_NUM}"
GIT_SHA=$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo 'unknown')
GIT_BRANCH=$(git -C "$REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')
BUILT_AT=$(date '+%Y-%m-%d %H:%M:%S')
sudo tee "$DEST/web/BUILD" >/dev/null <<BUILD
version=$VERSION
sha=$GIT_SHA
branch=$GIT_BRANCH
built=$BUILT_AT
BUILD
echo " ✓ BUILD: v$VERSION · $GIT_SHA · $BUILT_AT"
# ── migrate ───────────────────────────────────────────────────────────────────
echo "→ running migrations"
sudo chown -R www-data:www-data "$DEST/"
sudo -u www-data php "$DEST/bin/migrate.php"
# ── reload apache ─────────────────────────────────────────────────────────────
sudo systemctl reload apache2
echo "→ done — http://localhost:$PORT"

9
lib/Audit.php Normal file
View file

@ -0,0 +1,9 @@
<?php
class Audit {
public static function log(PDO $db, string $action, ?int $project_id = null, ?string $detail = null): void {
try {
$db->prepare('INSERT INTO audit_log (user_id, project_id, action, detail, ip) VALUES (?, ?, ?, ?, ?)')
->execute([$_SESSION['user_id'] ?? null, $project_id, $action, $detail, $_SERVER['REMOTE_ADDR'] ?? null]);
} catch (Exception $e) { /* don't let audit failures break the main op */ }
}
}

46
lib/Auth.php Normal file
View file

@ -0,0 +1,46 @@
<?php
class Auth {
public static function check(): bool {
return !empty($_SESSION['user_id']);
}
public static function requireLogin(): void {
if (!self::check()) {
header('Location: /login');
exit;
}
}
public static function login(PDO $db, string $username, string $password): bool {
$stmt = $db->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $username;
return true;
}
public static function logout(): void {
$_SESSION = [];
session_destroy();
}
public static function currentUser(): ?array {
if (!self::check()) return null;
return ['id' => $_SESSION['user_id'], 'username' => $_SESSION['username']];
}
public static function createUser(PDO $db, string $username, string $password): int {
$stmt = $db->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
$stmt->execute([$username, password_hash($password, PASSWORD_BCRYPT)]);
return (int)$db->lastInsertId();
}
public static function hasUsers(PDO $db): bool {
return (int)$db->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
}
}

41
lib/DB.php Normal file
View file

@ -0,0 +1,41 @@
<?php
class DB {
private static ?PDO $instance = null;
public static function connect(string $path): PDO {
if (self::$instance === null) {
self::$instance = new PDO('sqlite:' . $path, options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
self::$instance->exec('PRAGMA foreign_keys = ON');
self::$instance->exec('PRAGMA journal_mode = WAL');
}
return self::$instance;
}
public static function get(): PDO {
return self::$instance ?? throw new RuntimeException('DB not connected');
}
public static function autoMigrate(string $sqlDir): array {
$db = self::get();
$db->exec("CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
$files = glob($sqlDir . '/*.sql');
sort($files);
$applied = [];
foreach ($files as $file) {
$version = basename($file, '.sql');
if ($db->query("SELECT 1 FROM schema_migrations WHERE version = " . $db->quote($version))->fetch()) {
continue;
}
$db->exec(file_get_contents($file));
$db->exec("INSERT INTO schema_migrations (version) VALUES (" . $db->quote($version) . ")");
$applied[] = $version;
}
return $applied;
}
}

39
lib/ProjectTypes.php Normal file
View file

@ -0,0 +1,39 @@
<?php
class ProjectTypes {
private static array $types = [];
public static function load(): void {
if (!empty(self::$types)) return;
require_once ROOT . '/lib/project-types/ProjectTypeBase.php';
foreach (glob(ROOT . '/lib/project-types/*.php') as $file) {
if (basename($file) === 'ProjectTypeBase.php') continue;
require_once $file;
}
foreach (get_declared_classes() as $class) {
if (is_subclass_of($class, 'ProjectTypeBase')) {
self::$types[$class::typeSlug()] = $class;
}
}
}
public static function all(): array { return self::$types; }
public static function get(string $slug): ?string {
return self::$types[$slug] ?? null;
}
public static function detect(string $path): string {
foreach (self::$types as $slug => $class) {
if ($slug !== 'generic' && $class::detectFromPath($path)) return $slug;
}
return 'generic';
}
public static function forSelect(): array {
return array_values(array_map(fn($class) => [
'slug' => $class::typeSlug(),
'name' => $class::typeName(),
'icon' => $class::typeIcon(),
], self::$types));
}
}

38
lib/bootstrap.php Normal file
View file

@ -0,0 +1,38 @@
<?php
if (!defined('ROOT')) define('ROOT', dirname(__DIR__));
$config = require ROOT . '/config/config.php';
ini_set('session.cookie_httponly', '1');
ini_set('session.use_strict_mode', '1');
session_name($config['session_name']);
session_start();
require_once ROOT . '/lib/DB.php';
require_once ROOT . '/lib/Auth.php';
require_once ROOT . '/lib/ProjectTypes.php';
require_once ROOT . '/lib/Audit.php';
$db = DB::connect($config['db_path']);
DB::autoMigrate(ROOT . '/sql');
/**
* Returns the deploy fingerprint as ['version','sha','branch','built'] read
* from web/BUILD the file is generated by deploy.sh / deploylocal.sh and
* never committed (see .gitignore). Falls back to 'dev' if missing.
*
* Mirrors /opt/agenda/web/lib/layout.php:buildInfo().
*/
function buildInfo(): array {
static $info = null;
if ($info !== null) return $info;
$info = ['version' => 'dev', 'sha' => '', 'branch' => '', 'built' => ''];
$file = ROOT . '/web/BUILD';
if (!is_readable($file)) return $info;
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (strpos($line, '=') === false) continue;
[$k, $v] = array_map('trim', explode('=', $line, 2));
if (isset($info[$k])) $info[$k] = $v;
}
return $info;
}

View file

@ -0,0 +1,9 @@
<?php
require_once __DIR__ . '/ProjectTypeBase.php';
class GenericProject extends ProjectTypeBase {
public static function typeSlug(): string { return 'generic'; }
public static function typeName(): string { return 'Generic'; }
public static function typeIcon(): string { return 'bi-folder'; }
public static function description(): string { return 'Generic project directory'; }
}

View file

@ -0,0 +1,29 @@
<?php
require_once __DIR__ . '/ProjectTypeBase.php';
class HexoProject extends ProjectTypeBase {
public static function typeSlug(): string { return 'hexo'; }
public static function typeName(): string { return 'Hexo'; }
public static function typeIcon(): string { return 'bi-hexagon-fill'; }
public static function description(): string { return 'Hexo static site generator'; }
public static function tabs(): array {
return ['dashboard', 'analytics', 'posts', 'config', 'files',
'run', 'themes', 'plugins', 'git',
'notes', 'settings'];
}
public static function commands(): array {
return [
['id' => 'generate', 'label' => 'Generate', 'cmd' => 'hexo generate'],
['id' => 'clean', 'label' => 'Clean', 'cmd' => 'hexo clean'],
['id' => 'deploy', 'label' => 'Deploy', 'cmd' => 'hexo deploy'],
['id' => 'version', 'label' => 'Hexo version','cmd' => 'hexo version'],
];
}
public static function detectFromPath(string $path): bool {
return file_exists($path . '/_config.yml')
&& (is_dir($path . '/source') || is_dir($path . '/themes'));
}
}

View file

@ -0,0 +1,25 @@
<?php
abstract class ProjectTypeBase {
abstract public static function typeSlug(): string;
abstract public static function typeName(): string;
abstract public static function typeIcon(): string;
/** Tabs shown on the project page, in order */
public static function tabs(): array {
return ['dashboard', 'analytics', 'files', 'notes', 'settings'];
}
/** Whitelisted commands available in the command runner */
public static function commands(): array {
return [];
}
/** Return true if this type can be auto-detected from the given path */
public static function detectFromPath(string $path): bool {
return false;
}
public static function description(): string {
return '';
}
}

View file

@ -0,0 +1,17 @@
<?php
require_once __DIR__ . '/ProjectTypeBase.php';
class StorageProject extends ProjectTypeBase {
public static function typeSlug(): string { return 'storage'; }
public static function typeName(): string { return 'Storage'; }
public static function typeIcon(): string { return 'bi-hdd'; }
public static function description(): string { return 'File storage directory'; }
public static function tabs(): array {
return ['dashboard', 'analytics', 'media', 'files', 'notes', 'settings'];
}
public static function detectFromPath(string $path): bool {
return is_dir($path . '/uploads') || is_dir($path . '/files') || is_dir($path . '/storage');
}
}

View file

@ -0,0 +1,17 @@
<?php
require_once __DIR__ . '/ProjectTypeBase.php';
class WebsiteProject extends ProjectTypeBase {
public static function typeSlug(): string { return 'website'; }
public static function typeName(): string { return 'Website'; }
public static function typeIcon(): string { return 'bi-globe'; }
public static function description(): string { return 'Static or PHP website'; }
public static function tabs(): array {
return ['dashboard', 'analytics', 'files', 'git', 'notes', 'settings'];
}
public static function detectFromPath(string $path): bool {
return file_exists($path . '/index.html') || file_exists($path . '/index.php');
}
}

29
sql/001_initial.sql Normal file
View file

@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'generic',
url TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scan_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
depth INTEGER NOT NULL DEFAULT 2,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);

46
sql/002_milestone4.sql Normal file
View file

@ -0,0 +1,46 @@
ALTER TABLE projects ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
cmd_id TEXT NOT NULL,
cmd TEXT NOT NULL,
output TEXT,
exit_code INTEGER,
run_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
project_id INTEGER,
action TEXT NOT NULL,
detail TEXT,
ip TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS post_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'post',
content TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS project_settings (
project_id INTEGER NOT NULL REFERENCES projects(id),
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (project_id, key)
);

11
sql/003_db_drafts.sql Normal file
View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS drafts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
title TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL DEFAULT '',
frontmatter TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

1
sql/004_scratchpad.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE projects ADD COLUMN scratchpad TEXT NOT NULL DEFAULT '';

44
sql/005_milestone5.sql Normal file
View file

@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS recent_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
path TEXT NOT NULL,
opened_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, path)
);
CREATE INDEX IF NOT EXISTS idx_recent_files_project ON recent_files(project_id, opened_at DESC);
CREATE TABLE IF NOT EXISTS scheduled_builds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
cmd_id TEXT NOT NULL,
cron TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
last_run_at DATETIME,
last_status TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_scheduled_builds_project ON scheduled_builds(project_id);
CREATE TABLE IF NOT EXISTS link_check_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME,
total_links INTEGER DEFAULT 0,
broken INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS link_check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES link_check_runs(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id),
url TEXT NOT NULL,
source TEXT NOT NULL,
status_code INTEGER,
error TEXT,
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_link_results_run ON link_check_results(run_id);

15
sql/006_analytics.sql Normal file
View file

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS site_visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
path TEXT NOT NULL,
referrer TEXT,
ua_hash TEXT,
ip_hash TEXT,
visited_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_visits_project_time
ON site_visits(project_id, visited_at DESC);
CREATE INDEX IF NOT EXISTS idx_visits_project_path
ON site_visits(project_id, path);

View file

@ -0,0 +1,5 @@
-- Track HTTP status on each visit so analytics can split 200s from 404s.
ALTER TABLE site_visits ADD COLUMN status INTEGER NOT NULL DEFAULT 200;
CREATE INDEX IF NOT EXISTS idx_visits_project_status
ON site_visits(project_id, status);

View file

@ -0,0 +1,37 @@
-- Hourly rollup: views + uniques per (project, hour, path, status, referrer).
CREATE TABLE IF NOT EXISTS site_visits_hourly (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
bucket_at DATETIME NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 200,
referrer TEXT NOT NULL DEFAULT '',
views INTEGER NOT NULL DEFAULT 0,
uniques INTEGER NOT NULL DEFAULT 0,
UNIQUE(project_id, bucket_at, path, status, referrer)
);
CREATE INDEX IF NOT EXISTS idx_visits_hourly_project_time
ON site_visits_hourly(project_id, bucket_at);
CREATE INDEX IF NOT EXISTS idx_visits_hourly_project_status
ON site_visits_hourly(project_id, status);
-- Daily rollup: views + uniques per (project, day, path, status, referrer).
-- "Uniques" here is "distinct ip_hash within the day" — and since the salt
-- rotates daily, that hash is meaningful only within the bucket's day.
CREATE TABLE IF NOT EXISTS site_visits_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
bucket_at DATE NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 200,
referrer TEXT NOT NULL DEFAULT '',
views INTEGER NOT NULL DEFAULT 0,
uniques INTEGER NOT NULL DEFAULT 0,
UNIQUE(project_id, bucket_at, path, status, referrer)
);
CREATE INDEX IF NOT EXISTS idx_visits_daily_project_time
ON site_visits_daily(project_id, bucket_at);
CREATE INDEX IF NOT EXISTS idx_visits_daily_project_status
ON site_visits_daily(project_id, status);

44
views/_footer.php Normal file
View file

@ -0,0 +1,44 @@
</main>
<?php $b = buildInfo(); ?>
<footer class="text-center text-muted small py-3 border-top mt-4">
HackmanCMS
<strong>v<?= htmlspecialchars($b['version']) ?></strong>
<?php if ($b['sha'] !== ''): ?>
&middot; <code><?= htmlspecialchars($b['sha']) ?></code>
<?php endif; ?>
<?php if ($b['built'] !== ''): ?>
&middot; build <?= htmlspecialchars($b['built']) ?>
<?php endif; ?>
</footer>
<!-- Keyboard shortcut help (triggered with `?`) -->
<div class="modal fade" id="shortcutsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-keyboard me-1"></i>Keyboard shortcuts</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body small">
<table class="table table-sm mb-0">
<tbody>
<tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
<tr><td><kbd>g</kbd> <kbd>d</kbd></td><td>Go to dashboard</td></tr>
<tr><td><kbd>g</kbd> <kbd>s</kbd></td><td>Go to settings</td></tr>
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>Go to audit log</td></tr>
<tr><td><kbd>n</kbd></td><td>New post / draft (project page)</td></tr>
<tr><td><kbd>b</kbd></td><td>Trigger generate (Hexo project page)</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close any open modal</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php if (!empty($extra_scripts)) echo $extra_scripts; ?>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
</body>
</html>

65
views/_header.php Normal file
View file

@ -0,0 +1,65 @@
<?php
$_nav_active = $nav_active ?? '';
$_page_title = isset($page_title) ? htmlspecialchars($page_title) . ' — ' : '';
?>
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $_page_title ?>HackmanCMS</title>
<link rel="icon" type="image/png" href="/assets/img/logo.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body>
<nav class="navbar navbar-expand-lg border-bottom px-3">
<a class="navbar-brand fw-bold" href="/">
<img src="/assets/img/logo.png" alt="" width="24" height="24" class="me-2">HackmanCMS
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMain">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link <?= $_nav_active === 'dashboard' ? 'active' : '' ?>" href="/">
<i class="bi bi-grid-3x3-gap me-1"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $_nav_active === 'audit' ? 'active' : '' ?>" href="/audit">
<i class="bi bi-journal-text me-1"></i>Audit
</a>
</li>
</ul>
<?php $user = Auth::currentUser(); ?>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i><?= htmlspecialchars($user['username'] ?? '') ?>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="/settings">
<i class="bi bi-gear me-2"></i>Settings
</a>
</li>
<li>
<a class="dropdown-item" href="/audit">
<i class="bi bi-journal-text me-2"></i>Audit
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="/api/auth?action=logout">
<i class="bi bi-box-arrow-right me-2"></i>Log out
</a>
</li>
</ul>
</li>
</ul>
</div>
</nav>
<main class="container-fluid py-4">

31
views/audit.php Normal file
View file

@ -0,0 +1,31 @@
<?php
$page_title = 'Audit Log';
$nav_active = 'audit';
include ROOT . '/views/_header.php';
?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Audit Log</h2>
<div class="d-flex gap-2 align-items-center">
<select id="auditProjectFilter" class="form-select form-select-sm" style="width:auto">
<option value="">All projects</option>
<?php foreach ($db->query('SELECT id, name FROM projects WHERE is_active = 1 ORDER BY name') as $p): ?>
<option value="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div id="auditTable">
<div class="text-muted small">Loading…</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-sm btn-outline-secondary d-none" id="auditPrev">
<i class="bi bi-chevron-left me-1"></i>Prev
</button>
<button class="btn btn-sm btn-outline-secondary d-none" id="auditNext">
Next<i class="bi bi-chevron-right ms-1"></i>
</button>
</div>
<?php include ROOT . '/views/_footer.php'; ?>

128
views/dashboard.php Normal file
View file

@ -0,0 +1,128 @@
<?php
$page_title = 'Dashboard';
$nav_active = 'dashboard';
include ROOT . '/views/_header.php';
$projects = $db->query('SELECT * FROM projects WHERE is_active = 1 ORDER BY is_pinned DESC, name')->fetchAll();
?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Projects</h2>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addProjectModal">
<i class="bi bi-plus-lg me-1"></i>Add project
</button>
</div>
<?php if (empty($projects)): ?>
<div class="text-center text-muted py-5">
<i class="bi bi-folder-x fs-1 d-block mb-3 opacity-50"></i>
<p>No projects yet.</p>
<a href="/settings" class="btn btn-outline-secondary btn-sm">Configure scan paths</a>
<button class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#addProjectModal">Add manually</button>
</div>
<?php else: ?>
<div class="row g-3">
<?php foreach ($projects as $proj):
$type = ProjectTypes::get($proj['type']);
$icon = $type ? $type::typeIcon() : 'bi-folder';
$typeName = $type ? $type::typeName() : $proj['type'];
$hasRun = $type && in_array('run', $type::tabs());
?>
<div class="col-sm-6 col-lg-4 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-2 gap-2">
<i class="bi <?= htmlspecialchars($icon) ?> text-primary fs-5 flex-shrink-0"></i>
<h6 class="card-title mb-0 text-truncate"><?= htmlspecialchars($proj['name']) ?></h6>
</div>
<p class="text-muted small mb-2 text-truncate" title="<?= htmlspecialchars($proj['path']) ?>">
<code><?= htmlspecialchars($proj['path']) ?></code>
</p>
<div class="d-flex align-items-center gap-1 flex-wrap mt-1">
<span class="badge bg-secondary"><?= htmlspecialchars($typeName) ?></span>
<?php if ($proj['is_pinned']): ?>
<span class="badge bg-warning text-dark"><i class="bi bi-pin-fill"></i></span>
<?php endif; ?>
<?php if ($proj['url']): ?>
<a href="<?= htmlspecialchars($proj['url']) ?>" target="_blank" rel="noopener"
class="badge bg-dark text-decoration-none" title="Open site">
<i class="bi bi-box-arrow-up-right"></i>
</a>
<?php endif; ?>
<span class="badge bg-secondary-subtle text-body-secondary border project-disk d-none"
data-project-id="<?= $proj['id'] ?>" title="Project size (excl. node_modules, public, .git)">
<i class="bi bi-hdd me-1"></i><span class="project-disk-value"></span>
</span>
</div>
</div>
<div class="card-footer d-flex gap-2">
<a href="/project/<?= $proj['id'] ?>" class="btn btn-sm btn-outline-primary flex-grow-1">Open</a>
<?php if ($hasRun): ?>
<a href="/project/<?= $proj['id'] ?>?tab=run" class="btn btn-sm btn-outline-secondary" title="Run commands">
<i class="bi bi-terminal"></i>
</a>
<?php endif; ?>
<button class="btn btn-sm btn-outline-secondary btn-pin-project flex-shrink-0"
data-id="<?= $proj['id'] ?>"
title="<?= $proj['is_pinned'] ? 'Unpin' : 'Pin' ?>">
<i class="bi <?= $proj['is_pinned'] ? 'bi-pin-fill text-warning' : 'bi-pin' ?>"></i>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Activity feed -->
<div class="mt-5">
<div class="d-flex align-items-center mb-3">
<h6 class="mb-0"><i class="bi bi-journal-text me-1"></i>Recent activity</h6>
<a href="/audit" class="ms-auto small text-decoration-none">View all </a>
</div>
<div id="activityFeed" class="list-group list-group-flush">
<div class="list-group-item text-muted small bg-transparent">Loading…</div>
</div>
</div>
<!-- Add Project Modal -->
<div class="modal fade" id="addProjectModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add project</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addProjectForm">
<div class="modal-body">
<div id="addProjectError" class="alert alert-danger d-none"></div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Path on server</label>
<input type="text" name="path" class="form-control" placeholder="/var/www/mysite" required>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" class="form-select">
<?php foreach (ProjectTypes::all() as $slug => $class): ?>
<option value="<?= htmlspecialchars($slug) ?>"><?= htmlspecialchars($class::typeName()) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">URL <span class="text-muted">(optional)</span></label>
<input type="url" name="url" class="form-control" placeholder="https://...">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
</div>
</div>
<?php include ROOT . '/views/_footer.php'; ?>

24
views/error.php Normal file
View file

@ -0,0 +1,24 @@
<?php
$status = http_response_code() ?: 404;
$messages = [404 => 'Page not found', 403 => 'Forbidden', 500 => 'Server error'];
$msg = $messages[$status] ?? 'Something went wrong';
if (Auth::check()):
$page_title = $status;
$nav_active = '';
include ROOT . '/views/_header.php';
?>
<div class="text-center py-5">
<p class="display-3 fw-bold text-muted"><?= $status ?></p>
<p class="lead mb-4"><?= htmlspecialchars($msg) ?></p>
<a href="/" class="btn btn-primary">Go home</a>
</div>
<?php
include ROOT . '/views/_footer.php';
else:
?>
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head><meta charset="UTF-8"><title><?= $status ?></title></head>
<body class="text-center pt-5"><?= $status ?> <?= htmlspecialchars($msg) ?></body>
</html>
<?php endif; ?>

51
views/login.php Normal file
View file

@ -0,0 +1,51 @@
<?php
$needs_setup = !Auth::hasUsers($db);
?>
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login HackmanCMS</title>
<link rel="icon" type="image/png" href="/assets/img/logo.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card" style="width:360px">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">
<img src="/assets/img/logo.png" alt="" width="40" height="40" class="me-2">HackmanCMS
</h4>
<?php if (!empty($error)): ?>
<div class="alert alert-danger py-2"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($needs_setup): ?>
<p class="text-muted small">First run create your admin account.</p>
<?php endif; ?>
<form method="POST" action="/api/auth">
<input type="hidden" name="action" value="<?= $needs_setup ? 'setup' : 'login' ?>">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<?php if ($needs_setup): ?>
<div class="mb-3">
<label class="form-label">Confirm password</label>
<input type="password" name="password_confirm" class="form-control" required>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-primary w-100">
<?= $needs_setup ? 'Create account' : 'Log in' ?>
</button>
</form>
</div>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,105 @@
<?php
$pStmt = $db->prepare('SELECT key, value FROM project_settings WHERE project_id = ?');
$pStmt->execute([$pid]);
$pSettings = [];
foreach ($pStmt->fetchAll() as $row) $pSettings[$row['key']] = $row['value'];
?>
<div id="analyticsPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Analytics</h5>
<select id="analyticsRange" class="form-select form-select-sm py-0 ms-auto" style="width:auto">
<option value="7" selected>Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last year</option>
</select>
<button class="btn btn-sm btn-outline-secondary" id="analyticsRefreshBtn" title="Refresh">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div id="analyticsBody" class="text-muted small">Loading…</div>
<!-- Setup / log import collapsed by default. -->
<details class="mt-4" id="analyticsSetupDetails">
<summary class="small text-muted">
<i class="bi bi-gear me-1"></i>Server-log import setup
</summary>
<div class="mt-2">
<p class="text-muted small mb-2">
Imports page-view data from the web server's access log. No JS, no pixel,
no client-side change to the managed site. Set the path of the access log
below; HackmanCMS tails new lines on each import. Asset hits, non-GETs and
obvious bots are dropped.
</p>
<div id="analyticsSettings" data-project-id="<?= $pid ?>" class="mb-2">
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Access log file</label>
<input type="text" id="analyticsLogPath" class="form-control form-control-sm font-monospace"
value="<?= htmlspecialchars($pSettings['analytics_log_path'] ?? '') ?>"
placeholder="/var/log/apache2/blog.example.com_access.log">
</div>
<div class="col-md-3">
<label class="form-label small">Log format</label>
<select id="analyticsLogFormat" class="form-select form-select-sm">
<?php $fmt = $pSettings['analytics_log_format'] ?? 'combined'; ?>
<option value="combined" <?= $fmt === 'combined' ? 'selected' : '' ?>>Apache combined</option>
<option value="nginx" <?= $fmt === 'nginx' ? 'selected' : '' ?>>nginx default</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-sm btn-outline-secondary w-100" id="analyticsSaveBtn">Save</button>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Path prefix filter <span class="text-muted">(optional)</span></label>
<input type="text" id="analyticsLogFilter" class="form-control form-control-sm font-monospace"
value="<?= htmlspecialchars($pSettings['analytics_log_filter'] ?? '') ?>"
placeholder="/blog">
</div>
<div class="col-md-3">
<label class="form-label small d-block">&nbsp;</label>
<button class="btn btn-sm btn-primary w-100" id="analyticsImportBtn">
<i class="bi bi-arrow-down-circle me-1"></i>Import now
</button>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-sm btn-outline-warning w-100" id="analyticsResetBtn"
title="Discard last-position state and reimport from start">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div id="analyticsStatus" class="small text-muted">
<?php if (isset($pSettings['analytics_imported_at'])): ?>
Last import: <?= htmlspecialchars($pSettings['analytics_imported_at']) ?>
(<?= htmlspecialchars($pSettings['analytics_imported_count'] ?? '0') ?> rows total).
<?php else: ?>
No imports yet.
<?php endif; ?>
</div>
<p class="text-muted small mb-0 mt-2">
Continuous tracking install once as a user that can read the log:
<br>
<code class="small">*/5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php</code>
</p>
<p class="text-muted small mb-0 mt-2">
<i class="bi bi-archive"></i>
<strong>Retention:</strong> raw events for 90 days, hourly rollups
for 365 days, daily rollups forever. Visitor IPs are hashed with a
<strong>daily-rotating salt</strong> so visitors look like new
visitors across days "uniques over a multi-day window" is
therefore the sum of per-day unique counts.
<?php if (isset($pSettings['analytics_last_rollup'])): ?>
Last rollup:
<?= htmlspecialchars($pSettings['analytics_last_rollup']) ?>.
<?php endif; ?>
</p>
</div>
</div>
</details>
</div>

View file

@ -0,0 +1,15 @@
<div id="configPanel" data-project-id="<?= $pid ?>">
<div class="d-flex justify-content-between align-items-center mb-2 gap-2 flex-wrap">
<select id="configFileSelect" class="form-select form-select-sm" style="width:auto;min-width:220px">
<option value="_config.yml">_config.yml (blog)</option>
</select>
<div class="d-flex align-items-center gap-2">
<span id="configSaveStatus" class="text-muted small"></span>
<button class="btn btn-sm btn-primary" id="configSaveBtn">
<i class="bi bi-floppy me-1"></i>Save
</button>
</div>
</div>
<div id="configEditor"
style="border:1px solid var(--hm-border);border-radius:4px;overflow:hidden"></div>
</div>

View file

@ -0,0 +1,44 @@
<div id="projectDashboard" data-project-id="<?= $pid ?>"
data-project-type="<?= htmlspecialchars($project['type']) ?>">
<?php if ($isHexo): ?>
<!-- Tags + categories -->
<div id="tagsPanel" data-project-id="<?= $pid ?>" class="mb-4">
<div id="tagsLoading" class="text-muted small">Loading tags…</div>
<div id="tagsContent" class="d-none">
<div class="row g-4">
<div class="col-md-6">
<h6 class="mb-2 text-muted">
<i class="bi bi-tags me-1"></i>Tags
<span id="tagsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="tagCloud" class="tag-cloud"></div>
</div>
<div class="col-md-6">
<h6 class="mb-2 text-muted">
<i class="bi bi-folder me-1"></i>Categories
<span id="catsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="catCloud" class="tag-cloud"></div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Recent files -->
<div id="recentPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-2 gap-2">
<h6 class="mb-0 text-muted">
<i class="bi bi-clock-history me-1"></i>Recent files
</h6>
<button class="btn btn-sm btn-outline-secondary ms-auto py-0" id="recentClearBtn">
<i class="bi bi-trash"></i> Clear
</button>
</div>
<div id="recentList" class="list-group">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<div id="draftsPanel" class="row g-0 h-split" data-project-id="<?= $pid ?>">
<!-- Left: draft list -->
<div class="col-md-5 col-xl-4 split-list pe-2">
<div class="mb-3">
<button class="btn btn-sm btn-primary" id="newDraftBtn">
<i class="bi bi-plus-lg me-1"></i>New draft
</button>
</div>
<div id="draftsList">
<div class="text-muted small">Loading…</div>
</div>
</div>
<!-- Right: draft editor -->
<div class="col-md-7 col-xl-8 border-start ps-3" id="draftEditorPane">
<div class="editor-placeholder">
<i class="bi bi-pencil-square fs-1 d-block mb-2 opacity-25"></i>
<span>Select a draft or create a new one</span>
</div>
</div>
</div>

View file

@ -0,0 +1,84 @@
<div id="gitPanel" data-project-id="<?= $pid ?>">
<!-- Subdir picker (hidden until needed) -->
<div id="gitSubdirSelector" class="d-none alert alert-info py-2 d-flex align-items-center gap-3 mb-3">
<i class="bi bi-git flex-shrink-0"></i>
<span class="flex-grow-1 small">No git repo at project root. Found in subdirectory:</span>
<select id="gitSubdirSelect" class="form-select form-select-sm flex-shrink-0" style="width:auto"></select>
<button class="btn btn-sm btn-primary flex-shrink-0" id="gitSubdirUseBtn">Use</button>
</div>
<div id="gitNoRepo" class="alert alert-warning d-none">
<i class="bi bi-exclamation-triangle me-2"></i>Not a git repository and no git repos found in subdirectories.
</div>
<div id="gitStatus" class="d-none">
<!-- Branch + summary -->
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
<span class="badge bg-secondary fs-6" id="gitBranch"><i class="bi bi-git me-1"></i></span>
<span class="text-muted small" id="gitStatusSummary"></span>
<div class="ms-auto d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" id="gitStashBtn">Stash</button>
<button class="btn btn-sm btn-outline-secondary" id="gitStashPopBtn">Stash pop</button>
<button class="btn btn-sm btn-outline-danger" id="gitResetBtn">Reset HEAD</button>
</div>
</div>
<!-- Changed files -->
<div id="gitFiles" class="mb-2"></div>
<!-- Commit form -->
<div class="input-group mb-3" style="max-width:700px">
<input type="text" id="gitCommitMsg" class="form-control form-control-sm"
placeholder="Commit message — stages all changed files">
<button class="btn btn-sm btn-primary" id="gitCommitBtn">
<i class="bi bi-check2 me-1"></i>Commit
</button>
</div>
<!-- Pull / Push -->
<div class="d-flex gap-2 mb-3">
<button class="btn btn-sm btn-outline-secondary" id="gitPullBtn">
<i class="bi bi-cloud-download me-1"></i>Pull
</button>
<button class="btn btn-sm btn-outline-secondary" id="gitPushBtn">
<i class="bi bi-cloud-upload me-1"></i>Push
</button>
</div>
<pre id="gitStreamOutput" class="small p-2 rounded d-none"
style="max-height:200px;overflow-y:auto;background:var(--hm-bg)"></pre>
</div>
<!-- Sub-tabs -->
<div class="d-flex gap-2 mb-3 mt-2">
<button class="btn btn-sm btn-outline-primary active" id="gitTabLogBtn">Log</button>
<button class="btn btn-sm btn-outline-secondary" id="gitTabBranchesBtn">Branches</button>
</div>
<div id="gitLogPanel">
<div id="gitLogTable" class="text-muted small">Loading…</div>
</div>
<div id="gitBranchesPanel" class="d-none">
<div id="gitBranchList" class="mb-3"></div>
<div class="input-group input-group-sm" style="max-width:400px">
<input type="text" id="gitNewBranch" class="form-control" placeholder="new-branch-name">
<button class="btn btn-outline-secondary" id="gitCreateBranchBtn">
<i class="bi bi-plus-lg me-1"></i>Create &amp; switch
</button>
</div>
</div>
</div>
<!-- Diff viewer modal -->
<div class="modal fade" id="gitDiffModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="gitDiffTitle">Diff</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<pre id="gitDiffContent" class="diff-view m-0 p-3 small" style="overflow-x:auto"></pre>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,30 @@
<div id="linksPanel" data-project-id="<?= $pid ?>"
data-project-url="<?= htmlspecialchars($project['url'] ?? '') ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<i class="bi bi-link-45deg text-primary"></i>
<span class="small text-muted">
Scans posts/pages for HTTP/HTTPS links (and internal links if a project URL is set)
and reports any that are unreachable.
</span>
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" id="linksOnlyBroken" checked>
<label class="form-check-label small" for="linksOnlyBroken">Broken only</label>
</div>
<button class="btn btn-sm btn-primary" id="linksScanBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Run scan
</button>
</div>
<?php if (!($project['url'] ?? '')): ?>
<div class="alert alert-warning small py-2">
No project URL is set. Internal links (starting with <code>/</code>) and relative
links will be skipped. Set a URL on Settings Display name area or via project edit
to enable them.
</div>
<?php endif; ?>
<div id="linksRunInfo" class="text-muted small mb-2"></div>
<div id="linksTableWrap" class="table-responsive">
<div class="text-muted small">Loading…</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<div id="scratchpadCard" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-2 gap-2">
<i class="bi bi-sticky text-warning"></i>
<span class="small text-muted">Quick notes pre-deploy TODOs, reminders. Auto-saves.</span>
<span id="scratchpadStatus" class="text-muted small ms-auto"></span>
</div>
<textarea id="scratchpadInput"
class="form-control font-monospace"
style="height: calc(var(--hm-tab-height) - 30px)"
placeholder="Start typing — saves automatically…"><?= htmlspecialchars((string)($project['scratchpad'] ?? '')) ?></textarea>
</div>

View file

@ -0,0 +1,27 @@
<div id="pluginsPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<i class="bi bi-puzzle text-primary"></i>
<span class="small text-muted">
Hexo plugins listed from <code>package.json</code>. Install/uninstall runs npm.
</span>
</div>
<div class="card mb-3">
<div class="card-body py-2">
<div class="d-flex gap-2 align-items-center">
<input type="text" id="pluginInstallName" class="form-control form-control-sm font-monospace"
placeholder="hexo-…" style="max-width:300px">
<button class="btn btn-sm btn-primary" id="pluginInstallBtn">
<i class="bi bi-cloud-download me-1"></i>Install
</button>
<small class="text-muted ms-2">npm install --save</small>
</div>
<div id="pluginInstallLog" class="small font-monospace text-muted mt-2 d-none"
style="white-space:pre-wrap;max-height:200px;overflow:auto"></div>
</div>
</div>
<div id="pluginsList" class="row g-2">
<div class="col-12 text-muted small">Loading…</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
<div id="recentPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2">
<i class="bi bi-clock-history text-info"></i>
<span class="small text-muted">Recently opened or saved files in this project.</span>
<button class="btn btn-sm btn-outline-secondary ms-auto" id="recentClearBtn">
<i class="bi bi-trash"></i> Clear
</button>
</div>
<div id="recentList" class="list-group">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
<div id="searchPanel" data-project-id="<?= $pid ?>">
<div class="input-group mb-3" style="max-width:600px">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" id="searchQuery" class="form-control"
placeholder="Search in markdown files…" autocomplete="off">
<button class="btn btn-outline-secondary" id="searchClearBtn">Clear</button>
</div>
<div id="searchResults"></div>
</div>

View file

@ -0,0 +1,231 @@
<?php
// Load current project settings from DB
$psStmt = $db->prepare('SELECT key, value FROM project_settings WHERE project_id = ?');
$psStmt->execute([$pid]);
$pSettings = [];
foreach ($psStmt->fetchAll() as $row) {
$pSettings[$row['key']] = $row['value'];
}
?>
<?php
$schedules = [];
if ($isHexo) {
$sStmt = $db->prepare('SELECT * FROM scheduled_builds WHERE project_id = ? ORDER BY id');
$sStmt->execute([$pid]);
$schedules = $sStmt->fetchAll();
}
?>
<div id="settingsPanel" data-project-id="<?= $pid ?>">
<!-- Project -->
<h6 class="mb-3">Project</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label small">Display name</label>
<div class="input-group input-group-sm">
<input type="text" id="projectNameInput" class="form-control"
value="<?= htmlspecialchars($project['name']) ?>">
<button class="btn btn-outline-secondary" id="projectNameSaveBtn">Save</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label small">Project type</label>
<div class="input-group input-group-sm">
<select class="form-select" id="projectTypeSelectSettings">
<?php foreach (ProjectTypes::all() as $slug => $class): ?>
<option value="<?= htmlspecialchars($slug) ?>" <?= $project['type'] === $slug ? 'selected' : '' ?>>
<?= htmlspecialchars($class::typeName()) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn btn-outline-secondary" id="projectTypeSaveBtn">Save</button>
</div>
</div>
</div>
<?php if ($isHexo): ?>
<hr>
<!-- Broken link checker -->
<h6 class="mb-2 mt-3">Broken link checker</h6>
<p class="text-muted small mb-2">
Scans posts/pages for HTTP/HTTPS links (and internal links if a project URL is set)
and reports any that are unreachable.
</p>
<div id="linksPanel" class="mb-4"
data-project-id="<?= $pid ?>"
data-project-url="<?= htmlspecialchars($project['url'] ?? '') ?>">
<div class="d-flex align-items-center gap-2 mb-2">
<button class="btn btn-sm btn-primary" id="linksScanBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Run scan
</button>
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" id="linksOnlyBroken" checked>
<label class="form-check-label small" for="linksOnlyBroken">Broken only</label>
</div>
<span id="linksRunInfo" class="text-muted small ms-auto"></span>
</div>
<div id="linksTableWrap" class="table-responsive"></div>
</div>
<?php endif; ?>
<hr>
<!-- Backup & Export -->
<h6 class="mb-2 mt-3">Backup &amp; export</h6>
<p class="text-muted small mb-2">
Download a zip of the project source.
Excludes <code>node_modules</code>, <code>public</code>, and <code>.git</code>.
</p>
<a class="btn btn-sm btn-outline-primary mb-4"
href="/api/backup?project_id=<?= $pid ?>">
<i class="bi bi-download me-1"></i>Download backup (.zip)
</a>
<?php if ($isHexo): ?>
<hr>
<!-- Scheduled builds (Hexo) -->
<h6 class="mb-2 mt-3">Scheduled builds</h6>
<p class="text-muted small mb-2">
Run a project command on a cron schedule. Output is saved to command history.
Requires the system cron entry to be installed (see <code>docs/scheduled-builds.md</code>).
</p>
<div id="schedulesList" class="mb-2">
<?php if (!$schedules): ?>
<div class="text-muted small">No schedules yet.</div>
<?php else: foreach ($schedules as $s): ?>
<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1 schedule-row"
data-id="<?= (int)$s['id'] ?>">
<div class="form-check form-switch m-0">
<input class="form-check-input schedule-enabled" type="checkbox"
<?= $s['is_enabled'] ? 'checked' : '' ?>>
</div>
<select class="form-select form-select-sm schedule-cmd" style="max-width:150px">
<?php foreach ($type::commands() as $c): ?>
<option value="<?= htmlspecialchars($c['id']) ?>"
<?= $s['cmd_id'] === $c['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($c['label']) ?>
</option>
<?php endforeach; ?>
</select>
<input type="text" class="form-control form-control-sm font-monospace schedule-cron"
placeholder="0 3 * * *" value="<?= htmlspecialchars($s['cron']) ?>"
style="max-width:160px">
<small class="text-muted small flex-grow-1">
<?php if ($s['last_run_at']): ?>
last: <?= htmlspecialchars($s['last_run_at']) ?>
(<?= htmlspecialchars($s['last_status'] ?? '') ?>)
<?php else: ?>
never run
<?php endif; ?>
</small>
<button class="btn btn-sm btn-outline-secondary schedule-save" title="Save">
<i class="bi bi-check-lg"></i>
</button>
<button class="btn btn-sm btn-outline-danger schedule-delete" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
<?php endforeach; endif; ?>
</div>
<button class="btn btn-sm btn-outline-primary mb-4" id="newScheduleBtn">
<i class="bi bi-plus-lg me-1"></i>Add schedule
</button>
<hr>
<!-- Page directories (Hexo) -->
<h6 class="mb-2 mt-3">Page directories</h6>
<p class="text-muted small mb-2">
Subdirectories of <code>source/</code> scanned for pages, one per line.
Leave empty to scan only <code>source/*.md</code>.
</p>
<textarea id="pageDirsInput" class="form-control form-control-sm font-monospace mb-2" rows="3"
placeholder="p&#10;pages"><?= htmlspecialchars($pSettings['page_dirs'] ?? "p\npages") ?></textarea>
<button class="btn btn-sm btn-outline-primary mb-4" id="pageDirsSaveBtn">Save</button>
<hr>
<!-- Templates -->
<h6 class="mb-3 mt-3">Post Templates</h6>
<p class="text-muted small">
Full post file content (front matter + body). Applied when creating a new post.
</p>
<div id="templatesList" class="mb-3"></div>
<button class="btn btn-sm btn-outline-primary mb-4" id="newTemplateBtn">
<i class="bi bi-plus-lg me-1"></i>New template
</button>
<hr>
<!-- Snippets -->
<h6 class="mb-3 mt-3">Snippets</h6>
<p class="text-muted small">
Reusable markdown fragments. Insert into post body via the snippet picker.
</p>
<div id="snippetsList" class="mb-3"></div>
<button class="btn btn-sm btn-outline-primary" id="newSnippetBtn">
<i class="bi bi-plus-lg me-1"></i>New snippet
</button>
<?php endif; ?>
</div>
<?php if ($isHexo): ?>
<!-- Template editor modal -->
<div class="modal fade" id="templateModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="templateModalTitle">New template</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="templateId">
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" id="templateName" class="form-control form-control-sm">
</div>
<div>
<label class="form-label small">Content <span class="text-muted">(full post: front matter + body)</span></label>
<div id="templateContentEditor" class="border rounded" style="height:400px"></div>
<textarea id="templateContent" style="display:none"></textarea>
</div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="templateSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Snippet editor modal -->
<div class="modal fade" id="snippetModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="snippetModalTitle">New snippet</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="snippetId">
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" id="snippetName" class="form-control form-control-sm">
</div>
<div>
<label class="form-label small">Content</label>
<div id="snippetContentEditor" class="border rounded" style="height:250px"></div>
<textarea id="snippetContent" style="display:none"></textarea>
</div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="snippetSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<?php endif; ?>

View file

@ -0,0 +1,19 @@
<div id="tagsPanel" data-project-id="<?= $pid ?>">
<div id="tagsLoading" class="text-muted small">Loading…</div>
<div id="tagsContent" class="d-none">
<div class="row g-4">
<div class="col-md-6">
<h6 class="mb-3 text-muted">
Tags <span id="tagsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="tagCloud" class="tag-cloud"></div>
</div>
<div class="col-md-6">
<h6 class="mb-3 text-muted">
Categories <span id="catsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="catCloud" class="tag-cloud"></div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,46 @@
<div id="themesPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<i class="bi bi-palette text-primary"></i>
<span class="small text-muted">
Manage Hexo themes installed under <code>themes/</code>. Switching writes to <code>_config.yml</code>.
</span>
<button class="btn btn-sm btn-outline-primary ms-auto" id="cloneThemeBtn" data-bs-toggle="modal"
data-bs-target="#cloneThemeModal">
<i class="bi bi-cloud-download me-1"></i>Clone from git URL
</button>
</div>
<div id="themesList" class="row g-3">
<div class="col-12 text-muted small">Loading…</div>
</div>
</div>
<!-- Clone modal -->
<div class="modal fade" id="cloneThemeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Clone theme</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Git URL</label>
<input type="text" id="cloneThemeUrl" class="form-control form-control-sm font-monospace"
placeholder="https://github.com/user/hexo-theme-foo.git">
</div>
<div class="mb-2">
<label class="form-label small">Folder name <span class="text-muted">(optional)</span></label>
<input type="text" id="cloneThemeName" class="form-control form-control-sm"
placeholder="auto from URL">
</div>
<div id="cloneThemeLog" class="small font-monospace text-muted"
style="white-space:pre-wrap;max-height:200px;overflow:auto"></div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="cloneThemeSubmit">Clone</button>
</div>
</div>
</div>
</div>

431
views/project/view.php Normal file
View file

@ -0,0 +1,431 @@
<?php
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ?');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); include ROOT . '/views/error.php'; exit; }
$type = ProjectTypes::get($project['type']);
$tabs = $type ? $type::tabs() : ['files'];
$isHexo = $project['type'] === 'hexo';
$isStorage = $project['type'] === 'storage';
$pid = (int)$project['id'];
// Remove 'git' tab if no git repo found at project root or one level deep
if (in_array('git', $tabs)) {
$projectPath = $project['path'];
$hasGit = is_dir($projectPath . '/.git');
if (!$hasGit) {
foreach (@scandir($projectPath) ?: [] as $item) {
if ($item[0] === '.') continue;
if (is_dir($projectPath . '/' . $item . '/.git')) { $hasGit = true; break; }
}
}
if (!$hasGit) $tabs = array_values(array_diff($tabs, ['git']));
}
$tab = $_GET['tab'] ?? $tabs[0];
if (!in_array($tab, $tabs)) $tab = $tabs[0];
$page_title = $project['name'];
$nav_active = '';
include ROOT . '/views/_header.php';
?>
<?php
$tabLabels = [
'dashboard' => 'Dashboard', 'analytics' => 'Analytics',
'posts' => 'Posts', 'config' => 'Config',
'files' => 'Files', 'media' => 'Media',
'run' => 'Run', 'themes' => 'Themes',
'plugins' => 'Plugins', 'git' => 'Git',
'notes' => 'Notes', 'settings' => 'Settings',
];
$tabIcons = [
'dashboard' => 'bi-grid-1x2', 'analytics' => 'bi-graph-up',
'posts' => 'bi-file-earmark-text','config' => 'bi-sliders',
'files' => 'bi-folder2', 'media' => 'bi-images',
'run' => 'bi-terminal', 'themes' => 'bi-palette',
'plugins' => 'bi-puzzle', 'git' => 'bi-git',
'notes' => 'bi-sticky', 'settings' => 'bi-gear',
];
$tabGroups = [
['dashboard', 'analytics'],
['posts', 'config', 'files', 'media'],
['run', 'themes', 'plugins', 'git'],
['notes', 'settings'],
];
?>
<!-- Sidebar + content -->
<div class="project-layout d-flex gap-3 align-items-start">
<aside class="project-sidebar" id="projectSidebar">
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0 sidebar-toggle"
id="sidebarToggle" title="Collapse sidebar" aria-label="Collapse sidebar">
<i class="bi bi-chevron-double-left collapse-icon-expanded"></i>
<i class="bi bi-chevron-double-right collapse-icon-collapsed"></i>
</button>
<!-- Project meta header -->
<div class="project-sidebar-header mb-2">
<div class="d-flex align-items-center gap-1">
<i class="bi <?= htmlspecialchars($type ? $type::typeIcon() : 'bi-folder') ?> text-primary flex-shrink-0"></i>
<span class="fw-semibold text-truncate sidebar-label" title="<?= htmlspecialchars($project['path']) ?>">
<?= htmlspecialchars($project['name']) ?>
</span>
<div class="dropdown ms-auto flex-shrink-0 sidebar-label">
<button class="btn btn-sm btn-link text-body-secondary p-0 px-1"
data-bs-toggle="dropdown" aria-expanded="false" title="More">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><span class="dropdown-item-text small text-muted text-truncate d-block" style="max-width:280px"
title="<?= htmlspecialchars($project['path']) ?>">
<code><?= htmlspecialchars($project['path']) ?></code>
</span></li>
<li><hr class="dropdown-divider"></li>
<li>
<button type="button" class="dropdown-item text-danger"
data-bs-toggle="modal" data-bs-target="#deleteProjectModal">
<i class="bi bi-trash me-2"></i>Remove project
</button>
</li>
</ul>
</div>
</div>
<?php if ($project['url']): ?>
<a href="<?= htmlspecialchars($project['url']) ?>" target="_blank" rel="noopener"
class="d-inline-flex align-items-center gap-1 small text-decoration-none sidebar-label mt-1"
title="<?= htmlspecialchars($project['url']) ?>">
<i class="bi bi-box-arrow-up-right"></i>
<span class="text-truncate"><?= htmlspecialchars(preg_replace('#^https?://#', '', $project['url'])) ?></span>
</a>
<?php endif; ?>
<div class="mt-1 sidebar-label">
<span class="badge bg-secondary-subtle text-body-secondary border d-none"
id="diskBadge" data-project-id="<?= $pid ?>"
title="Project size (excl. node_modules, public, .git)">
<i class="bi bi-hdd me-1"></i><span id="diskBadgeValue"></span>
</span>
</div>
</div>
<hr>
<!-- Grouped nav -->
<?php foreach ($tabGroups as $i => $group):
$visible = array_values(array_intersect($group, $tabs));
if (!$visible) continue; ?>
<?php if ($i > 0): ?><hr><?php endif; ?>
<ul class="nav flex-column">
<?php foreach ($visible as $t): ?>
<li class="nav-item">
<a class="nav-link <?= $t === $tab ? 'active' : '' ?>"
href="?tab=<?= urlencode($t) ?>"
title="<?= htmlspecialchars($tabLabels[$t] ?? ucfirst($t)) ?>">
<i class="bi <?= $tabIcons[$t] ?? 'bi-circle' ?>"></i>
<span class="sidebar-label"><?= htmlspecialchars($tabLabels[$t] ?? ucfirst($t)) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
</aside>
<section class="project-main flex-grow-1 min-w-0">
<!-- ── POSTS tab (Hexo) ─────────────────────────────────────────────────────── -->
<?php if ($tab === 'posts'): ?>
<div id="postsPanel" class="row g-0 h-split" data-project-id="<?= $pid ?>">
<div class="col-md-5 col-xl-4 split-list pe-2">
<div class="d-flex align-items-center gap-1 mb-2 flex-wrap">
<button class="btn btn-sm btn-outline-primary active" id="showPosts">Posts</button>
<button class="btn btn-sm btn-outline-secondary" id="showPages">Pages</button>
<button class="btn btn-sm btn-outline-secondary" id="showDrafts">Drafts</button>
<button class="btn btn-sm btn-primary" id="newItemBtn" title="New draft">
<i class="bi bi-plus-lg"></i> New
</button>
<div class="input-group input-group-sm ms-auto" style="max-width:160px">
<input type="text" id="searchQuery" class="form-control" placeholder="Search…" autocomplete="off">
<button class="btn btn-outline-secondary" id="searchClearBtn" title="Clear"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<div id="postsList"></div>
<div id="searchResults" class="d-none"></div>
</div>
<div class="col-md-7 col-xl-8 border-start ps-3 d-flex flex-column">
<div class="editor-tab-bar" id="editorTabBar"></div>
<div class="editor-tab-content" id="editorTabContent">
<div class="editor-placeholder">
<i class="bi bi-cursor-text fs-1 d-block mb-2 opacity-25"></i>
Select a post to edit
</div>
</div>
</div>
</div>
<!-- ── FILES tab ─────────────────────────────────────────────────────────────── -->
<?php elseif ($tab === 'files'): ?>
<div id="fileBrowser" class="row g-0 h-split"
data-project-id="<?= $pid ?>"
data-project-type="<?= htmlspecialchars($project['type']) ?>">
<div class="col-md-5 col-xl-4 split-list pe-2">
<div class="d-flex align-items-center mb-2 gap-2">
<nav aria-label="breadcrumb" id="fileCrumb" class="flex-grow-1">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item"><a href="#" data-path="">Root</a></li>
</ol>
</nav>
<button class="btn btn-xs btn-outline-secondary flex-shrink-0"
data-bs-toggle="modal" data-bs-target="#uploadModal"
title="Upload file">
<i class="bi bi-cloud-upload"></i>
</button>
</div>
<div id="fileList" class="list-group">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>
<div class="col-md-7 col-xl-8 border-start ps-3 d-flex flex-column" id="editorPane">
<div class="editor-tab-bar" id="editorTabBar"></div>
<div class="editor-tab-content" id="editorTabContent">
<div class="editor-placeholder">
<i class="bi bi-cursor-text fs-1 d-block mb-2 opacity-25"></i>
Select a file to edit
</div>
</div>
</div>
</div>
<!-- ── RUN tab ───────────────────────────────────────────────────────────────── -->
<?php elseif ($tab === 'run' && $type && in_array('run', $tabs)): ?>
<div id="commandRunner" data-project-id="<?= $pid ?>">
<div class="row g-3">
<div class="col-md-4 col-lg-3">
<div class="card">
<div class="card-header small">Commands</div>
<div class="list-group list-group-flush">
<?php foreach ($type::commands() as $cmd): ?>
<button class="list-group-item list-group-item-action btn-run-cmd"
data-cmd="<?= htmlspecialchars($cmd['id']) ?>">
<span class="d-block"><?= htmlspecialchars($cmd['label']) ?></span>
<code class="small text-muted"><?= htmlspecialchars($cmd['cmd']) ?></code>
</button>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="col-md-8 col-lg-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center small">
Output
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary py-0" id="showHistoryBtn">History</button>
<button class="btn btn-sm btn-outline-secondary py-0" id="clearOutput">Clear</button>
</div>
</div>
<div class="card-body p-0">
<pre id="cmdOutput" class="m-0 p-3 text-success"
style="min-height:300px;max-height:65vh;overflow-y:auto;font-size:.8rem;background:transparent"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- ── MEDIA tab (Storage) ───────────────────────────────────────────────────── -->
<?php elseif ($tab === 'media'): ?>
<div id="mediaPanel" data-project-id="<?= $pid ?>"
data-project-url="<?= htmlspecialchars($project['url'] ?? '') ?>">
<nav aria-label="breadcrumb" id="mediaCrumb" class="mb-3">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item"><a href="#" data-path="">Root</a></li>
</ol>
</nav>
<div id="mediaGrid" class="row g-3">
<div class="col-12 text-muted small">Loading…</div>
</div>
</div>
<?php elseif ($tab === 'git'): ?>
<?php include ROOT . '/views/project/_tab_git.php'; ?>
<?php elseif ($tab === 'config'): ?>
<?php include ROOT . '/views/project/_tab_config.php'; ?>
<?php elseif ($tab === 'dashboard'): ?>
<?php include ROOT . '/views/project/_tab_dashboard.php'; ?>
<?php elseif ($tab === 'analytics'): ?>
<?php include ROOT . '/views/project/_tab_analytics.php'; ?>
<?php elseif ($tab === 'settings'): ?>
<?php include ROOT . '/views/project/_tab_settings.php'; ?>
<?php elseif ($tab === 'notes'): ?>
<?php include ROOT . '/views/project/_tab_notes.php'; ?>
<?php elseif ($tab === 'themes'): ?>
<?php include ROOT . '/views/project/_tab_themes.php'; ?>
<?php elseif ($tab === 'plugins'): ?>
<?php include ROOT . '/views/project/_tab_plugins.php'; ?>
<?php endif; ?>
</section>
</div>
<!-- ═══ MODALS ════════════════════════════════════════════════════════════════ -->
<!-- File editor -->
<div class="modal fade" id="fileEditModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="fileEditName">Edit file</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<input type="hidden" id="fileEditPid" value="<?= $pid ?>">
<input type="hidden" id="fileEditPath">
<textarea id="fileEditContent" style="display:none"></textarea>
<div id="fileEditCm" style="height:70vh"></div>
</div>
<div class="modal-footer py-2">
<span id="fileEditStatus" class="text-muted small me-auto"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary btn-sm" id="fileEditSave">Save</button>
</div>
</div>
</div>
</div>
<!-- Upload -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Upload file</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="uploadPid" value="<?= $pid ?>">
<input type="hidden" id="uploadAccept" value="any">
<div class="mb-3">
<label class="form-label small">Target folder</label>
<input type="text" id="uploadFolder" class="form-control form-control-sm font-monospace"
placeholder="<?= $isHexo ? 'source/images' : '' ?>">
</div>
<div class="mb-3">
<label class="form-label small">File</label>
<input type="file" id="uploadFile" class="form-control form-control-sm">
</div>
<div id="uploadResult" class="d-none">
<div class="alert alert-success py-2 small mb-2" id="uploadResultMsg"></div>
<div class="input-group input-group-sm">
<input type="text" id="uploadResultUrl" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" id="uploadCopy" type="button">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary btn-sm" id="uploadSubmit">Upload</button>
</div>
</div>
</div>
</div>
<!-- Command history -->
<div class="modal fade" id="cmdHistoryModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Command history</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="cmdHistoryList" class="list-group list-group-flush">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete project -->
<div class="modal fade" id="deleteProjectModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Remove project?</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body small text-muted">Files on disk are untouched.</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="confirmDelete"
data-project-id="<?= $pid ?>">Remove</button>
</div>
</div>
</div>
</div>
<!-- Toast container -->
<div id="toastContainer" style="position:fixed;bottom:1rem;right:1rem;z-index:9999;min-width:260px"></div>
<?php
$extra_scripts = '
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/dracula.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script type="module">
// Mirrors Agenda\'s Milkdown loader (lib/layout.php). Front matter is NOT fed
// through Milkdown — it\'s extracted in JS and edited in a separate CodeMirror
// so the YAML round-trips byte-for-byte.
const H = "https://esm.sh/@milkdown/";
const V = "@7.20.0/es2022/";
Promise.all([
import(H + "core" + V + "core.mjs"),
import(H + "preset-commonmark" + V + "preset-commonmark.mjs"),
import(H + "preset-gfm" + V + "preset-gfm.mjs"),
import(H + "plugin-history" + V + "plugin-history.mjs"),
import(H + "utils" + V + "utils.mjs"),
]).then(function ([core, cm, gfmPkg, hist, utils]) {
window.MilkdownKit = {
Editor: core.Editor, rootCtx: core.rootCtx,
defaultValueCtx: core.defaultValueCtx, commandsCtx: core.commandsCtx,
commonmark: cm.commonmark, gfm: gfmPkg.gfm, history: hist.history,
getMarkdown: utils.getMarkdown, replaceAll: utils.replaceAll,
callCommand: utils.callCommand,
commands: {
bold: cm.toggleStrongCommand, italic: cm.toggleEmphasisCommand,
strikethrough: gfmPkg.toggleStrikethroughCommand,
inlineCode: cm.toggleInlineCodeCommand, link: cm.toggleLinkCommand,
bulletList: cm.wrapInBulletListCommand, orderedList: cm.wrapInOrderedListCommand,
blockquote: cm.wrapInBlockquoteCommand, codeBlock: cm.createCodeBlockCommand,
},
};
window.dispatchEvent(new CustomEvent("milkdown-ready"));
}).catch(function (err) { console.error("[Milkdown] core load failed:", err); });
</script>
<script src="/assets/js/milkdown-mount.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/php/php.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/shell/shell.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
';
include ROOT . '/views/_footer.php';
?>

84
views/settings.php Normal file
View file

@ -0,0 +1,84 @@
<?php
$page_title = 'Settings';
$nav_active = 'settings';
include ROOT . '/views/_header.php';
$scan_paths = $db->query('SELECT * FROM scan_paths ORDER BY path')->fetchAll();
?>
<h2 class="h4 mb-4">Settings</h2>
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">Project discovery</div>
<div class="card-body">
<p class="text-muted small mb-3">
Directories to scan. HackmanCMS detects project types automatically by looking for marker files
(<code>_config.yml</code> Hexo, <code>index.php</code> Website, etc.).
</p>
<ul class="list-group list-group-flush mb-3" id="scanPathsList">
<?php if (empty($scan_paths)): ?>
<li class="list-group-item text-muted px-0 small">No scan paths configured yet.</li>
<?php endif; ?>
<?php foreach ($scan_paths as $sp): ?>
<li class="list-group-item d-flex align-items-center gap-2 px-0">
<code class="flex-grow-1"><?= htmlspecialchars($sp['path']) ?></code>
<span class="badge bg-secondary">depth <?= (int)$sp['depth'] ?></span>
<button class="btn btn-sm btn-outline-primary btn-scan" data-path="<?= htmlspecialchars($sp['path']) ?>"
data-depth="<?= (int)$sp['depth'] ?>">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-sm btn-outline-danger btn-remove-scan" data-id="<?= (int)$sp['id'] ?>">
<i class="bi bi-x-lg"></i>
</button>
</li>
<?php endforeach; ?>
</ul>
<form id="addScanPathForm" class="d-flex gap-2">
<input type="text" name="path" class="form-control form-control-sm" placeholder="/var/www" required>
<input type="number" name="depth" class="form-control form-control-sm" value="2" min="1" max="5"
style="width:72px" title="Scan depth">
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Project types</div>
<div class="card-body">
<p class="text-muted small mb-3">
Drop a PHP file extending <code>ProjectTypeBase</code> into
<code>lib/project-types/</code> to register a new type no config needed.
</p>
<ul class="list-group list-group-flush">
<?php foreach (ProjectTypes::all() as $slug => $class): ?>
<li class="list-group-item px-0 d-flex align-items-center gap-2">
<i class="bi <?= htmlspecialchars($class::typeIcon()) ?> text-primary"></i>
<span><?= htmlspecialchars($class::typeName()) ?></span>
<?php if ($class::description()): ?>
<small class="text-muted"><?= htmlspecialchars($class::description()) ?></small>
<?php endif; ?>
<code class="ms-auto text-muted small"><?= htmlspecialchars($slug) ?></code>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
</div>
</div>
<div id="scanResults" class="mt-4 d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Scan results</h5>
<button class="btn btn-sm btn-outline-secondary" id="closeScanResults">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div id="scanResultsList"></div>
</div>
<?php include ROOT . '/views/_footer.php'; ?>

4
web/.htaccess Normal file
View file

@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

187
web/api/analytics.php Normal file
View file

@ -0,0 +1,187 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$st = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$st->execute([$project_id]);
if (!$st->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$days = max(1, min(3650, (int)($_GET['days'] ?? 7)));
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime($today . ' +1 day'));
$sinceD = date('Y-m-d', strtotime($today . " -{$days} days")); // window start (date)
$prevFrom = date('Y-m-d', strtotime($today . " -" . ($days * 2) . " days"));
$prevTo = $sinceD;
$startTs = $sinceD . ' 00:00:00'; // for raw bound
// ---- helpers --------------------------------------------------------------
// "Today's" raw stats — raw is authoritative for the current day; everything
// else comes from the daily rollup (which is built once per day in import).
function rawTotalsToday(PDO $db, int $pid, string $today): array {
$q = $db->prepare(
"SELECT COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques
FROM site_visits WHERE project_id = ? AND date(visited_at) = ? AND status < 400");
$q->execute([$pid, $today]);
$r = $q->fetch();
return ['views' => (int)($r['views'] ?? 0), 'uniques' => (int)($r['uniques'] ?? 0)];
}
function dailyTotalsRange(PDO $db, int $pid, string $fromDate, string $toDateExcl): array {
// SUM views + uniques across rollup rows (per-day uniques summed; cross-
// day overlap not deduped — by design, since the daily-rotating salt
// makes cross-day visitors look like new visitors).
$q = $db->prepare(
"SELECT COALESCE(SUM(views),0) AS views, COALESCE(SUM(uniques),0) AS uniques
FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400");
$q->execute([$pid, $fromDate, $toDateExcl]);
$r = $q->fetch();
return ['views' => (int)($r['views'] ?? 0), 'uniques' => (int)($r['uniques'] ?? 0)];
}
// Window totals = today (raw) + (since..today) (daily rollup). When the window
// includes today, raw covers it. When the window is fully past, raw isn't used.
$tot = rawTotalsToday($db, $project_id, $today);
$totDaily = dailyTotalsRange($db, $project_id, $sinceD, $today);
$totals = [
'views' => $tot['views'] + $totDaily['views'],
'uniques' => $tot['uniques'] + $totDaily['uniques'],
];
// Previous-period totals — fully past, daily rollup only.
$prevTotals = dailyTotalsRange($db, $project_id, $prevFrom, $prevTo);
// All-time
$all = $db->prepare(
"SELECT COALESCE(SUM(views),0) AS views, COALESCE(SUM(uniques),0) AS uniques,
MIN(bucket_at) AS first_seen, MAX(bucket_at) AS last_seen
FROM site_visits_daily WHERE project_id = ? AND status < 400");
$all->execute([$project_id]);
$allRow = $all->fetch();
$rawAll = $db->prepare(
"SELECT COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques,
MIN(date(visited_at)) AS first_seen, MAX(date(visited_at)) AS last_seen
FROM site_visits WHERE project_id = ? AND status < 400");
$rawAll->execute([$project_id]);
$rawAllRow = $rawAll->fetch();
$allTime = [
'views' => (int)$allRow['views'] + (int)($rawAllRow['views'] ?? 0),
'uniques' => (int)$allRow['uniques'] + (int)($rawAllRow['uniques'] ?? 0),
'first_seen' => $allRow['first_seen'] ?: $rawAllRow['first_seen'],
'last_seen' => max($allRow['last_seen'] ?? '', $rawAllRow['last_seen'] ?? '') ?: null,
];
// Top pages — UNION raw(today) + daily(since..today), aggregate by path.
$topPages = $db->prepare(
"SELECT path, SUM(views) AS views, SUM(uniques) AS uniques
FROM (
SELECT path, COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
GROUP BY path
UNION ALL
SELECT path, views, uniques
FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
)
GROUP BY path ORDER BY views DESC LIMIT 20");
$topPages->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Top referrers
$topRefs = $db->prepare(
"SELECT referrer, SUM(views) AS views FROM (
SELECT COALESCE(referrer, '') AS referrer, COUNT(*) AS views
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
AND referrer IS NOT NULL AND referrer != ''
GROUP BY referrer
UNION ALL
SELECT referrer, views FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ?
AND status < 400 AND referrer != ''
)
GROUP BY referrer ORDER BY views DESC LIMIT 20");
$topRefs->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Daily series (current period) — today from raw, prior days from daily rollup.
$series = $db->prepare(
"SELECT day, SUM(views) AS views, SUM(uniques) AS uniques FROM (
SELECT date(visited_at) AS day, COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
GROUP BY day
UNION ALL
SELECT bucket_at AS day, views, uniques FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
)
GROUP BY day ORDER BY day");
$series->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Previous-period daily series (for chart overlay) — daily rollup only.
$prevSeries = $db->prepare(
"SELECT bucket_at AS day, SUM(views) AS views FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
GROUP BY bucket_at ORDER BY bucket_at");
$prevSeries->execute([$project_id, $prevFrom, $prevTo]);
// Hour-of-day — raw (today) + hourly rollup (other days). Days only in daily
// rollup don't contribute to this distribution (we lose sub-day timestamps
// after 365d). For most query windows that's fine.
$hours = $db->prepare(
"SELECT hour, SUM(views) AS views FROM (
SELECT CAST(strftime('%H', visited_at) AS INTEGER) AS hour, COUNT(*) AS views
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
GROUP BY hour
UNION ALL
SELECT CAST(strftime('%H', bucket_at) AS INTEGER) AS hour, SUM(views) AS views
FROM site_visits_hourly
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
GROUP BY hour
)
GROUP BY hour ORDER BY hour");
$hours->execute([$project_id, $today, $project_id, $startTs, $tomorrow]);
// Top 404s
$top404s = $db->prepare(
"SELECT path, SUM(hits) AS hits, MAX(last_hit) AS last_hit FROM (
SELECT path, COUNT(*) AS hits, MAX(visited_at) AS last_hit
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status = 404
GROUP BY path
UNION ALL
SELECT path, views AS hits, bucket_at AS last_hit FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status = 404
)
GROUP BY path ORDER BY hits DESC LIMIT 20");
$top404s->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Status mix
$statusMix = $db->prepare(
"SELECT status, SUM(views) AS views FROM (
SELECT status, COUNT(*) AS views FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? GROUP BY status
UNION ALL
SELECT status, SUM(views) AS views FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? GROUP BY status
)
GROUP BY status ORDER BY views DESC");
$statusMix->execute([$project_id, $today, $project_id, $sinceD, $today]);
echo json_encode([
'days' => $days,
'window' => $totals,
'previous' => $prevTotals,
'all_time' => $allTime,
'top_pages' => $topPages->fetchAll(),
'top_refs' => $topRefs->fetchAll(),
'series' => $series->fetchAll(),
'prev_series' => $prevSeries->fetchAll(),
'hours' => $hours->fetchAll(),
'top_404s' => $top404s->fetchAll(),
'status_mix' => $statusMix->fetchAll(),
'notes' => [
'unique_semantic' => 'sum of per-day unique visitors (daily-rotating salt) — same person across days counts once per day',
],
]);

View file

@ -0,0 +1,39 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$st = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$st->execute([$project_id]);
if (!$st->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'POST only']); exit; }
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'run';
if ($action === 'reset') {
$db->prepare('DELETE FROM project_settings WHERE project_id = ?
AND key IN ("analytics_last_size", "analytics_last_inode")')
->execute([$project_id]);
Audit::log($db, 'analytics_reset', $project_id);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'wipe') {
// Drop all collected visits for this project and clear cursors.
$db->prepare('DELETE FROM site_visits WHERE project_id = ?')->execute([$project_id]);
$db->prepare('DELETE FROM project_settings WHERE project_id = ?
AND key IN ("analytics_last_size", "analytics_last_inode",
"analytics_imported_at", "analytics_imported_count")')
->execute([$project_id]);
Audit::log($db, 'analytics_wipe', $project_id);
echo json_encode(['ok' => true]);
exit;
}
@set_time_limit(300);
require_once ROOT . '/bin/import-site-logs.php';
$res = importOne($db, $project_id);
echo json_encode($res + ['ok' => $res['error'] === null]);

34
web/api/audit.php Normal file
View file

@ -0,0 +1,34 @@
<?php
header('Content-Type: application/json');
$limit = min(200, (int)($_GET['limit'] ?? 50));
$offset = max(0, (int)($_GET['offset'] ?? 0));
$pid = isset($_GET['project_id']) && $_GET['project_id'] !== '' ? (int)$_GET['project_id'] : null;
if ($pid) {
$stmt = $db->prepare(
'SELECT a.*, u.username FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.project_id = ?
ORDER BY a.created_at DESC LIMIT ? OFFSET ?'
);
$stmt->execute([$pid, $limit, $offset]);
$cnt = $db->prepare('SELECT COUNT(*) FROM audit_log WHERE project_id = ?');
$cnt->execute([$pid]);
} else {
$stmt = $db->prepare(
'SELECT a.*, u.username, p.name AS project_name FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN projects p ON p.id = a.project_id
ORDER BY a.created_at DESC LIMIT ? OFFSET ?'
);
$stmt->execute([$limit, $offset]);
$cnt = $db->query('SELECT COUNT(*) FROM audit_log');
}
echo json_encode([
'entries' => $stmt->fetchAll(),
'total' => (int)$cnt->fetchColumn(),
'limit' => $limit,
'offset' => $offset,
]);

48
web/api/auth.php Normal file
View file

@ -0,0 +1,48 @@
<?php
$action = $_POST['action'] ?? $_GET['action'] ?? '';
if ($action === 'logout') {
Auth::logout();
header('Location: /login');
exit;
}
if ($action === 'login') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (Auth::login($db, $username, $password)) {
header('Location: /');
exit;
}
$error = 'Invalid username or password.';
include ROOT . '/views/login.php';
exit;
}
if ($action === 'setup') {
if (Auth::hasUsers($db)) {
header('Location: /login');
exit;
}
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$confirm = $_POST['password_confirm'] ?? '';
if (strlen($username) < 2) {
$error = 'Username must be at least 2 characters.';
} elseif (strlen($password) < 8) {
$error = 'Password must be at least 8 characters.';
} elseif ($password !== $confirm) {
$error = 'Passwords do not match.';
} else {
Auth::createUser($db, $username, $password);
Auth::login($db, $username, $password);
header('Location: /');
exit;
}
include ROOT . '/views/login.php';
exit;
}
http_response_code(400);
echo 'Bad request';

68
web/api/backup.php Normal file
View file

@ -0,0 +1,68 @@
<?php
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['error' => 'Project not found']);
exit;
}
$base = realpath($project['path']);
if (!$base || !is_dir($base)) {
header('Content-Type: application/json');
echo json_encode(['error' => 'Project path not accessible']);
exit;
}
$excludes = ['node_modules', 'public', '.git'];
// Build ZIP into a temp file then stream
$tmp = tempnam(sys_get_temp_dir(), 'hexbk');
$zip = new ZipArchive();
if ($zip->open($tmp, ZipArchive::OVERWRITE) !== true) {
@unlink($tmp);
header('Content-Type: application/json');
echo json_encode(['error' => 'Cannot open zip for writing']);
exit;
}
$baseLen = strlen($base) + 1;
$it = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($base,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS),
function ($current) use ($excludes, $base) {
$name = $current->getFilename();
if ($current->isDir() && in_array($name, $excludes, true)) return false;
return true;
}
),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $file) {
$abs = $file->getPathname();
$local = substr($abs, $baseLen);
if ($local === '') continue;
if ($file->isDir()) {
$zip->addEmptyDir($local);
} else {
$zip->addFile($abs, $local);
}
}
$zip->close();
Audit::log($db, 'backup_download', $project_id);
$slug = preg_replace('/[^A-Za-z0-9._-]+/', '-', $project['name']) ?: 'project';
$filename = $slug . '_' . date('Y-m-d_His') . '.zip';
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($tmp));
header('Cache-Control: no-store');
readfile($tmp);
@unlink($tmp);

46
web/api/disk.php Normal file
View file

@ -0,0 +1,46 @@
<?php
header('Content-Type: application/json');
$ids = $_GET['ids'] ?? '';
if ($ids !== '') {
// Bulk: ?ids=1,2,3
$idList = array_filter(array_map('intval', explode(',', $ids)));
if (!$idList) { echo json_encode(['items' => []]); exit; }
$place = implode(',', array_fill(0, count($idList), '?'));
$stmt = $db->prepare("SELECT id, path FROM projects WHERE is_active = 1 AND id IN ($place)");
$stmt->execute($idList);
$out = [];
foreach ($stmt->fetchAll() as $p) {
$out[(int)$p['id']] = diskUsage($p['path']);
}
echo json_encode(['items' => $out]);
exit;
}
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id, path FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
echo json_encode(['size' => diskUsage($project['path'])]);
function diskUsage(?string $path): ?array {
if (!$path) return null;
$real = realpath($path);
if (!$real || !is_dir($real)) return null;
// GNU du -sb with excludes; bytes for accuracy
$cmd = 'du -sb --exclude=node_modules --exclude=public --exclude=.git '
. escapeshellarg($real) . ' 2>/dev/null';
$out = shell_exec($cmd);
if ($out === null) return null;
$bytes = (int)strtok(trim($out), "\t");
return ['bytes' => $bytes, 'human' => humanBytes($bytes)];
}
function humanBytes(int $b): string {
if ($b < 1024) return $b . ' B';
if ($b < 1024 * 1024) return number_format($b / 1024, 1) . ' KB';
if ($b < 1024 * 1024 * 1024) return number_format($b / 1024 / 1024, 1) . ' MB';
return number_format($b / 1024 / 1024 / 1024, 2) . ' GB';
}

111
web/api/drafts.php Normal file
View file

@ -0,0 +1,111 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$pid = (int)($_GET['project_id'] ?? $input['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$pid]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
// ── LIST ──────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
$rows = $db->prepare('SELECT * FROM drafts WHERE project_id = ? ORDER BY updated_at DESC');
$rows->execute([$pid]);
echo json_encode(['drafts' => $rows->fetchAll()]);
exit;
}
if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
$action = $input['action'] ?? 'create';
// ── CREATE ────────────────────────────────────────────────────────────────────
if ($action === 'create') {
$title = trim($input['title'] ?? '');
$slug = trim($input['slug'] ?? '') ?: slugify($title);
$folder = trim($input['folder'] ?? '');
$fm = $input['frontmatter'] ?? '';
$body = $input['body'] ?? '';
if (!$title) { echo json_encode(['error' => 'Title required']); exit; }
$stmt = $db->prepare(
'INSERT INTO drafts (project_id, title, slug, folder, frontmatter, body) VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$pid, $title, $slug, $folder, $fm, $body]);
$newId = (int)$db->lastInsertId();
Audit::log($db, 'draft_create', $pid, $title);
echo json_encode(['ok' => true, 'id' => $newId]);
exit;
}
// ── UPDATE ────────────────────────────────────────────────────────────────────
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
$title = trim($input['title'] ?? '');
$slug = trim($input['slug'] ?? '');
$folder = trim($input['folder'] ?? '');
$fm = $input['frontmatter'] ?? '';
$body = $input['body'] ?? '';
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$db->prepare(
'UPDATE drafts SET title=?, slug=?, folder=?, frontmatter=?, body=?,
updated_at=CURRENT_TIMESTAMP WHERE id=? AND project_id=?'
)->execute([$title, $slug, $folder, $fm, $body, $id, $pid]);
Audit::log($db, 'draft_update', $pid, $title);
echo json_encode(['ok' => true]);
exit;
}
// ── DELETE ────────────────────────────────────────────────────────────────────
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
$row = $db->prepare('SELECT title FROM drafts WHERE id = ? AND project_id = ?');
$row->execute([$id, $pid]);
$title = (string)$row->fetchColumn();
$db->prepare('DELETE FROM drafts WHERE id = ? AND project_id = ?')->execute([$id, $pid]);
Audit::log($db, 'draft_delete', $pid, $title);
echo json_encode(['ok' => true]);
exit;
}
// ── PUBLISH (write to _posts, delete from DB) ─────────────────────────────────
if ($action === 'publish') {
$id = (int)($input['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM drafts WHERE id = ? AND project_id = ?');
$stmt->execute([$id, $pid]);
$draft = $stmt->fetch();
if (!$draft) { echo json_encode(['error' => 'Draft not found']); exit; }
$base = realpath($project['path']);
$posts_dir = $base . '/source/_posts';
$target_dir = $draft['folder']
? $posts_dir . '/' . ltrim($draft['folder'], '/')
: $posts_dir;
$real_t = realpath($target_dir) ?: $target_dir;
if (realpath($posts_dir) && !str_starts_with($real_t . '/', realpath($posts_dir) . '/')) {
http_response_code(403); echo json_encode(['error' => 'Invalid folder']); exit;
}
if (!is_dir($target_dir)) mkdir($target_dir, 0755, true);
$filename = ($draft['slug'] ?: slugify($draft['title'])) . '.md';
$filepath = $target_dir . '/' . $filename;
$fm = "---\ntitle: \"" . addslashes($draft['title']) . "\"\ndate: " . date('Y-m-d H:i:s') . "\n---";
file_put_contents($filepath, $fm . "\n\n" . $draft['body']);
$db->prepare('DELETE FROM drafts WHERE id = ?')->execute([$id]);
$rel = 'source/_posts/' . ($draft['folder'] ? ltrim($draft['folder'], '/') . '/' : '') . $filename;
Audit::log($db, 'draft_publish', $pid, $rel);
echo json_encode(['ok' => true, 'path' => $rel, 'filename' => $filename]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
function slugify(string $s): string {
$s = mb_strtolower($s);
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-') ?: 'draft-' . time();
}

122
web/api/files.php Normal file
View file

@ -0,0 +1,122 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$method = $_SERVER['REQUEST_METHOD'];
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
if (!$base || !is_dir($base)) { echo json_encode(['error' => 'Project path not accessible']); exit; }
// POST actions read params from JSON body
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'write';
$rel_path = $input['path'] ?? '';
} else {
$input = [];
$rel_path = $_GET['path'] ?? '';
$action = $_GET['action'] ?? 'list';
}
function safeTarget(string $base, string $rel): string|false {
if ($rel === '' || $rel === '.') return $base;
$candidate = $base . '/' . ltrim($rel, '/');
if (file_exists($candidate)) {
$real = realpath($candidate);
return ($real && str_starts_with($real . '/', $base . '/')) ? $real : false;
}
// File doesn't exist yet (write) — validate parent
$parentReal = realpath(dirname($candidate));
return ($parentReal && str_starts_with($parentReal . '/', $base . '/')) ? $candidate : false;
}
$target = safeTarget($base, $rel_path);
function touchRecent(PDO $db, int $pid, string $rel): void {
if ($rel === '' || $rel === '.') return;
$db->prepare('INSERT INTO recent_files (project_id, path, opened_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(project_id, path) DO UPDATE SET opened_at = CURRENT_TIMESTAMP')
->execute([$pid, $rel]);
}
// ── WRITE ─────────────────────────────────────────────────────────────────────
if ($action === 'write' && $method === 'POST') {
if ($target === false) { http_response_code(403); echo json_encode(['error' => 'Access denied']); exit; }
$dir = dirname($target);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($target, $input['content'] ?? '');
touchRecent($db, $project_id, $rel_path);
Audit::log($db, 'file_write', $project_id, $rel_path);
echo json_encode(['ok' => true]);
exit;
}
// ── DELETE file ───────────────────────────────────────────────────────────────
if ($action === 'delete' && $method === 'POST') {
if ($target === false || !is_file($target)) { echo json_encode(['error' => 'File not found']); exit; }
unlink($target);
Audit::log($db, 'file_delete', $project_id, $rel_path);
echo json_encode(['ok' => true]);
exit;
}
// ── SERVE (proxy file with correct content-type) ──────────────────────────────
if ($action === 'serve') {
if ($target === false || !is_file($target)) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($target, PATHINFO_EXTENSION));
$mimes = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
'mp4' => 'video/mp4', 'webm' => 'video/webm',
'mp3' => 'audio/mpeg', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg',
'pdf' => 'application/pdf',
];
header('Content-Type: ' . ($mimes[$ext] ?? 'application/octet-stream'));
header('Content-Length: ' . filesize($target));
header('Cache-Control: max-age=3600');
readfile($target);
exit;
}
// ── READ ──────────────────────────────────────────────────────────────────────
if ($action === 'read') {
if ($target === false || !is_file($target)) { echo json_encode(['error' => 'Not a file']); exit; }
if (filesize($target) > 512 * 1024) { echo json_encode(['error' => 'File too large (>512 KB)']); exit; }
$content = file_get_contents($target);
if ($content === false) { echo json_encode(['error' => 'Cannot read file — check permissions']); exit; }
touchRecent($db, $project_id, $rel_path);
$json = json_encode(['content' => $content]);
if ($json === false) {
// Non-UTF-8 bytes — substitute replacement characters
$json = json_encode(['content' => $content], JSON_INVALID_UTF8_SUBSTITUTE);
}
echo $json ?? json_encode(['error' => 'Cannot encode file content']);
exit;
}
// ── LIST (default) ────────────────────────────────────────────────────────────
if ($target === false || !is_dir($target)) { echo json_encode(['error' => 'Not a directory']); exit; }
$entries = [];
foreach (scandir($target) as $item) {
if ($item === '.' || $item === '..') continue;
$full = $target . '/' . $item;
$rel = $rel_path ? rtrim($rel_path, '/') . '/' . $item : $item;
$entries[] = [
'name' => $item,
'type' => is_dir($full) ? 'dir' : 'file',
'size' => is_file($full) ? filesize($full) : null,
'modified' => filemtime($full),
'path' => $rel,
];
}
usort($entries, fn($a, $b) =>
$a['type'] !== $b['type'] ? ($a['type'] === 'dir' ? -1 : 1) : strcmp($a['name'], $b['name'])
);
echo json_encode(['entries' => $entries]);

221
web/api/git.php Normal file
View file

@ -0,0 +1,221 @@
<?php
$project_id = (int)($_GET['project_id'] ?? 0);
$method = $_SERVER['REQUEST_METHOD'];
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { jsonErr(404, 'Project not found'); }
$path = realpath($project['path']);
if (!$path) { jsonErr(400, 'Path not accessible'); }
// Parse POST body early so subdir can be sent in either GET param or POST body
$input = [];
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
}
// Allow using a git repo in a subdirectory
$subdir = trim($_GET['subdir'] ?? ($input['subdir'] ?? ''));
if ($subdir) {
$sub = realpath($path . '/' . $subdir);
if ($sub && str_starts_with($sub . '/', $path . '/') && is_dir($sub . '/.git')) {
$path = $sub;
}
}
if (!is_dir($path . '/.git')) {
// Scan one level deep for git sub-repos
$subdirs = [];
foreach (@scandir($path) ?: [] as $item) {
if ($item[0] === '.') continue;
$sub = $path . '/' . $item;
if (is_dir($sub) && is_dir($sub . '/.git')) $subdirs[] = $item;
}
header('Content-Type: application/json');
echo json_encode(['no_git' => true, 'subdirs' => $subdirs]);
exit;
}
function jsonErr(int $code, string $msg): never {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(['error' => $msg]);
exit;
}
function git(string $path, string $args, array &$out = [], int &$exit = 0): string {
exec('git -C ' . escapeshellarg($path) . ' ' . $args . ' 2>&1', $out, $exit);
return implode("\n", $out);
}
// ── READ actions (GET) ────────────────────────────────────────────────────────
header('Content-Type: application/json');
$action = $_GET['action'] ?? '';
if ($action === 'status') {
$lines = [];
$branch = trim(git($path, 'rev-parse --abbrev-ref HEAD'));
git($path, 'status --porcelain', $lines);
$files = [];
foreach ($lines as $l) {
if (strlen($l) < 3) continue;
$files[] = ['xy' => substr($l, 0, 2), 'file' => trim(substr($l, 3))];
}
$stashes = [];
git($path, 'stash list', $stashes);
echo json_encode(['branch' => $branch, 'files' => $files, 'stash_count' => count($stashes)]);
exit;
}
if ($action === 'branches') {
$lines = [];
git($path, 'branch -a', $lines);
$branches = [];
foreach ($lines as $l) {
$cur = str_starts_with($l, '* ');
$branches[] = ['name' => trim(ltrim($l, '* ')), 'current' => $cur];
}
echo json_encode(['branches' => $branches]);
exit;
}
if ($action === 'log') {
$limit = min(50, (int)($_GET['limit'] ?? 25));
$lines = [];
git($path, 'log --pretty=format:"%H|%h|%s|%an|%ar|%ad" --date=short -' . $limit, $lines);
$commits = array_map(fn($l) => array_combine(
['hash','short','subject','author','rel','date'],
array_pad(explode('|', $l, 6), 6, '')
), array_filter($lines));
echo json_encode(['commits' => array_values($commits)]);
exit;
}
if ($action === 'diff') {
$file = $_GET['file'] ?? '';
$hash = $_GET['hash'] ?? '';
if ($hash) {
$diff = git($path, 'show ' . escapeshellarg($hash));
} elseif ($file) {
$diff = git($path, 'diff -- ' . escapeshellarg($file));
if (!trim($diff)) $diff = git($path, 'diff --cached -- ' . escapeshellarg($file));
} else {
$diff = git($path, 'diff');
}
echo json_encode(['diff' => $diff]);
exit;
}
// ── WRITE actions (POST) ──────────────────────────────────────────────────────
if ($method !== 'POST') { jsonErr(405, 'Method not allowed'); }
$action = $input['action'] ?? $action;
// Streaming ops (pull, push) return SSE
if (in_array($action, ['pull', 'push'])) {
Audit::log($db, 'git_' . $action, $project_id);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
while (ob_get_level()) ob_end_flush();
$cmd = 'git -C ' . escapeshellarg($path) . ' ' . $action . ' 2>&1';
$proc = popen($cmd, 'r');
if (!$proc) {
echo "data: " . json_encode(['error' => 'Failed to start']) . "\n\n";
flush();
exit;
}
while (!feof($proc)) {
$line = fgets($proc, 4096);
if ($line !== false && $line !== '') {
echo 'data: ' . json_encode(['line' => $line]) . "\n\n";
flush();
}
}
$exit = pclose($proc);
echo 'data: ' . json_encode(['done' => true, 'exit_code' => $exit]) . "\n\n";
flush();
exit;
}
// JSON ops
header('Content-Type: application/json');
if ($action === 'commit') {
$msg = trim($input['message'] ?? '');
if (!$msg) { echo json_encode(['error' => 'Commit message required']); exit; }
$out = []; $exit = 0;
git($path, 'add -A');
$result = git($path, 'commit -m ' . escapeshellarg($msg), $out, $exit);
Audit::log($db, 'git_commit', $project_id, $msg);
echo json_encode(['ok' => $exit === 0, 'output' => $result, 'exit_code' => $exit]);
exit;
}
if ($action === 'checkout') {
$branch = $input['branch'] ?? '';
if (!$branch) { echo json_encode(['error' => 'Branch required']); exit; }
$out = []; $exit = 0;
$result = git($path, 'checkout ' . escapeshellarg($branch), $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'create_branch') {
$branch = $input['branch'] ?? '';
if (!$branch) { echo json_encode(['error' => 'Branch name required']); exit; }
$out = []; $exit = 0;
$result = git($path, 'checkout -b ' . escapeshellarg($branch), $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'merge') {
$branch = $input['branch'] ?? '';
if (!$branch) { echo json_encode(['error' => 'Branch required']); exit; }
$out = []; $exit = 0;
$result = git($path, 'merge ' . escapeshellarg($branch), $out, $exit);
Audit::log($db, 'git_merge', $project_id, $branch);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'stash') {
$out = []; $exit = 0;
$result = git($path, 'stash', $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'stash_pop') {
$out = []; $exit = 0;
$result = git($path, 'stash pop', $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'reset') {
$out = []; $exit = 0;
$result = git($path, 'reset --hard HEAD', $out, $exit);
Audit::log($db, 'git_reset', $project_id);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'stage') {
$files = $input['files'] ?? [];
if (!is_array($files) || empty($files)) {
echo json_encode(['error' => 'No files specified']); exit;
}
$args = 'add --';
foreach ($files as $f) { $args .= ' ' . escapeshellarg((string)$f); }
$out = []; $exit = 0;
$result = git($path, $args, $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
jsonErr(400, 'Unknown action');

204
web/api/links.php Normal file
View file

@ -0,0 +1,204 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'scan';
if ($action === 'scan') {
@set_time_limit(300);
$result = scanProjectLinks($db, $project);
Audit::log($db, 'link_scan', $project_id,
"broken={$result['broken']} of {$result['total']}");
echo json_encode(['ok' => true, 'run_id' => $result['run_id']] + $result);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET — latest run + its results
$run = $db->prepare('SELECT * FROM link_check_runs WHERE project_id = ?
ORDER BY id DESC LIMIT 1');
$run->execute([$project_id]);
$lastRun = $run->fetch();
if (!$lastRun) { echo json_encode(['run' => null, 'results' => []]); exit; }
$onlyBroken = !empty($_GET['broken_only']);
$where = 'run_id = ?';
$args = [$lastRun['id']];
if ($onlyBroken) {
$where .= ' AND (status_code IS NULL OR status_code >= 400)';
}
$res = $db->prepare("SELECT * FROM link_check_results WHERE $where ORDER BY status_code DESC, source");
$res->execute($args);
echo json_encode(['run' => $lastRun, 'results' => $res->fetchAll()]);
function scanProjectLinks(PDO $db, array $project): array {
$base = realpath($project['path']);
$siteUrl = rtrim((string)($project['url'] ?? ''), '/');
$pid = (int)$project['id'];
$db->prepare('INSERT INTO link_check_runs (project_id) VALUES (?)')->execute([$pid]);
$runId = (int)$db->lastInsertId();
// Collect markdown files
$files = [];
foreach (['source/_posts', 'source/_drafts', 'source'] as $sub) {
$dir = $base . '/' . $sub;
if (!is_dir($dir)) continue;
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
$dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS));
foreach ($it as $f) {
if ($f->getExtension() === 'md') {
$files[] = ['abs' => $f->getPathname(),
'rel' => ltrim(str_replace($base, '', $f->getPathname()), '/')];
}
}
}
// Extract links per file
$byUrl = []; // url => [ [source, ...] ]
foreach ($files as $f) {
$content = file_get_contents($f['abs']);
if ($content === false) continue;
$urls = extractLinks($content);
foreach ($urls as $u) {
$byUrl[$u][] = $f['rel'];
}
}
// Resolve relative/internal URLs against project URL
$jobs = []; // url => fetchUrl
foreach (array_keys($byUrl) as $u) {
$fetch = resolveLink($u, $siteUrl);
if ($fetch !== null) $jobs[$u] = $fetch;
}
$statuses = parallelCheck($jobs);
$insRes = $db->prepare(
'INSERT INTO link_check_results (run_id, project_id, url, source, status_code, error)
VALUES (?, ?, ?, ?, ?, ?)');
$total = 0; $broken = 0;
foreach ($byUrl as $url => $sources) {
$st = $statuses[$url] ?? null;
$status = $st['code'] ?? null;
$error = $st['error'] ?? null;
$isBroken = $status === null || $status >= 400;
foreach ($sources as $src) {
$insRes->execute([$runId, $pid, $url, $src, $status, $error]);
$total++;
if ($isBroken) $broken++;
}
}
$db->prepare('UPDATE link_check_runs SET finished_at = CURRENT_TIMESTAMP,
total_links = ?, broken = ? WHERE id = ?')
->execute([$total, $broken, $runId]);
return ['run_id' => $runId, 'total' => $total, 'broken' => $broken];
}
function extractLinks(string $content): array {
$urls = [];
// Markdown links [text](url)
if (preg_match_all('/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/', $content, $m)) {
foreach ($m[1] as $u) $urls[] = $u;
}
// HTML href="..."
if (preg_match_all('/href=["\']([^"\']+)["\']/i', $content, $m)) {
foreach ($m[1] as $u) $urls[] = $u;
}
// Bare URLs (markdown auto-link)
if (preg_match_all('/<(https?:\/\/[^>]+)>/', $content, $m)) {
foreach ($m[1] as $u) $urls[] = $u;
}
return array_values(array_unique(array_filter(array_map('trim', $urls), function ($u) {
if ($u === '' || $u[0] === '#') return false;
if (str_starts_with($u, 'mailto:')) return false;
if (str_starts_with($u, 'tel:')) return false;
if (str_starts_with($u, 'javascript:')) return false;
if (str_starts_with($u, 'data:')) return false;
return true;
})));
}
function resolveLink(string $url, string $siteUrl): ?string {
if (preg_match('#^https?://#i', $url)) return $url;
if ($url[0] === '/' && $siteUrl !== '') return $siteUrl . $url;
// Pure relative refs (./foo, ../foo, foo) — can't resolve without post URL context
return null;
}
function parallelCheck(array $jobs): array {
if (!$jobs) return [];
if (!function_exists('curl_multi_init')) {
$out = [];
foreach ($jobs as $key => $url) $out[$key] = singleCheck($url);
return $out;
}
$mh = curl_multi_init();
$handles = [];
foreach ($jobs as $key => $url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_USERAGENT => 'HackmanCMS-LinkChecker/1.0',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
curl_multi_add_handle($mh, $ch);
$handles[$key] = $ch;
}
$running = null;
do { curl_multi_exec($mh, $running); curl_multi_select($mh, 0.5); } while ($running > 0);
$out = [];
foreach ($handles as $key => $ch) {
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE) ?: null;
$err = curl_error($ch) ?: null;
// Some servers reject HEAD; retry with GET for non-2xx HEAD failures
if (($code === null || $code === 0 || $code === 405 || $code === 403) && $err === '') {
$code = singleCheck($jobs[$key])['code'] ?? $code;
}
$out[$key] = ['code' => $code ?: null, 'error' => $err ?: null];
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
return $out;
}
function singleCheck(string $url): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_USERAGENT => 'HackmanCMS-LinkChecker/1.0',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => false,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_RANGE => '0-1024',
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE) ?: null;
$err = curl_error($ch) ?: null;
curl_close($ch);
return ['code' => $code ?: null, 'error' => $err ?: null];
}

87
web/api/plugins.php Normal file
View file

@ -0,0 +1,87 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
$pkgFile = $base ? $base . '/package.json' : null;
if (!$base || !is_dir($base)) { echo json_encode(['error' => 'Project path not accessible']); exit; }
if ($method === 'POST') {
@set_time_limit(180);
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
$name = trim($input['name'] ?? '');
if (!preg_match('/^(@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/i', $name)) {
echo json_encode(['error' => 'Invalid package name']); exit;
}
if ($action === 'install') {
$cmd = 'cd ' . escapeshellarg($base) . ' && npm install --save ' . escapeshellarg($name) . ' 2>&1';
exec($cmd, $out, $rc);
Audit::log($db, 'plugin_install', $project_id, $name);
echo json_encode(['ok' => $rc === 0, 'log' => implode("\n", $out)]);
exit;
}
if ($action === 'uninstall') {
$cmd = 'cd ' . escapeshellarg($base) . ' && npm uninstall --save ' . escapeshellarg($name) . ' 2>&1';
exec($cmd, $out, $rc);
Audit::log($db, 'plugin_uninstall', $project_id, $name);
echo json_encode(['ok' => $rc === 0, 'log' => implode("\n", $out)]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET — list installed plugins
if (!$pkgFile || !is_file($pkgFile)) {
echo json_encode(['plugins' => [], 'no_package_json' => true]);
exit;
}
$pkg = json_decode(file_get_contents($pkgFile), true) ?: [];
$deps = array_merge($pkg['dependencies'] ?? [], $pkg['devDependencies'] ?? []);
$plugins = [];
foreach ($deps as $name => $version) {
if (!str_starts_with($name, 'hexo-')) continue;
$info = readPackageInfo($base . '/node_modules/' . $name);
$plugins[] = [
'name' => $name,
'version' => $version,
'installed' => $info['version'] ?? null,
'description' => $info['description'] ?? null,
'homepage' => $info['homepage'] ?? null,
'repository' => $info['repo'] ?? null,
'npm' => 'https://www.npmjs.com/package/' . $name,
];
}
usort($plugins, fn($a, $b) => strcmp($a['name'], $b['name']));
echo json_encode(['plugins' => $plugins]);
function readPackageInfo(string $modDir): array {
$f = $modDir . '/package.json';
if (!is_file($f)) return [];
$j = json_decode(file_get_contents($f), true) ?: [];
$repo = $j['repository']['url'] ?? ($j['repository'] ?? null);
if (is_string($repo)) {
$repo = preg_replace('#^git\+#', '', $repo);
$repo = preg_replace('#\.git$#', '', $repo);
}
return [
'version' => $j['version'] ?? null,
'description' => $j['description'] ?? null,
'homepage' => $j['homepage'] ?? null,
'repo' => is_string($repo) ? $repo : null,
];
}

306
web/api/posts.php Normal file
View file

@ -0,0 +1,306 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$method = $_SERVER['REQUEST_METHOD'];
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
if (!$base) { echo json_encode(['error' => 'Project path not accessible']); exit; }
$posts_dir = $base . '/source/_posts';
$drafts_dir = $base . '/source/_drafts';
$pages_dir = $base . '/source';
// ── LIST ──────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
$type = $_GET['type'] ?? 'post';
if ($type === 'draft') {
$dir = $drafts_dir;
} elseif ($type === 'page') {
$dir = $pages_dir;
} else {
$dir = $posts_dir;
}
if (!is_dir($dir)) {
echo json_encode(['items' => [], 'missing_dir' => true]);
exit;
}
$items = [];
if ($type === 'post' || $type === 'draft') {
$srcDir = ($type === 'draft') ? $drafts_dir : $posts_dir;
$srcPfx = ($type === 'draft') ? 'source/_drafts/' : 'source/_posts/';
// Recursive scan — posts/drafts may live in subdirs
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS));
foreach ($it as $file) {
if ($file->getExtension() !== 'md') continue;
$abs = $file->getPathname();
$relp = ltrim(str_replace($srcDir, '', $abs), '/');
$fm = parseFrontMatter(file_get_contents($abs));
$items[] = [
'filename' => $file->getFilename(),
'relpath' => $relp,
'path' => $srcPfx . $relp,
'folder' => ltrim(dirname($relp), '.'),
'title' => $fm['title'] ?? basename($abs, '.md'),
'date' => $fm['date'] ?? null,
'modified' => filemtime($abs),
'tags' => $fm['tags'] ?? [],
'categories' => $fm['categories'] ?? [],
];
}
} else {
// Pages: source/*.md + configured subdirectories (default: p, pages)
$pdStmt = $db->prepare('SELECT value FROM project_settings WHERE project_id = ? AND key = ?');
$pdStmt->execute([$project_id, 'page_dirs']);
$pdVal = $pdStmt->fetchColumn();
$extraDirs = $pdVal !== false
? array_filter(array_map('trim', explode("\n", $pdVal)))
: ['p', 'pages'];
// Top-level pages
foreach (glob($pages_dir . '/*.md') as $file) {
$fm = parseFrontMatter(file_get_contents($file));
$items[] = [
'filename' => basename($file),
'relpath' => basename($file),
'path' => 'source/' . basename($file),
'folder' => '',
'title' => $fm['title'] ?? basename($file, '.md'),
'date' => $fm['date'] ?? null,
'modified' => filemtime($file),
'tags' => $fm['tags'] ?? [],
'categories' => $fm['categories'] ?? [],
];
}
// Subdirectory pages
foreach ($extraDirs as $pd) {
$subdir = $pages_dir . '/' . $pd;
if (!is_dir($subdir)) continue;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($subdir,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS)
);
foreach ($it as $file) {
if ($file->getExtension() !== 'md') continue;
$abs = $file->getPathname();
$relp = ltrim(str_replace($pages_dir, '', $abs), '/');
$fm = parseFrontMatter(file_get_contents($abs));
$folder = ltrim(dirname($relp), '.');
$items[] = [
'filename' => $file->getFilename(),
'relpath' => $relp,
'path' => 'source/' . $relp,
'folder' => $folder,
'title' => $fm['title'] ?? basename($abs, '.md'),
'date' => $fm['date'] ?? null,
'modified' => filemtime($abs),
];
}
}
}
usort($items, fn($a, $b) => strcmp($b['date'] ?? '0', $a['date'] ?? '0'));
echo json_encode(['items' => $items]);
exit;
}
// ── CREATE ────────────────────────────────────────────────────────────────────
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$type = $input['type'] ?? 'post';
$title = trim($input['title'] ?? 'Untitled');
$slug = trim($input['slug'] ?? '') ?: slugify($title);
$folder = trim($input['folder'] ?? ''); // e.g. "2026" or "2026/travel"
$body = $input['body'] ?? '';
$fm = trim($input['frontmatter'] ?? '');
$date = date('Y-m-d H:i:s');
if (!$fm) {
$fm = "---\ntitle: \"" . addslashes($title) . "\"\ndate: $date\ntags: []\n---";
}
// Handle draft→post publish action
if (($input['action'] ?? '') === 'publish') {
$relpath = trim($input['relpath'] ?? '');
if (!$relpath || !str_ends_with($relpath, '.md')) {
echo json_encode(['error' => 'Invalid relpath']); exit;
}
$src = realpath($drafts_dir . '/' . $relpath);
if (!$src || !str_starts_with($src . '/', $drafts_dir . '/')) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
$folder = ltrim(dirname($relpath), '.');
$dest = $folder ? $posts_dir . '/' . $folder : $posts_dir;
if (!is_dir($dest)) mkdir($dest, 0755, true);
rename($src, $dest . '/' . basename($relpath));
Audit::log($db, 'post_publish', $project_id, 'source/_posts/' . $relpath);
echo json_encode(['ok' => true, 'path' => 'source/_posts/' . $relpath]);
exit;
}
// Handle duplicate action
if (($input['action'] ?? '') === 'duplicate') {
$relpath = trim($input['relpath'] ?? '');
$srcType = $input['type'] ?? 'post';
if (!$relpath || !str_ends_with($relpath, '.md')) {
echo json_encode(['error' => 'Invalid relpath']); exit;
}
$srcBase = ($srcType === 'draft') ? $drafts_dir : $posts_dir;
$src = realpath($srcBase . '/' . $relpath);
if (!$src || !str_starts_with($src . '/', $srcBase . '/')) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
$info = pathinfo($src);
$newName = $info['filename'] . '-copy.' . $info['extension'];
$dest = $info['dirname'] . '/' . $newName;
// Avoid collision
$i = 2;
while (file_exists($dest)) {
$dest = $info['dirname'] . '/' . $info['filename'] . '-copy' . $i . '.' . $info['extension'];
$i++;
}
copy($src, $dest);
$newRelpath = ltrim(str_replace($srcBase, '', $dest), '/');
$srcPfx = ($srcType === 'draft') ? 'source/_drafts/' : 'source/_posts/';
Audit::log($db, 'post_duplicate', $project_id, $srcPfx . $newRelpath);
echo json_encode(['ok' => true, 'relpath' => $newRelpath, 'path' => $srcPfx . $newRelpath]);
exit;
}
if ($type === 'page') {
$dir = $pages_dir;
} elseif ($type === 'draft') {
$dir = $folder ? $drafts_dir . '/' . ltrim($folder, '/') : $drafts_dir;
$real = realpath($dir) ?: $dir;
if (realpath($drafts_dir) && !str_starts_with($real . '/', realpath($drafts_dir) . '/')) {
http_response_code(403); echo json_encode(['error' => 'Invalid folder']); exit;
}
} else {
$dir = $folder ? $posts_dir . '/' . ltrim($folder, '/') : $posts_dir;
// Security: ensure target stays inside posts_dir
$real = realpath($dir) ?: $dir;
if (realpath($posts_dir) && !str_starts_with($real . '/', realpath($posts_dir) . '/')) {
http_response_code(403); echo json_encode(['error' => 'Invalid folder']); exit;
}
}
if (!is_dir($dir)) mkdir($dir, 0755, true);
$filename = $slug . '.md';
$filepath = $dir . '/' . $filename;
if (file_exists($filepath) && !($input['overwrite'] ?? false)) {
echo json_encode(['error' => 'File already exists', 'filename' => $filename]);
exit;
}
file_put_contents($filepath, $fm . "\n\n" . $body);
$pfx = match($type) {
'page' => 'source/',
'draft' => 'source/_drafts/' . ($folder ? $folder . '/' : ''),
default => 'source/_posts/' . ($folder ? $folder . '/' : ''),
};
$relPost = $pfx . $filename;
Audit::log($db, 'post_create', $project_id, $relPost);
echo json_encode(['ok' => true, 'filename' => $filename, 'path' => $relPost]);
exit;
}
// ── DELETE ────────────────────────────────────────────────────────────────────
if ($method === 'DELETE') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$relpath = trim($input['relpath'] ?? ''); // relative to posts_dir or pages_dir
$type = $input['type'] ?? 'post';
if (!$relpath || !str_ends_with($relpath, '.md')) {
echo json_encode(['error' => 'Invalid path']); exit;
}
$dir = match($type) {
'page' => $pages_dir,
'draft' => $drafts_dir,
default => $posts_dir,
};
$path = realpath($dir . '/' . $relpath);
if (!$path || !str_starts_with($path . '/', $dir . '/')) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
unlink($path);
$pfx = match($type) { 'page' => 'source/', 'draft' => 'source/_drafts/', default => 'source/_posts/' };
Audit::log($db, 'post_delete', $project_id, $pfx . $relpath);
echo json_encode(['ok' => true]);
exit;
}
function parseFrontMatter(string $content): array {
if (!str_starts_with($content, '---')) return [];
$end = strpos($content, '---', 3);
if (!$end) return [];
$yaml = substr($content, 3, $end - 3);
preg_match('/^title:\s*["\']?(.+?)["\']?\s*$/m', $yaml, $tm);
preg_match('/^date:\s*(.+)$/m', $yaml, $dm);
return [
'title' => $tm[1] ?? null,
'date' => $dm[1] ?? null,
'tags' => extractYamlList($yaml, 'tags'),
'categories' => extractYamlList($yaml, 'categories', 'category'),
];
}
function extractYamlList(string $yaml, string $key, ?string $altKey = null): array {
$kq = preg_quote($key, '/');
// 1. Inline array on same line: key: [a, b, c]
if (preg_match('/^' . $kq . ':[ \t]*\[(.+?)\]\s*$/m', $yaml, $m)) {
return array_values(array_filter(array_map(
fn($s) => trim($s, "\"' "), explode(',', $m[1])
)));
}
// 2. Block list (any leading indent, incl. none): key:\n- a\n- b
// Checked BEFORE single-value form so that `\s*` in the single-value
// pattern can't cannibalise the dash-prefixed lines below the key.
if (preg_match('/^' . $kq . ':[ \t]*\n((?:[ \t]*-\s*.+\n?)+)/m', $yaml, $m)) {
preg_match_all('/^[ \t]*-\s*(.+?)\s*$/m', $m[1], $items);
$out = [];
foreach ($items[1] as $item) {
$item = trim($item, "\"' ");
// Hexo nested category form - [Foo, Bar] → take first element
if ($item !== '' && $item[0] === '[') {
$inner = trim($item, "[]");
$first = trim(explode(',', $inner)[0] ?? '', "\"' ");
if ($first !== '') $out[] = $first;
} elseif ($item !== '') {
$out[] = $item;
}
}
return array_values($out);
}
// 3. Single value on the same line as the key: key: foo
// Whitespace must be tab/space (not newline) so this can't hop into a
// block list on the next line.
if (preg_match('/^' . $kq . ':[ \t]+([^\s\[].*?)\s*$/m', $yaml, $m)) {
return [trim($m[1], "\"' ")];
}
// 4. Alt-key fallback (e.g. `category: foo` for `categories`)
if ($altKey && preg_match('/^' . preg_quote($altKey, '/') . ':[ \t]+(.+?)\s*$/m', $yaml, $m)) {
return [trim($m[1], "\"' ")];
}
return [];
}
function slugify(string $s): string {
$s = mb_strtolower($s);
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-') ?: 'post-' . time();
}

146
web/api/projects.php Normal file
View file

@ -0,0 +1,146 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if ($method === 'GET') {
echo json_encode($db->query('SELECT * FROM projects WHERE is_active = 1 ORDER BY is_pinned DESC, name')->fetchAll());
exit;
}
if ($method === 'POST') {
$action = $input['action'] ?? 'create';
if ($action === 'add_scan_path') {
$path = trim($input['path'] ?? '');
$depth = max(1, min(5, (int)($input['depth'] ?? 2)));
if (!$path) { echo json_encode(['error' => 'Path required']); exit; }
$stmt = $db->prepare('INSERT OR REPLACE INTO scan_paths (path, depth) VALUES (?, ?)');
$stmt->execute([$path, $depth]);
Audit::log($db, 'scan_path_add', null, $path . ' depth=' . $depth);
echo json_encode(['ok' => true, 'id' => (int)$db->lastInsertId()]);
exit;
}
if ($action === 'update_type') {
$id = (int)($input['id'] ?? 0);
$type = trim($input['type'] ?? '');
if (!$id || !$type) { echo json_encode(['error' => 'id and type required']); exit; }
$db->prepare('UPDATE projects SET type = ? WHERE id = ?')->execute([$type, $id]);
Audit::log($db, 'project_type_change', $id, $type);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'update_name') {
$id = (int)($input['id'] ?? 0);
$name = trim($input['name'] ?? '');
if (!$id || !$name) { echo json_encode(['error' => 'id and name required']); exit; }
$db->prepare('UPDATE projects SET name = ? WHERE id = ?')->execute([$name, $id]);
Audit::log($db, 'project_rename', $id, $name);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'update_setting') {
$id = (int)($input['id'] ?? 0);
$key = trim($input['key'] ?? '');
$val = $input['value'] ?? null;
if (!$id || !$key) { echo json_encode(['error' => 'id and key required']); exit; }
$db->prepare('INSERT OR REPLACE INTO project_settings (project_id, key, value) VALUES (?, ?, ?)')
->execute([$id, $key, $val]);
Audit::log($db, 'project_setting', $id, $key);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'pin') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$db->prepare('UPDATE projects SET is_pinned = CASE WHEN is_pinned = 1 THEN 0 ELSE 1 END WHERE id = ?')
->execute([$id]);
$stmt = $db->prepare('SELECT is_pinned FROM projects WHERE id = ?');
$stmt->execute([$id]);
$pinned = (bool)$stmt->fetchColumn();
Audit::log($db, $pinned ? 'project_pin' : 'project_unpin', $id);
echo json_encode(['ok' => true, 'is_pinned' => $pinned]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
$db->prepare('UPDATE projects SET is_active = 0 WHERE id = ?')->execute([$id]);
Audit::log($db, 'project_delete', $id);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'remove_scan_path') {
$sid = (int)($input['id'] ?? 0);
$db->prepare('DELETE FROM scan_paths WHERE id = ?')->execute([$sid]);
Audit::log($db, 'scan_path_delete', null, 'id=' . $sid);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'scan') {
$path = trim($input['path'] ?? '');
$depth = max(1, min(5, (int)($input['depth'] ?? 2)));
$real = realpath($path);
if (!$real || !is_dir($real)) {
echo json_encode(['error' => 'Path not found or not a directory']);
exit;
}
$found = [];
scanForProjects($real, $depth, $found);
echo json_encode(['found' => $found]);
exit;
}
// Default: create project
$name = trim($input['name'] ?? '');
$path = trim($input['path'] ?? '');
$type = trim($input['type'] ?? 'generic');
$url = trim($input['url'] ?? '') ?: null;
if (!$name || !$path) {
echo json_encode(['error' => 'Name and path are required']);
exit;
}
$stmt = $db->prepare('INSERT INTO projects (name, path, type, url) VALUES (?, ?, ?, ?)');
$stmt->execute([$name, $path, $type, $url]);
$newId = (int)$db->lastInsertId();
Audit::log($db, 'project_add', $newId, $name . ' (' . $type . ')');
echo json_encode(['ok' => true, 'id' => $newId]);
exit;
}
if ($method === 'DELETE') {
$id = (int)($input['id'] ?? 0);
$db->prepare('UPDATE projects SET is_active = 0 WHERE id = ?')->execute([$id]);
Audit::log($db, 'project_delete', $id);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
function scanForProjects(string $base, int $maxDepth, array &$found, int $depth = 0): void {
if ($depth >= $maxDepth) return;
$items = @scandir($base);
if (!$items) return;
foreach ($items as $item) {
if ($item[0] === '.') continue;
$path = $base . '/' . $item;
if (!is_dir($path)) continue;
$type = ProjectTypes::detect($path);
$typeClass = ProjectTypes::get($type);
$found[] = [
'name' => $item,
'path' => $path,
'type' => $type,
'type_name' => $typeClass ? $typeClass::typeName() : $type,
];
scanForProjects($path, $maxDepth, $found, $depth + 1);
}
}

72
web/api/recent.php Normal file
View file

@ -0,0 +1,72 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id, path FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'touch';
if ($action === 'touch') {
$path = trim($input['path'] ?? '');
if ($path === '') { echo json_encode(['error' => 'path required']); exit; }
$db->prepare('INSERT INTO recent_files (project_id, path, opened_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(project_id, path) DO UPDATE SET opened_at = CURRENT_TIMESTAMP')
->execute([$project_id, $path]);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'clear') {
$db->prepare('DELETE FROM recent_files WHERE project_id = ?')->execute([$project_id]);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'remove') {
$path = trim($input['path'] ?? '');
$db->prepare('DELETE FROM recent_files WHERE project_id = ? AND path = ?')
->execute([$project_id, $path]);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET — list recent files, prune missing ones lazily
$limit = max(1, min(100, (int)($_GET['limit'] ?? 30)));
$rows = $db->prepare('SELECT path, opened_at FROM recent_files
WHERE project_id = ? ORDER BY opened_at DESC LIMIT ?');
$rows->execute([$project_id, $limit]);
$base = realpath($project['path']);
$out = [];
$pruned = [];
foreach ($rows->fetchAll() as $r) {
$abs = $base ? $base . '/' . ltrim($r['path'], '/') : null;
if (!$abs || !is_file($abs)) {
$pruned[] = $r['path'];
continue;
}
$out[] = [
'path' => $r['path'],
'name' => basename($r['path']),
'dir' => dirname($r['path']) === '.' ? '' : dirname($r['path']),
'opened_at' => $r['opened_at'],
'size' => @filesize($abs),
'modified' => @filemtime($abs),
];
}
if ($pruned) {
$del = $db->prepare('DELETE FROM recent_files WHERE project_id = ? AND path = ?');
foreach ($pruned as $p) $del->execute([$project_id, $p]);
}
echo json_encode(['items' => $out]);

62
web/api/run.php Normal file
View file

@ -0,0 +1,62 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
// GET: return command history for a project
if ($method === 'GET') {
$pid = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare(
'SELECT * FROM command_history WHERE project_id = ? ORDER BY run_at DESC LIMIT 50'
);
$stmt->execute([$pid]);
echo json_encode(['history' => $stmt->fetchAll()]);
exit;
}
if ($method !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'POST required']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$project_id = (int)($input['project_id'] ?? 0);
$cmd_id = $input['cmd'] ?? '';
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$type = ProjectTypes::get($project['type']);
if (!$type) { echo json_encode(['error' => 'Unknown project type']); exit; }
// Find the matching whitelisted command
$cmd = null;
foreach ($type::commands() as $c) {
if ($c['id'] === $cmd_id) { $cmd = $c; break; }
}
if (!$cmd) { http_response_code(400); echo json_encode(['error' => 'Unknown command']); exit; }
$path = realpath($project['path']);
if (!$path || !is_dir($path)) {
echo json_encode(['error' => 'Project path not accessible on this server']);
exit;
}
$output = [];
$exit_code = 0;
exec('cd ' . escapeshellarg($path) . ' && ' . $cmd['cmd'] . ' 2>&1', $output, $exit_code);
$outText = implode("\n", $output);
$db->prepare('INSERT INTO command_history (project_id, cmd_id, cmd, output, exit_code) VALUES (?, ?, ?, ?, ?)')
->execute([$project_id, $cmd_id, $cmd['cmd'], $outText, $exit_code]);
Audit::log($db, 'command_run', $project_id, $cmd_id . ' exit=' . $exit_code);
echo json_encode([
'exit_code' => $exit_code,
'output' => $outText,
'cmd' => $cmd['cmd'],
]);

68
web/api/schedules.php Normal file
View file

@ -0,0 +1,68 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
if (!$stmt->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'GET') {
$rows = $db->prepare('SELECT * FROM scheduled_builds WHERE project_id = ? ORDER BY id');
$rows->execute([$project_id]);
echo json_encode(['items' => $rows->fetchAll()]);
exit;
}
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
if ($action === 'create') {
$cmdId = trim($input['cmd_id'] ?? '');
$cron = trim($input['cron'] ?? '');
$en = !empty($input['is_enabled']) ? 1 : 0;
if (!$cmdId || !$cron) { echo json_encode(['error' => 'cmd_id and cron required']); exit; }
$db->prepare('INSERT INTO scheduled_builds (project_id, cmd_id, cron, is_enabled)
VALUES (?, ?, ?, ?)')->execute([$project_id, $cmdId, $cron, $en]);
$sid = (int)$db->lastInsertId();
Audit::log($db, 'schedule_create', $project_id, $cmdId . ' "' . $cron . '"');
echo json_encode(['ok' => true, 'id' => $sid]);
exit;
}
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$sets = []; $args = [];
foreach (['cmd_id', 'cron'] as $k) {
if (array_key_exists($k, $input)) { $sets[] = "$k = ?"; $args[] = trim($input[$k]); }
}
if (array_key_exists('is_enabled', $input)) {
$sets[] = 'is_enabled = ?'; $args[] = $input['is_enabled'] ? 1 : 0;
}
if (!$sets) { echo json_encode(['ok' => true]); exit; }
$args[] = $id; $args[] = $project_id;
$db->prepare('UPDATE scheduled_builds SET ' . implode(', ', $sets)
. ' WHERE id = ? AND project_id = ?')->execute($args);
Audit::log($db, 'schedule_update', $project_id, 'id=' . $id);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
$db->prepare('DELETE FROM scheduled_builds WHERE id = ? AND project_id = ?')
->execute([$id, $project_id]);
Audit::log($db, 'schedule_delete', $project_id, 'id=' . $id);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

26
web/api/scratchpad.php Normal file
View file

@ -0,0 +1,26 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id, scratchpad FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$row = $stmt->fetch();
if (!$row) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'GET') {
echo json_encode(['content' => (string)$row['scratchpad']]);
exit;
}
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$content = (string)($input['content'] ?? '');
$db->prepare('UPDATE projects SET scratchpad = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
->execute([$content, $project_id]);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

32
web/api/search.php Normal file
View file

@ -0,0 +1,32 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$q = trim($_GET['q'] ?? '');
if (!$q) { echo json_encode(['results' => []]); exit; }
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Not found']); exit; }
$base = realpath($project['path']);
if (!$base) { echo json_encode(['error' => 'Path not accessible']); exit; }
$output = [];
exec('grep -rn --include="*.md" -i ' . escapeshellarg($q) . ' ' . escapeshellarg($base) . '/source 2>/dev/null', $output);
$results = [];
foreach ($output as $line) {
if (!preg_match('#^(.+\.md):(\d+):(.+)$#', $line, $m)) continue;
$file = ltrim(str_replace($base, '', $m[1]), '/');
$results[] = [
'file' => $file,
'line' => (int)$m[2],
'content' => trim($m[3]),
];
if (count($results) >= 100) break;
}
echo json_encode(['results' => $results, 'query' => $q]);

58
web/api/snippets.php Normal file
View file

@ -0,0 +1,58 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if ($method === 'GET') {
$pid = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM snippets WHERE project_id = ? ORDER BY name');
$stmt->execute([$pid]);
echo json_encode(['snippets' => $stmt->fetchAll()]);
exit;
}
if ($method === 'POST') {
$action = $input['action'] ?? 'create';
if ($action === 'create') {
$pid = (int)($input['project_id'] ?? 0);
$name = trim($input['name'] ?? '');
$content = $input['content'] ?? '';
if (!$pid || !$name) { echo json_encode(['error' => 'project_id and name required']); exit; }
$stmt = $db->prepare('INSERT INTO snippets (project_id, name, content) VALUES (?, ?, ?)');
$stmt->execute([$pid, $name, $content]);
Audit::log($db, 'snippet_create', $pid, $name);
echo json_encode(['ok' => true, 'id' => (int)$db->lastInsertId()]);
exit;
}
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
$name = trim($input['name'] ?? '');
$content = $input['content'] ?? '';
if (!$id || !$name) { echo json_encode(['error' => 'id and name required']); exit; }
$row = $db->prepare('SELECT project_id FROM snippets WHERE id = ?');
$row->execute([$id]);
$pid = (int)$row->fetchColumn();
$db->prepare('UPDATE snippets SET name = ?, content = ? WHERE id = ?')
->execute([$name, $content, $id]);
Audit::log($db, 'snippet_update', $pid ?: null, $name);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$row = $db->prepare('SELECT project_id, name FROM snippets WHERE id = ?');
$row->execute([$id]);
$r = $row->fetch();
$db->prepare('DELETE FROM snippets WHERE id = ?')->execute([$id]);
Audit::log($db, 'snippet_delete', $r ? (int)$r['project_id'] : null, $r['name'] ?? null);
echo json_encode(['ok' => true]);
exit;
}
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

58
web/api/tags.php Normal file
View file

@ -0,0 +1,58 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Not found']); exit; }
$base = realpath($project['path'] . '/source');
if (!$base || !is_dir($base)) { echo json_encode(['tags' => [], 'categories' => []]); exit; }
$tags = [];
$categories = [];
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS)
);
foreach ($it as $file) {
if ($file->getExtension() !== 'md') continue;
$content = file_get_contents($file->getPathname());
$fm = parseFm($content);
foreach ($fm['tags'] as $t) $tags[$t] = ($tags[$t] ?? 0) + 1;
foreach ($fm['cats'] as $c) $categories[$c] = ($categories[$c] ?? 0) + 1;
}
arsort($tags); arsort($categories);
echo json_encode(['tags' => $tags, 'categories' => $categories]);
function parseFm(string $content): array {
$tags = []; $cats = [];
if (!str_starts_with($content, '---')) return compact('tags', 'cats');
$end = strpos($content, '---', 3);
if (!$end) return compact('tags', 'cats');
$yaml = substr($content, 3, $end - 3);
// tags: [a, b, c] or tags:\n - a\n - b
if (preg_match('/^tags:\s*\[(.+)\]/m', $yaml, $m)) {
$tags = array_map('trim', explode(',', $m[1]));
} elseif (preg_match('/^tags:\s*\n((?:\s+-\s*.+\n?)+)/m', $yaml, $m)) {
preg_match_all('/^\s+-\s*(.+)$/m', $m[1], $items);
$tags = array_map('trim', $items[1]);
}
if (preg_match('/^categories:\s*\[(.+)\]/m', $yaml, $m)) {
$cats = array_map('trim', explode(',', $m[1]));
} elseif (preg_match('/^categories:\s*\n((?:\s+-\s*.+\n?)+)/m', $yaml, $m)) {
preg_match_all('/^\s+-\s*(.+)$/m', $m[1], $items);
$cats = array_map('trim', $items[1]);
} elseif (preg_match('/^category:\s*(.+)$/m', $yaml, $m)) {
$cats = [trim($m[1])];
}
$tags = array_filter(array_map('trim', $tags));
$cats = array_filter(array_map('trim', $cats));
return compact('tags', 'cats');
}

59
web/api/templates.php Normal file
View file

@ -0,0 +1,59 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if ($method === 'GET') {
$pid = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM post_templates WHERE project_id = ? ORDER BY name');
$stmt->execute([$pid]);
echo json_encode(['templates' => $stmt->fetchAll()]);
exit;
}
if ($method === 'POST') {
$action = $input['action'] ?? 'create';
if ($action === 'create') {
$pid = (int)($input['project_id'] ?? 0);
$name = trim($input['name'] ?? '');
$type = $input['type'] ?? 'post';
$content = $input['content'] ?? '';
if (!$pid || !$name) { echo json_encode(['error' => 'project_id and name required']); exit; }
$stmt = $db->prepare('INSERT INTO post_templates (project_id, name, type, content) VALUES (?, ?, ?, ?)');
$stmt->execute([$pid, $name, $type, $content]);
Audit::log($db, 'template_create', $pid, $name);
echo json_encode(['ok' => true, 'id' => (int)$db->lastInsertId()]);
exit;
}
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
$name = trim($input['name'] ?? '');
$content = $input['content'] ?? '';
if (!$id || !$name) { echo json_encode(['error' => 'id and name required']); exit; }
$row = $db->prepare('SELECT project_id FROM post_templates WHERE id = ?');
$row->execute([$id]);
$pid = (int)$row->fetchColumn();
$db->prepare('UPDATE post_templates SET name = ?, content = ? WHERE id = ?')
->execute([$name, $content, $id]);
Audit::log($db, 'template_update', $pid ?: null, $name);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$row = $db->prepare('SELECT project_id, name FROM post_templates WHERE id = ?');
$row->execute([$id]);
$r = $row->fetch();
$db->prepare('DELETE FROM post_templates WHERE id = ?')->execute([$id]);
Audit::log($db, 'template_delete', $r ? (int)$r['project_id'] : null, $r['name'] ?? null);
echo json_encode(['ok' => true]);
exit;
}
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

154
web/api/themes.php Normal file
View file

@ -0,0 +1,154 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
$themesDir = $base ? $base . '/themes' : null;
$configFile = $base ? $base . '/_config.yml' : null;
if (!$base || !is_dir($base)) { echo json_encode(['error' => 'Project path not accessible']); exit; }
function readActiveTheme(?string $configFile): ?string {
if (!$configFile || !is_file($configFile)) return null;
foreach (file($configFile, FILE_IGNORE_NEW_LINES) as $line) {
if (preg_match('/^theme:\s*(.+?)\s*$/', $line, $m)) return trim($m[1], "\"' ");
}
return null;
}
function writeActiveTheme(string $configFile, string $name): bool {
if (!is_file($configFile)) return false;
$lines = file($configFile, FILE_IGNORE_NEW_LINES);
$found = false;
foreach ($lines as $i => $line) {
if (preg_match('/^theme:/', $line)) { $lines[$i] = 'theme: ' . $name; $found = true; break; }
}
if (!$found) $lines[] = 'theme: ' . $name;
return file_put_contents($configFile, implode("\n", $lines) . "\n") !== false;
}
function gitInfo(string $dir): array {
if (!is_dir($dir . '/.git')) return ['has_git' => false];
$run = function (string $cmd) use ($dir) {
$out = [];
exec('cd ' . escapeshellarg($dir) . ' && ' . $cmd . ' 2>/dev/null', $out);
return implode("\n", $out);
};
$branch = trim($run('git rev-parse --abbrev-ref HEAD'));
$remote = trim($run('git remote get-url origin'));
$commit = trim($run('git log -1 --pretty=format:"%h %s"'));
$status = trim($run('git status --porcelain'));
@exec('cd ' . escapeshellarg($dir) . ' && git rev-list --left-right --count @{u}...HEAD 2>/dev/null',
$countOut);
$ahead = $behind = null;
if (!empty($countOut[0]) && preg_match('/^(\d+)\s+(\d+)$/', $countOut[0], $m)) {
$behind = (int)$m[1]; $ahead = (int)$m[2];
}
return [
'has_git' => true,
'branch' => $branch,
'remote' => $remote ?: null,
'commit' => $commit ?: null,
'dirty' => $status !== '',
'ahead' => $ahead,
'behind' => $behind,
];
}
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
$name = preg_replace('/[^A-Za-z0-9._-]/', '', (string)($input['name'] ?? ''));
if ($action === 'switch' && $name) {
if (!is_dir($themesDir . '/' . $name)) { echo json_encode(['error' => 'Theme not found']); exit; }
$ok = writeActiveTheme($configFile, $name);
Audit::log($db, 'theme_switch', $project_id, $name);
echo json_encode(['ok' => $ok, 'active' => $name]);
exit;
}
if ($action === 'clone') {
$url = trim($input['url'] ?? '');
if (!$url) { echo json_encode(['error' => 'url required']); exit; }
if (!$name) {
// derive from url
$name = preg_replace('/\.git$/', '', basename(parse_url($url, PHP_URL_PATH) ?: ''));
$name = preg_replace('/[^A-Za-z0-9._-]/', '', $name);
}
if (!$name) { echo json_encode(['error' => 'cannot derive name']); exit; }
$dest = $themesDir . '/' . $name;
if (is_dir($dest)) { echo json_encode(['error' => 'Theme directory already exists']); exit; }
if (!is_dir($themesDir)) mkdir($themesDir, 0755, true);
$cmd = 'git clone --depth 50 ' . escapeshellarg($url) . ' ' . escapeshellarg($dest) . ' 2>&1';
exec($cmd, $out, $rc);
$log = implode("\n", $out);
if ($rc !== 0) { echo json_encode(['error' => 'git clone failed', 'log' => $log]); exit; }
Audit::log($db, 'theme_clone', $project_id, $name);
echo json_encode(['ok' => true, 'name' => $name, 'log' => $log]);
exit;
}
if ($action === 'git' && $name) {
$op = $input['op'] ?? '';
$dir = $themesDir . '/' . $name;
if (!is_dir($dir . '/.git')) { echo json_encode(['error' => 'No git repo in theme']); exit; }
$cmd = match ($op) {
'pull' => 'git pull',
'push' => 'git push',
'fetch' => 'git fetch',
default => null,
};
if (!$cmd) { echo json_encode(['error' => 'Unknown op']); exit; }
exec('cd ' . escapeshellarg($dir) . ' && ' . $cmd . ' 2>&1', $out, $rc);
Audit::log($db, 'theme_git_' . $op, $project_id, $name);
echo json_encode(['ok' => $rc === 0, 'log' => implode("\n", $out)]);
exit;
}
if ($action === 'delete' && $name) {
if ($name === readActiveTheme($configFile)) {
echo json_encode(['error' => 'Cannot delete the active theme']); exit;
}
$dir = $themesDir . '/' . $name;
if (!is_dir($dir) || !str_starts_with(realpath($dir) . '/', realpath($themesDir) . '/')) {
echo json_encode(['error' => 'Theme not found']); exit;
}
exec('rm -rf ' . escapeshellarg($dir), $_o, $rc);
Audit::log($db, 'theme_delete', $project_id, $name);
echo json_encode(['ok' => $rc === 0]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET list
if (!$themesDir || !is_dir($themesDir)) {
echo json_encode(['active' => null, 'themes' => [], 'no_themes_dir' => true]);
exit;
}
$active = readActiveTheme($configFile);
$themes = [];
foreach (scandir($themesDir) as $name) {
if ($name[0] === '.') continue;
$dir = $themesDir . '/' . $name;
if (!is_dir($dir)) continue;
$themes[] = [
'name' => $name,
'active' => $name === $active,
'git' => gitInfo($dir),
];
}
usort($themes, fn($a, $b) => ((int)$b['active']) - ((int)$a['active']) ?: strcmp($a['name'], $b['name']));
echo json_encode(['active' => $active, 'themes' => $themes]);

33
web/api/track.php Normal file
View file

@ -0,0 +1,33 @@
<?php
// Public visit-tracking endpoint. Whitelisted in index.php; no session required.
$pid = (int)($_GET['p'] ?? 0);
$path = substr(trim((string)($_GET['path'] ?? '/')), 0, 512);
$ref = substr(trim((string)($_GET['ref'] ?? '')), 0, 512);
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? '');
// Truncated salted hashes to count uniques without storing raw values
$salt = 'hackmancms-site-visits';
$uaHash = $ua ? substr(hash('sha256', $salt . $ua), 0, 16) : null;
$ipHash = $ip ? substr(hash('sha256', $salt . $ip), 0, 16) : null;
// Verify the project exists and is active
$st = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$st->execute([$pid]);
if ($st->fetch()) {
try {
$db->prepare('INSERT INTO site_visits (project_id, path, referrer, ua_hash, ip_hash)
VALUES (?, ?, ?, ?, ?)')
->execute([$pid, $path, $ref ?: null, $uaHash, $ipHash]);
} catch (Exception $e) { /* don't fail the pixel on logging error */ }
}
// 1x1 transparent PNG, cache-busted by request
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Access-Control-Allow-Origin: *');
echo base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAYAAjCB0C8AAAAASUVORK5CYII='
);

110
web/api/upload.php Normal file
View file

@ -0,0 +1,110 @@
<?php
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); echo json_encode(['error' => 'POST required']); exit;
}
$project_id = (int)($_POST['project_id'] ?? 0);
$folder = trim($_POST['folder'] ?? '');
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
if (!$base) { echo json_encode(['error' => 'Project path not accessible']); exit; }
// Validate target folder (may not exist yet)
$target_dir = $folder ? $base . '/' . ltrim($folder, '/') : $base;
$real_dir = realpath($target_dir);
if ($real_dir) {
if (!str_starts_with($real_dir . '/', $base . '/') && $real_dir !== $base) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
} else {
// Directory doesn't exist yet — validate parent
$parent = realpath(dirname($target_dir));
if (!$parent || !str_starts_with($parent . '/', $base . '/') && $parent !== $base) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
mkdir($target_dir, 0755, true);
$real_dir = realpath($target_dir);
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'Upload failed (error ' . ($_FILES['file']['error'] ?? '?') . ')']);
exit;
}
$file = $_FILES['file'];
// Validate MIME type
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
$accept = $_POST['accept'] ?? 'image';
if ($accept === 'image') {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $allowed)) {
echo json_encode(['error' => 'Only image files allowed']); exit;
}
}
$safe_name = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $file['name']);
$dest = $real_dir . '/' . $safe_name;
// Avoid overwriting
if (file_exists($dest)) {
$info = pathinfo($safe_name);
$safe_name = $info['filename'] . '_' . time() . '.' . ($info['extension'] ?? '');
$dest = $real_dir . '/' . $safe_name;
}
move_uploaded_file($file['tmp_name'], $dest);
// Optimize: resize images wider than 2000px
if (isset($mime) && in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
optimizeImage($dest, $mime);
}
$rel_path = ltrim(str_replace($base, '', $dest), '/');
$url = $project['url'] ? rtrim($project['url'], '/') . '/' . $rel_path : null;
Audit::log($db, 'file_upload', $project_id, $rel_path);
echo json_encode([
'ok' => true,
'filename' => $safe_name,
'path' => $rel_path,
'url' => $url,
]);
function optimizeImage(string $path, string $mime): void {
if (!function_exists('imagecreatefromjpeg')) return;
$img = match($mime) {
'image/jpeg' => @imagecreatefromjpeg($path),
'image/png' => @imagecreatefrompng($path),
'image/webp' => @imagecreatefromwebp($path),
default => false,
};
if (!$img) return;
$w = imagesx($img);
$h = imagesy($img);
if ($w <= 2000) { imagedestroy($img); return; }
$nw = 2000;
$nh = (int)round($h * 2000 / $w);
$resized = imagecreatetruecolor($nw, $nh);
if ($mime === 'image/png') {
imagealphablending($resized, false);
imagesavealpha($resized, true);
}
imagecopyresampled($resized, $img, 0, 0, 0, 0, $nw, $nh, $w, $h);
match($mime) {
'image/jpeg' => imagejpeg($resized, $path, 85),
'image/png' => imagepng($resized, $path, 8),
'image/webp' => imagewebp($resized, $path, 85),
};
imagedestroy($img);
imagedestroy($resized);
}

440
web/assets/css/app.css Normal file
View file

@ -0,0 +1,440 @@
:root {
--hm-bg: #0d1117;
--hm-surface: #161b22;
--hm-border: #30363d;
--hm-muted: #6e7681;
/* Vertical budget for a project tab pane after navbar + main padding + footer */
--hm-tab-height: calc(100vh - 130px);
}
body {
background-color: var(--hm-bg);
display: flex;
flex-direction: column;
min-height: 100vh;
}
body > main { flex: 1 0 auto; }
body > footer.border-top {
border-top-color: var(--hm-border) !important;
flex-shrink: 0;
}
.navbar,
.card,
.modal-content,
.list-group-item,
.dropdown-menu {
background-color: var(--hm-surface) !important;
border-color: var(--hm-border) !important;
}
.list-group-item { color: inherit; }
.list-group-item-action:hover,
.list-group-item-action:focus { background-color: #21262d !important; }
.nav-tabs { border-color: var(--hm-border); }
.nav-tabs .nav-link { color: var(--hm-muted); border-color: transparent; }
.nav-tabs .nav-link:hover { color: #e6edf3; border-color: transparent; background: #21262d; }
.nav-tabs .nav-link.active {
background-color: var(--hm-surface);
border-color: var(--hm-border) var(--hm-border) var(--hm-surface);
color: #e6edf3;
}
/* Project page header (single-line, condensed) */
.project-header > code {
background: transparent;
padding: 0;
font-size: .8rem;
flex: 1 1 0;
min-width: 0;
}
.project-header h2 { font-weight: 500; }
main.container-fluid { padding-top: 1rem !important; padding-bottom: 1rem !important; }
/* Project page sidebar nav */
.project-sidebar {
flex: 0 0 200px;
position: sticky;
top: 1rem;
align-self: flex-start;
position: relative;
}
.project-sidebar .nav-link {
color: var(--hm-muted);
padding: .35rem .65rem;
border-radius: .375rem;
margin-bottom: .1rem;
font-size: .875rem;
display: flex;
align-items: center;
gap: .55rem;
white-space: nowrap;
}
.project-sidebar .nav-link i {
width: 1rem;
text-align: center;
font-size: 1rem;
flex-shrink: 0;
}
.project-sidebar .nav-link:hover {
background: #21262d;
color: #e6edf3;
}
.project-sidebar .nav-link.active {
background: #1f6feb33;
color: #e6edf3;
border-left: 2px solid #58a6ff;
padding-left: calc(.65rem - 2px);
}
/* Subtle separators between nav groups */
.project-sidebar hr {
border: none;
border-top: 1px solid var(--hm-border);
opacity: .35;
margin: .4rem .25rem;
}
/* Collapse toggle — floating circular button at bottom-left of viewport */
.project-sidebar .sidebar-toggle {
position: fixed;
bottom: 1rem;
left: .5rem;
z-index: 1050;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--hm-surface) !important;
border: 1px solid var(--hm-border) !important;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.project-sidebar .sidebar-toggle:hover {
background: #21262d !important;
color: #e6edf3 !important;
}
.project-sidebar .collapse-icon-collapsed { display: none; }
.project-sidebar.collapsed .collapse-icon-expanded { display: none; }
.project-sidebar.collapsed .collapse-icon-collapsed { display: inline; }
/* Collapsed state — hide labels, shrink width, centre type icon */
.project-sidebar.collapsed { flex: 0 0 48px; }
.project-sidebar.collapsed .nav-link { justify-content: center; padding: .4rem; }
.project-sidebar.collapsed .nav-link.active { padding-left: calc(.4rem - 2px); }
.project-sidebar.collapsed .sidebar-label { display: none; }
.project-sidebar.collapsed .project-sidebar-header > .d-flex {
justify-content: center;
}
/* Auto-collapse on narrow viewports */
@media (max-width: 767.98px) {
.project-sidebar { flex: 0 0 48px; }
.project-sidebar .nav-link { justify-content: center; padding: .4rem; }
.project-sidebar .nav-link.active { padding-left: calc(.4rem - 2px); }
.project-sidebar .sidebar-label { display: none; }
.project-sidebar > .project-sidebar-header > .d-flex {
justify-content: center;
}
}
code { color: #79c0ff; }
pre { color: #e6edf3; }
.breadcrumb-item + .breadcrumb-item::before { color: var(--hm-muted); }
.card-header {
background-color: #1c2128 !important;
border-color: var(--hm-border) !important;
font-size: .875rem;
}
.badge.bg-secondary { background-color: #21262d !important; }
/* CodeMirror in dark context */
.CodeMirror { height: 100%; font-size: .85rem; }
#fileEditCm .CodeMirror { height: 70vh; }
#newPostFmEditor .CodeMirror { height: 120px; }
/* EasyMDE dark tweaks */
.EasyMDE-wrapper, .editor-toolbar, .CodeMirror.cm-s-paper {
background-color: var(--hm-surface) !important;
border-color: var(--hm-border) !important;
color: #e6edf3 !important;
}
.editor-toolbar button { color: #e6edf3 !important; }
.editor-toolbar button:hover, .editor-toolbar button.active { background: #21262d !important; }
.editor-statusbar { color: var(--hm-muted); }
/* Media grid */
#mediaGrid .card { transition: border-color .15s; }
#mediaGrid .card:hover { border-color: #58a6ff !important; }
/* Small button variant */
.btn-xs { padding: .1rem .35rem; font-size: .75rem; line-height: 1.4; }
.btn-xs.btn-outline-secondary { border-color: var(--hm-border); color: var(--hm-muted); }
.btn-xs.btn-outline-secondary:hover { border-color: #8b949e; color: #e6edf3; background: transparent; }
.btn-xs.btn-outline-danger { border-color: var(--hm-border); color: var(--hm-muted); }
.btn-xs.btn-outline-danger:hover { border-color: #f85149; color: #f85149; background: transparent; }
/* Git diff viewer */
.diff-view { background: var(--hm-bg); color: #e6edf3; }
.diff-add { background: rgba(63,185,80,.15); color: #7ee787; display: block; }
.diff-remove { background: rgba(248,81,73,.15); color: #ff7b72; display: block; }
.diff-hunk { color: #79c0ff; display: block; }
.diff-meta { color: var(--hm-muted); display: block; }
/* Git status XY codes */
.git-xy {
min-width: 2em; text-align: center;
color: #d29922; background: rgba(210,153,34,.1);
border-radius: 3px; padding: 0 .3rem;
}
/* Tag cloud */
.tag-cloud { line-height: 2.2; }
.tag-item {
display: inline-block; margin: .15rem .3rem;
padding: .1rem .5rem;
background: #21262d; border-radius: 999px;
color: #79c0ff; cursor: pointer;
text-decoration: none;
transition: background .15s;
}
.tag-item:hover { background: #2d333b; color: #a5d6ff; }
/* Search results */
.search-result:hover code { color: #a5d6ff; }
/* Markdown editor pane: photos banner inside the canvas */
.md-photos-banner { flex-shrink: 0; margin: 0; }
/* Inside-the-canvas variant: above the body content in the WYSIWYG pane,
* one image per row at natural aspect (no crop), full column width. */
.mk-mount-wrap .ie-mk-body .md-photos-banner.inside-editor {
display: block;
padding: .75rem 1rem;
margin-bottom: .25rem;
border-bottom: 1px solid var(--hm-border);
}
.mk-mount-wrap .ie-mk-body .md-photos-banner.inside-editor img {
display: block;
width: 75%;
height: auto;
max-height: none;
object-fit: contain;
margin: 0 auto .5rem;
border-radius: 4px;
border: 1px solid var(--hm-border);
}
.mk-mount-wrap .ie-mk-body .md-photos-banner.inside-editor img:last-child {
margin-bottom: 0;
}
.md-editor-mount {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
/* Floating save button only visible when the tab is dirty (.d-none toggled by JS).
* Bottom-right of the editor, above the content. */
.md-pane-save {
position: absolute;
bottom: 1rem;
right: 1.25rem;
z-index: 50;
border: none;
background: #1f6feb;
color: #fff;
font-size: .9rem;
padding: .5rem 1rem;
border-radius: 999px;
box-shadow: 0 4px 12px rgba(0,0,0,.35);
cursor: pointer;
transition: transform .12s, box-shadow .12s;
}
.md-pane-save:hover {
background: #388bfd;
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(0,0,0,.4);
}
/* Kebab in the editor toolbar (right end) — Delete / Publish */
.mk-pane-kebab .btn { padding: .2rem .45rem; line-height: 1.2; }
.mk-pane-kebab .btn:hover { background: #21262d; color: #e6edf3; }
/* FM editor — slots into the same flex slot as .ie-mk-body when active */
.mk-mount-wrap .ie-mk-fm {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
background: var(--hm-surface);
}
.mk-mount-wrap .ie-mk-fm .CodeMirror { flex: 1; height: 100% !important; }
.mk-mount-wrap .mk-fm-toggle.active {
background: #21262d; color: #e6edf3;
}
/* Milkdown via Agenda-style mk-mount — dark theme + full-height inside tab pane */
.mk-mount-wrap {
border: 1px solid var(--hm-border);
border-radius: .375rem;
background: var(--hm-surface);
overflow: hidden;
display: flex; flex-direction: column;
flex: 1; min-height: 0;
}
.mk-mount-wrap .ie-mk-toolbar {
display: flex; align-items: center; gap: .25rem;
padding: .3rem .5rem;
background: #1c2128;
border-bottom: 1px solid var(--hm-border);
flex-shrink: 0;
}
.mk-mount-wrap .ie-mk-tools { display: flex; gap: .25rem; flex: 1; flex-wrap: wrap; }
.mk-mount-wrap .ie-mk-sep { width: 1px; height: 16px; background: var(--hm-border); margin: 0 .1rem; }
.mk-mount-wrap .ie-mk-toolbar .btn { padding: .2rem .45rem; line-height: 1.2; color: #c9d1d9; border-color: var(--hm-border); }
.mk-mount-wrap .ie-mk-toolbar .btn:hover { background: #21262d; color: #e6edf3; }
/* JS (_reflowMdEditor) sets explicit pixel heights on .ie-mk-body and
* .ProseMirror that's what makes overflow-y: auto fire reliably and
* gives the empty editor a definite minimum. CSS just provides a sensible
* fallback for the brief moment before the first reflow. */
.mk-mount-wrap .ie-mk-body {
flex: 1; min-height: 0;
overflow-y: auto;
position: relative;
}
.mk-mount-wrap .ie-mk-body .ProseMirror {
padding: .75rem 1rem; font-size: .92rem; line-height: 1.55;
outline: none; color: #e6edf3;
}
.mk-mount-wrap .ie-mk-body .ProseMirror p { margin-bottom: .5rem; }
.mk-mount-wrap .ie-mk-body .ProseMirror ul,
.mk-mount-wrap .ie-mk-body .ProseMirror ol { padding-left: 1.4rem; margin-bottom: .5rem; }
.mk-mount-wrap .ie-mk-body .ProseMirror code {
background: #1c2128; color: #79c0ff;
padding: .1em .35em; border-radius: 3px;
}
.mk-mount-wrap .ie-mk-body .ProseMirror pre {
background: var(--hm-bg); border: 1px solid var(--hm-border);
border-radius: 4px; padding: .5rem; color: #e6edf3;
}
.mk-mount-wrap .ie-mk-body .ProseMirror pre code { background: transparent; padding: 0; }
.mk-mount-wrap .ie-mk-body .ProseMirror blockquote {
border-left: 3px solid var(--hm-border);
margin: .5rem 0; padding: .1rem 1rem; color: var(--hm-muted);
}
.mk-mount-wrap .ie-mk-body .ProseMirror a { color: #58a6ff; }
.mk-mount-wrap .ie-mk-body .ProseMirror img {
display: block;
width: 75%;
height: auto;
max-width: 75%;
margin: .5rem auto;
border-radius: 4px;
}
.mk-mount-wrap .ie-mk-body textarea.ie-mk-ta {
display: block; width: 100%; box-sizing: border-box;
border: none !important; border-radius: 0 !important;
background: var(--hm-surface) !important; color: #e6edf3 !important;
font-family: ui-monospace, monospace; font-size: .9rem;
min-height: calc(var(--hm-tab-height) - 260px);
resize: none;
}
/* Pinned project highlight */
.card:has(.badge.bg-warning) { border-color: rgba(210,153,34,.4) !important; }
/* Audit log table */
#auditTable .table { font-size: .82rem; }
#auditTable code { font-size: .8rem; }
/* Post folder tree — no folder icon, clean section headers */
.post-section-header {
display: flex; align-items: center; gap: .4rem;
padding: .25rem .5rem; margin-top: .5rem; margin-bottom: .15rem;
font-size: .75rem; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--hm-muted);
cursor: pointer; border-radius: 4px;
user-select: none;
}
.post-section-header:hover { background: #21262d; color: #e6edf3; }
.post-section-chevron { transition: transform .15s; font-size: .65rem; }
.post-section-header[aria-expanded="true"] .post-section-chevron { transform: rotate(90deg); }
.posts-tree .list-group-item { cursor: pointer; }
.posts-tree .list-group-item:hover { background: #21262d !important; }
.posts-tree .list-group { border-radius: 4px; }
/* File browser drag-and-drop */
.split-list.drag-over {
outline: 2px dashed #58a6ff;
outline-offset: -4px;
background: rgba(88, 166, 255, 0.04) !important;
}
/* Split-pane layout */
.h-split { height: var(--hm-tab-height); min-height: 400px; overflow: hidden; }
.split-list { overflow-y: auto; height: 100%; }
#editorPane,
#draftEditorPane { height: 100%; overflow-y: auto; display: flex; flex-direction: column; }
.editor-placeholder {
text-align: center; padding-top: 3rem;
color: var(--hm-muted); font-size: .875rem;
}
.pane-cm-container { border: 1px solid var(--hm-border); border-radius: 4px; flex: 1; overflow: hidden; }
.pane-cm-container { height: 100%; }
.pane-cm-container .CodeMirror { height: 100% !important; min-height: 260px; }
/* Editor tab bar */
.editor-tab-bar {
display: flex; align-items: stretch; gap: 0;
border-bottom: 1px solid var(--hm-border);
overflow-x: auto; flex-shrink: 0;
scrollbar-width: none;
min-height: 32px;
}
.editor-tab-bar::-webkit-scrollbar { display: none; }
.editor-tab {
display: flex; align-items: center; gap: .35rem;
padding: .25rem .65rem; font-size: .78rem;
border-right: 1px solid var(--hm-border);
white-space: nowrap; cursor: pointer;
color: var(--hm-muted); background: transparent;
min-width: 0; max-width: 180px;
border-bottom: 2px solid transparent;
flex-shrink: 0;
}
.editor-tab:hover { background: #21262d; color: #e6edf3; }
.editor-tab.active { color: #e6edf3; border-bottom-color: #58a6ff; background: #21262d; }
.editor-tab .tab-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.editor-tab .tab-dirty { color: #e3b341; font-size: .9em; flex-shrink: 0; }
.editor-tab .tab-close {
flex-shrink: 0; opacity: .5; font-size: .7rem; line-height: 1;
padding: .1rem .2rem; border-radius: 3px; margin-left: .15rem;
}
.editor-tab .tab-close:hover { opacity: 1; background: rgba(255,255,255,.1); }
.editor-tab-content { flex: 1; overflow: hidden; min-height: 0; }
.editor-tab-content > div { height: 100%; }
/* File preview in pane */
.file-preview-pane {
display: flex; flex-direction: column; align-items: center;
justify-content: flex-start; padding: 1rem; height: 100%; overflow: auto;
}
.file-preview-pane img { max-width: 100%; max-height: calc(var(--hm-tab-height) - 80px); object-fit: contain; border-radius: 4px; }
.file-preview-pane embed { width: 100%; height: calc(var(--hm-tab-height) - 80px); border-radius: 4px; }
.file-preview-meta { font-size: .8rem; color: var(--hm-muted); margin-top: .75rem; text-align: center; }
/* Gallery lightbox */
.gallery-overlay {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,.9);
display: flex; align-items: center; justify-content: center;
cursor: zoom-out;
}
.gallery-overlay img { max-width: 90vw; max-height: 90vh; object-fit: contain; border-radius: 4px; }
.gallery-overlay .gallery-caption {
position: absolute; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,.7); font-size: .85rem;
}

BIN
web/assets/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

3236
web/assets/js/app.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,362 @@
/**
* milkdown-mount.js auto-mount a Milkdown WYSIWYG editor on any
* <textarea class="mk-mount"> and keep it synced with the underlying
* textarea. Adapted from Agenda (/opt/agenda/web/lib/milkdown-mount.js)
* with English UI and HackmanCMS's dark theme.
*
* Relies on window.MilkdownKit being populated by the loader in view.php.
*
* data- attributes on the textarea:
* data-mk-min-height="20rem" override the editor's min-height
* data-mk-required empty content blocks form submit
*/
(function () {
'use strict';
// ── Link dialog ─────────────────────────────────────────────────────
var _linkModalEl = null;
function ensureLinkModal() {
if (_linkModalEl) return _linkModalEl;
var el = document.createElement('div');
el.className = 'modal fade';
el.tabIndex = -1;
el.setAttribute('aria-hidden', 'true');
el.innerHTML =
'<div class="modal-dialog modal-dialog-centered modal-sm">' +
'<div class="modal-content">' +
'<form class="ld-form">' +
'<div class="modal-header py-2">' +
'<h5 class="modal-title h6 mb-0"><i class="bi bi-link-45deg"></i> Link</h5>' +
'<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="mb-2">' +
'<label class="form-label small mb-1">URL</label>' +
'<input type="text" class="form-control form-control-sm ld-url" required ' +
'placeholder="https://… / /path / mailto:…">' +
'<div class="invalid-feedback small ld-url-warning"></div>' +
'</div>' +
'<div class="mb-1 ld-text-wrap">' +
'<label class="form-label small mb-1">Text <span class="text-muted">(optional)</span></label>' +
'<input type="text" class="form-control form-control-sm ld-text">' +
'</div>' +
'</div>' +
'<div class="modal-footer justify-content-between py-2">' +
'<button type="button" class="btn btn-sm btn-outline-danger ld-remove d-none">' +
'<i class="bi bi-trash"></i> Remove' +
'</button>' +
'<div class="ms-auto d-flex gap-2">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>' +
'<button type="submit" class="btn btn-sm btn-primary ld-confirm">Insert</button>' +
'</div>' +
'</div>' +
'</form>' +
'</div>' +
'</div>';
document.body.appendChild(el);
_linkModalEl = el;
return el;
}
function isPlausibleUrl(url) {
if (!url) return false;
return /^(https?:|mailto:|tel:|ftp:|#|\/|[\w.-]+\.[a-z]{2,})/i.test(url.trim());
}
window.LinkDialog = {
open: function (opts) {
opts = opts || {};
var el = ensureLinkModal();
var inst = bootstrap.Modal.getOrCreateInstance(el);
var form = el.querySelector('.ld-form');
var urlEl = el.querySelector('.ld-url');
var textEl = el.querySelector('.ld-text');
var textWrap = el.querySelector('.ld-text-wrap');
var removeBtn = el.querySelector('.ld-remove');
var warnEl = el.querySelector('.ld-url-warning');
var confirmBtn = el.querySelector('.ld-confirm');
urlEl.value = opts.url || '';
textEl.value = opts.text || '';
urlEl.classList.remove('is-invalid');
warnEl.textContent = '';
textWrap.classList.toggle('d-none', !!opts.urlOnly);
removeBtn.classList.toggle('d-none', !opts.canRemove);
confirmBtn.textContent = opts.canRemove ? 'Save' : 'Insert';
return new Promise(function (resolve) {
var done = false;
function cleanup(result) {
if (done) return; done = true;
form.removeEventListener('submit', onSubmit);
removeBtn.removeEventListener('click', onRemove);
el.removeEventListener('hidden.bs.modal', onHidden);
el.removeEventListener('shown.bs.modal', onShown);
inst.hide();
resolve(result);
}
function onSubmit(e) {
e.preventDefault();
var url = urlEl.value.trim();
if (!url) { urlEl.classList.add('is-invalid'); warnEl.textContent = 'URL required.'; return; }
if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('/') && !url.startsWith('#')) {
if (/^[\w.-]+\.[a-z]{2,}/i.test(url)) url = 'https://' + url;
}
if (!isPlausibleUrl(url)) {
urlEl.classList.add('is-invalid'); warnEl.textContent = "Doesn't look like a valid URL.";
urlEl.addEventListener('input', function once() {
urlEl.classList.remove('is-invalid'); warnEl.textContent = '';
urlEl.removeEventListener('input', once);
});
return;
}
cleanup({ action: 'confirm', url: url, text: textEl.value.trim() });
}
function onRemove() { cleanup({ action: 'remove' }); }
function onHidden() { cleanup(null); }
function onShown() { urlEl.focus(); urlEl.select(); }
form.addEventListener('submit', onSubmit);
removeBtn.addEventListener('click', onRemove);
el.addEventListener('hidden.bs.modal', onHidden);
el.addEventListener('shown.bs.modal', onShown);
inst.show();
});
}
};
function mountAll() {
var kit = window.MilkdownKit;
if (!kit) return;
document.querySelectorAll('textarea.mk-mount:not([data-mk-mounted])').forEach(function (ta) {
ta.dataset.mkMounted = '1';
mountOne(ta, kit);
});
}
function mountOne(ta, kit) {
var form = ta.closest('form');
var wrap = document.createElement('div');
wrap.className = 'mk-mount-wrap';
var toolbar = document.createElement('div');
toolbar.className = 'ie-mk-toolbar';
toolbar.innerHTML =
'<div class="ie-mk-tools">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="bold" title="Bold"><i class="bi bi-type-bold"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="italic" title="Italic"><i class="bi bi-type-italic"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="strikethrough" title="Strikethrough"><i class="bi bi-type-strikethrough"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="inlineCode" title="Inline code"><i class="bi bi-code"></i></button>' +
'<span class="ie-mk-sep"></span>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="bullet" title="Bullet list"><i class="bi bi-list-ul"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="ordered" title="Ordered list"><i class="bi bi-list-ol"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="blockquote" title="Quote"><i class="bi bi-blockquote-left"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="codeBlock" title="Code block"><i class="bi bi-code-square"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="link" title="Link"><i class="bi bi-link-45deg"></i></button>' +
'</div>' +
'<button type="button" class="btn btn-sm btn-outline-secondary mk-mode" title="Switch to Markdown source">' +
'<i class="bi bi-markdown"></i> MD' +
'</button>';
var body = document.createElement('div');
body.className = 'ie-mk-body';
// Only honour an explicit data-mk-min-height; otherwise let CSS size the
// body via the surrounding flex layout (so it actually fills the tab pane
// and lets the inner ProseMirror trigger overflow scroll).
var minH = ta.dataset.mkMinHeight;
if (minH) body.style.minHeight = minH;
var editorEl = document.createElement('div');
body.appendChild(editorEl);
var mdTa = document.createElement('textarea');
mdTa.className = 'form-control ie-mk-ta';
mdTa.rows = 4;
mdTa.style.display = 'none';
if (minH) mdTa.style.minHeight = minH;
body.appendChild(mdTa);
wrap.appendChild(toolbar);
wrap.appendChild(body);
var initialMd = ta.value;
ta.style.display = 'none';
var wasRequired = ta.hasAttribute('required');
if (wasRequired) { ta.removeAttribute('required'); ta.dataset.mkRequired = '1'; }
ta.parentNode.insertBefore(wrap, ta);
var mkEditor = null;
var mode = 'wysiwyg';
kit.Editor.make()
.config(function (ctx) {
ctx.set(kit.rootCtx, editorEl);
ctx.set(kit.defaultValueCtx, initialMd);
})
.use(kit.commonmark).use(kit.gfm).use(kit.history)
.create()
.then(function (ed) {
mkEditor = ed;
ta.dispatchEvent(new CustomEvent('mk-ready'));
})
.catch(function (err) { console.error('[mk-mount] init failed:', err); });
function getContent() {
if (mode === 'markdown') return mdTa.value;
if (mkEditor) return mkEditor.action(kit.getMarkdown());
return initialMd;
}
function setContent(md) {
if (mode === 'markdown') { mdTa.value = md; return; }
if (mkEditor) mkEditor.action(kit.replaceAll(md));
}
function insertMd(before, after) {
var s = mdTa.selectionStart, e = mdTa.selectionEnd;
var sel = mdTa.value.substring(s, e);
mdTa.value = mdTa.value.substring(0, s) + before + sel + after + mdTa.value.substring(e);
mdTa.selectionStart = s + before.length;
mdTa.selectionEnd = s + before.length + sel.length;
mdTa.focus();
}
toolbar.querySelector('.ie-mk-tools').addEventListener('click', function (e) {
var btn = e.target.closest('[data-cmd]'); if (!btn) return;
e.preventDefault();
var cmd = btn.dataset.cmd;
if (mode === 'markdown') {
var md = {
bold: function () { insertMd('**', '**'); },
italic: function () { insertMd('*', '*'); },
strikethrough: function () { insertMd('~~', '~~'); },
inlineCode: function () { insertMd('`', '`'); },
bullet: function () { insertMd('- ', ''); },
ordered: function () { insertMd('1. ', ''); },
blockquote: function () { insertMd('> ', ''); },
codeBlock: function () { insertMd('```\n', '\n```'); },
link: function () {
var s = mdTa.selectionStart, e = mdTa.selectionEnd;
var sel = mdTa.value.substring(s, e);
window.LinkDialog.open({ text: sel }).then(function (r) {
if (!r || r.action !== 'confirm') { mdTa.focus(); return; }
var txt = r.text || r.url;
var out = '[' + txt + '](' + r.url + ')';
mdTa.value = mdTa.value.substring(0, s) + out + mdTa.value.substring(e);
mdTa.selectionStart = mdTa.selectionEnd = s + out.length;
mdTa.focus();
});
},
};
if (md[cmd]) md[cmd](); return;
}
if (!mkEditor) return;
var c = kit.commands;
function exec(key, payload) { mkEditor.action(function (ctx) { ctx.get(kit.commandsCtx).call(key, payload); }); }
var wy = {
bold: function () { exec(c.bold.key); },
italic: function () { exec(c.italic.key); },
strikethrough: function () { exec(c.strikethrough.key); },
inlineCode: function () { exec(c.inlineCode.key); },
bullet: function () { exec(c.bulletList.key); },
ordered: function () { exec(c.orderedList.key); },
blockquote: function () { exec(c.blockquote.key); },
codeBlock: function () { exec(c.codeBlock.key); },
link: function () {
window.LinkDialog.open({ urlOnly: true }).then(function (r) {
if (!r || r.action !== 'confirm') return;
exec(c.link.key, { href: r.url });
var pm = editorEl.querySelector('.ProseMirror'); if (pm) pm.focus();
});
},
};
if (wy[cmd]) wy[cmd]();
var pm = editorEl.querySelector('.ProseMirror'); if (pm) pm.focus();
});
editorEl.addEventListener('click', function (evt) {
if (mode !== 'wysiwyg') return;
var a = evt.target.closest('a'); if (!a || !editorEl.contains(a)) return;
evt.preventDefault(); evt.stopPropagation();
var pm = editorEl.querySelector('.ProseMirror');
var href = a.getAttribute('href') || '', text = a.textContent || '';
function selectAnchor() {
if (!pm || !document.body.contains(a)) return false;
pm.focus();
var range = document.createRange(); range.selectNodeContents(a);
var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
return true;
}
selectAnchor();
window.LinkDialog.open({ url: href, text: text, canRemove: true, urlOnly: true }).then(function (r) {
if (!r) return;
if (!selectAnchor() || !mkEditor) return;
var c = kit.commands;
function exec(key, payload) { mkEditor.action(function (ctx) { ctx.get(kit.commandsCtx).call(key, payload); }); }
if (r.action === 'remove') { exec(c.link.key); }
else if (r.action === 'confirm') { exec(c.link.key); exec(c.link.key, { href: r.url }); }
if (pm) pm.focus();
});
});
function switchMkMode() {
if (mode === 'wysiwyg') {
mdTa.value = getContent();
editorEl.style.display = 'none';
mdTa.style.display = '';
mdTa.focus();
mode = 'markdown';
} else {
var md = mdTa.value;
mdTa.style.display = 'none';
editorEl.style.display = '';
if (mkEditor) mkEditor.action(kit.replaceAll(md));
mode = 'wysiwyg';
}
var modeBtn = toolbar.querySelector('.mk-mode');
if (modeBtn) modeBtn.innerHTML = mode === 'wysiwyg'
? '<i class="bi bi-markdown"></i> MD'
: '<i class="bi bi-eye"></i> WYSIWYG';
}
toolbar.querySelector('.mk-mode').addEventListener('click', switchMkMode);
// Track dirty changes
editorEl.addEventListener('input', function () {
if (typeof ta.onMkInput === 'function') ta.onMkInput();
}, true);
mdTa.addEventListener('input', function () {
if (typeof ta.onMkInput === 'function') ta.onMkInput();
});
if (form) {
form.addEventListener('submit', function (e) {
var md = getContent().trim();
if (ta.dataset.mkRequired && md === '') {
e.preventDefault();
var pm = editorEl.querySelector('.ProseMirror');
if (pm) pm.focus(); else mdTa.focus();
return;
}
ta.value = md;
});
}
ta.mkMount = {
getContent: getContent,
setContent: setContent,
getMode: function () { return mode; },
setMode: function (target) {
// target: 'wysiwyg' | 'markdown'
if (target !== 'wysiwyg' && target !== 'markdown') return;
if (mode !== target) switchMkMode();
},
};
ta.dispatchEvent(new CustomEvent('mk-mounted'));
}
if (window.MilkdownKit) mountAll();
else window.addEventListener('milkdown-ready', mountAll);
// Re-scan whenever new mk-mount textareas get inserted (e.g. opening a new tab)
var moObserver = new MutationObserver(mountAll);
moObserver.observe(document.body, { childList: true, subtree: true });
})();

48
web/index.php Normal file
View file

@ -0,0 +1,48 @@
<?php
define('ROOT', dirname(__DIR__));
require ROOT . '/lib/bootstrap.php';
$uri = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') ?: '/';
// Public routes — no auth required
if ($uri === '/login') {
if (Auth::check()) { header('Location: /'); exit; }
include ROOT . '/views/login.php';
exit;
}
if ($uri === '/api/auth') {
include ROOT . '/web/api/auth.php';
exit;
}
// All other routes require login
Auth::requireLogin();
ProjectTypes::load();
if ($uri === '/' || $uri === '/dashboard') {
include ROOT . '/views/dashboard.php';
} elseif ($uri === '/settings') {
include ROOT . '/views/settings.php';
} elseif ($uri === '/audit') {
include ROOT . '/views/audit.php';
} elseif (preg_match('#^/project/(\d+)$#', $uri, $m)) {
$project_id = (int)$m[1];
include ROOT . '/views/project/view.php';
} elseif (preg_match('#^/api/([a-z_]+)#', $uri, $m)) {
$api_file = ROOT . '/web/api/' . $m[1] . '.php';
if (file_exists($api_file)) {
include $api_file;
} else {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'API endpoint not found']);
}
} else {
http_response_code(404);
include ROOT . '/views/error.php';
}