Init
This commit is contained in:
commit
60ca58f5ba
80 changed files with 9458 additions and 0 deletions
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