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

75
bin/deploy.sh Executable file
View file

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

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

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

10
bin/migrate.php Executable file
View file

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

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

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