Init
This commit is contained in:
commit
60ca58f5ba
80 changed files with 9458 additions and 0 deletions
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
374
CLAUDE.md
Normal 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 |
|
||||
| 1–90 days | `site_visits` (raw) + rollups | full timestamps, plus rollups |
|
||||
| 91–365 days | `site_visits_hourly` + `_daily` | hour-granular path/status/referrer |
|
||||
| > 365 days | `site_visits_daily` only | day-granular path/status/referrer |
|
||||
|
||||
Rollups are built nightly (throttled to once per project per 24h) by
|
||||
aggregating raw rows GROUP BY (hour or day, path, status, referrer). The
|
||||
INSERT OR REPLACE on the rollup tables' UNIQUE constraint makes the rollup
|
||||
**idempotent** — re-rolling a day produces the same rows.
|
||||
|
||||
Aging-out drops raw rows >90d and hourly rows >365d. By the time a row is
|
||||
dropped, the equivalent aggregate is already in the next tier — no count
|
||||
information is lost. Audit-logged as `analytics_rollup`.
|
||||
|
||||
**Daily-rotating salt for IP hashes.** The importer hashes IPs with
|
||||
`base_salt + visit_date`, so the same IP gets a different `ip_hash` on
|
||||
different days. Within a day, distinct counts are exact; across days,
|
||||
visitors look like new visitors. This makes the stored data anonymized
|
||||
rather than pseudonymized for GDPR purposes — once the salt has rotated
|
||||
past, no one (including the controller) can re-link yesterday's hashes to
|
||||
today's visits. Trade-off: "unique visitors over multiple days" is the
|
||||
sum of per-day unique counts (each visitor counted once per day they
|
||||
visited), not deduplicated across days. UI surfaces this in a tooltip.
|
||||
|
||||
**Cron entry** (run on the box hosting both HackmanCMS and the web server, as a
|
||||
user with read access to the log files — typically root or a member of `adm`):
|
||||
|
||||
```bash
|
||||
*/5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php
|
||||
```
|
||||
|
||||
`web/api/analytics_import.php` (auth required) is the same code path with
|
||||
three actions: `run` (the "Import now" button), `reset` (clear cursors so
|
||||
the next run reimports from start), `wipe` (drop all visits + cursors).
|
||||
|
||||
`web/api/analytics.php` aggregates over a configurable window (7/30/90/365
|
||||
days) and returns: window + previous-window totals (for delta KPIs),
|
||||
all-time totals, top pages, top referrers, daily series (current + previous
|
||||
period for chart overlay), hour-of-day distribution, top 404s, and
|
||||
status-code mix.
|
||||
|
||||
**Note:** `web/api/track.php` (the old 1×1 pixel endpoint) is no longer wired
|
||||
into the public route table in `index.php`. The file is left in place as a
|
||||
dormant fallback for cases where the managed site is *not* on the same box —
|
||||
re-add the whitelist line in `index.php` to bring it back.
|
||||
|
||||
## Markdown editor (Milkdown)
|
||||
|
||||
`*.md` and `*.markdown` files open in a **Milkdown** WYSIWYG editor mounted
|
||||
via the Agenda-style `mk-mount.js` pattern. Milkdown is loaded as ESM from
|
||||
`esm.sh`'s pre-compiled `/es2022/` paths — same trick Agenda uses to keep
|
||||
all `@milkdown/*` sub-packages on a single shared `core` instance (otherwise
|
||||
ProseMirror's `SchemaReady` timer fails). The loader lives in `view.php`:
|
||||
|
||||
```html
|
||||
<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
114
README.md
Normal 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
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.3
|
||||
75
bin/deploy.sh
Executable file
75
bin/deploy.sh
Executable 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
289
bin/import-site-logs.php
Normal 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 0–90 days — full hour-level + visit-level detail
|
||||
* site_visits_hourly 90–365 — 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
10
bin/migrate.php
Executable 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
86
bin/run-schedules.php
Normal 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
7
config/config.php
Normal 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
0
data/.gitkeep
Normal file
52
deploylocal.sh
Executable file
52
deploylocal.sh
Executable 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
9
lib/Audit.php
Normal 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
46
lib/Auth.php
Normal 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
41
lib/DB.php
Normal 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
39
lib/ProjectTypes.php
Normal 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
38
lib/bootstrap.php
Normal 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;
|
||||
}
|
||||
9
lib/project-types/GenericProject.php
Normal file
9
lib/project-types/GenericProject.php
Normal 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'; }
|
||||
}
|
||||
29
lib/project-types/HexoProject.php
Normal file
29
lib/project-types/HexoProject.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
25
lib/project-types/ProjectTypeBase.php
Normal file
25
lib/project-types/ProjectTypeBase.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
17
lib/project-types/StorageProject.php
Normal file
17
lib/project-types/StorageProject.php
Normal 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');
|
||||
}
|
||||
}
|
||||
17
lib/project-types/WebsiteProject.php
Normal file
17
lib/project-types/WebsiteProject.php
Normal 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
29
sql/001_initial.sql
Normal 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
46
sql/002_milestone4.sql
Normal 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
11
sql/003_db_drafts.sql
Normal 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
1
sql/004_scratchpad.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE projects ADD COLUMN scratchpad TEXT NOT NULL DEFAULT '';
|
||||
44
sql/005_milestone5.sql
Normal file
44
sql/005_milestone5.sql
Normal 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
15
sql/006_analytics.sql
Normal 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);
|
||||
5
sql/007_analytics_status.sql
Normal file
5
sql/007_analytics_status.sql
Normal 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);
|
||||
37
sql/008_analytics_rollups.sql
Normal file
37
sql/008_analytics_rollups.sql
Normal 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
44
views/_footer.php
Normal 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'] !== ''): ?>
|
||||
· <code><?= htmlspecialchars($b['sha']) ?></code>
|
||||
<?php endif; ?>
|
||||
<?php if ($b['built'] !== ''): ?>
|
||||
· 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
65
views/_header.php
Normal 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
31
views/audit.php
Normal 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
128
views/dashboard.php
Normal 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
24
views/error.php
Normal 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
51
views/login.php
Normal 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>
|
||||
105
views/project/_tab_analytics.php
Normal file
105
views/project/_tab_analytics.php
Normal 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"> </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>
|
||||
15
views/project/_tab_config.php
Normal file
15
views/project/_tab_config.php
Normal 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>
|
||||
44
views/project/_tab_dashboard.php
Normal file
44
views/project/_tab_dashboard.php
Normal 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>
|
||||
21
views/project/_tab_drafts.php
Normal file
21
views/project/_tab_drafts.php
Normal 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>
|
||||
84
views/project/_tab_git.php
Normal file
84
views/project/_tab_git.php
Normal 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 & 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>
|
||||
30
views/project/_tab_links.php
Normal file
30
views/project/_tab_links.php
Normal 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>
|
||||
11
views/project/_tab_notes.php
Normal file
11
views/project/_tab_notes.php
Normal 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>
|
||||
27
views/project/_tab_plugins.php
Normal file
27
views/project/_tab_plugins.php
Normal 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>
|
||||
12
views/project/_tab_recent.php
Normal file
12
views/project/_tab_recent.php
Normal 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>
|
||||
9
views/project/_tab_search.php
Normal file
9
views/project/_tab_search.php
Normal 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>
|
||||
231
views/project/_tab_settings.php
Normal file
231
views/project/_tab_settings.php
Normal 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 & 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 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; ?>
|
||||
19
views/project/_tab_tags.php
Normal file
19
views/project/_tab_tags.php
Normal 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>
|
||||
46
views/project/_tab_themes.php
Normal file
46
views/project/_tab_themes.php
Normal 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
431
views/project/view.php
Normal 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
84
views/settings.php
Normal 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
4
web/.htaccess
Normal 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
187
web/api/analytics.php
Normal 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',
|
||||
],
|
||||
]);
|
||||
39
web/api/analytics_import.php
Normal file
39
web/api/analytics_import.php
Normal 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
34
web/api/audit.php
Normal 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
48
web/api/auth.php
Normal 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
68
web/api/backup.php
Normal 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
46
web/api/disk.php
Normal 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
111
web/api/drafts.php
Normal 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
122
web/api/files.php
Normal 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
221
web/api/git.php
Normal 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
204
web/api/links.php
Normal 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
87
web/api/plugins.php
Normal 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
306
web/api/posts.php
Normal 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
146
web/api/projects.php
Normal 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
72
web/api/recent.php
Normal 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
62
web/api/run.php
Normal 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
68
web/api/schedules.php
Normal 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
26
web/api/scratchpad.php
Normal 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
32
web/api/search.php
Normal 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
58
web/api/snippets.php
Normal 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
58
web/api/tags.php
Normal 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
59
web/api/templates.php
Normal 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
154
web/api/themes.php
Normal 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
33
web/api/track.php
Normal 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
110
web/api/upload.php
Normal 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
440
web/assets/css/app.css
Normal 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
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
3236
web/assets/js/app.js
Normal file
File diff suppressed because it is too large
Load diff
362
web/assets/js/milkdown-mount.js
Normal file
362
web/assets/js/milkdown-mount.js
Normal 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
48
web/index.php
Normal 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';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue