adding auth
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
FROM php:8.3-fpm-alpine
|
||||
|
||||
RUN apk add --no-cache linux-headers \
|
||||
&& docker-php-ext-install pcntl sockets || \
|
||||
docker-php-ext-install pcntl
|
||||
RUN apk add --no-cache linux-headers curl-dev \
|
||||
&& docker-php-ext-install curl pcntl sockets || \
|
||||
docker-php-ext-install curl pcntl
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
FROM php:8.3-cli-alpine
|
||||
|
||||
RUN apk add --no-cache linux-headers git
|
||||
RUN apk add --no-cache linux-headers curl-dev git
|
||||
|
||||
RUN apk add --no-cache --repository https://dl-cdn.alpinelinux.org/alpine/v3.20/main linux-headers \
|
||||
&& docker-php-ext-install pcntl sockets || \
|
||||
docker-php-ext-install pcntl
|
||||
RUN docker-php-ext-install curl pcntl sockets 2>/dev/null || \
|
||||
docker-php-ext-install curl pcntl
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
|
||||
+151
-19
@@ -30,22 +30,49 @@ body { font-size: .875rem; }
|
||||
.toast-container { z-index: 1060; }
|
||||
#detailModal .modal-body { max-height: 70vh; overflow-y: auto; }
|
||||
pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius: .375rem; font-size: .8rem; white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; }
|
||||
.login-container { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-card { max-width: 420px; width: 100%; }
|
||||
.login-container .navbar-brand { font-size: 1.5rem; }
|
||||
.login-footer { position: fixed; bottom: 1rem; left: 0; right: 0; text-align: center; font-size: .8rem; color: var(--bs-secondary-color); }
|
||||
@media (max-width: 768px) { .sidebar { display: none; } .main { margin-left: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="appLogin" class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="navbar-brand"><i class="bi bi-terminal-plus"></i> Jakach Logging</h3>
|
||||
<p class="text-secondary">Log analysis & alerting system</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-4 text-center">
|
||||
<i class="bi bi-shield-lock" style="font-size:3rem;display:block;margin-bottom:1rem"></i>
|
||||
<h5>Sign in required</h5>
|
||||
<p class="text-secondary mb-4">Authenticate with your Jakach account to access the system.</p>
|
||||
<a id="loginBtn" class="btn btn-primary btn-lg w-100" href="#">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Log in using Jakach Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-footer">Powered by <a href="https://auth.jakach.ch" class="text-secondary">Jakach Auth</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="appMain" style="display:none">
|
||||
<nav class="navbar navbar-expand navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="#"><i class="bi bi-terminal-plus"></i>Jakach Logging</a>
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
<span class="badge bg-danger d-none" id="criticalBadge">0</span>
|
||||
<span class="badge bg-warning text-dark d-none" id="warningBadge">0</span>
|
||||
<span class="text-secondary small me-2 d-none d-md-inline" id="userDisplay"></span>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="refreshBtn" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
<div class="form-check form-switch ms-2">
|
||||
<input class="form-check-input" type="checkbox" id="autoRefresh" checked>
|
||||
<label class="form-check-label" for="autoRefresh" style="font-size:.8rem">Auto</label>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm" id="logoutBtn" title="Logout"><i class="bi bi-box-arrow-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -74,7 +101,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<div class="col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-bell me-1"></i>Recent Alerts</span>
|
||||
<span><i class="bi bi-bell me-1"></i>Recent Critical Alerts</span>
|
||||
<a href="#" data-page="alerts" class="btn btn-outline-secondary btn-sm">View all</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@@ -130,7 +157,6 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center py-2">
|
||||
<small class="text-secondary" id="alertsCount">0 alerts</small>
|
||||
<nav><ul class="pagination pagination-sm mb-0" id="alertsPagination"></ul></nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,24 +208,48 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-1"></i>System Info</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Health</dt><dd class="col-sm-8"><span id="sysHealth" class="badge bg-secondary">checking...</span></dd>
|
||||
<dt class="col-sm-4">Auth Server</dt><dd class="col-sm-8"><a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></dd>
|
||||
<dt class="col-sm-4">Logged in as</dt><dd class="col-sm-8" id="settingsUser">—</dd>
|
||||
<dt class="col-sm-4">DB Path</dt><dd class="col-sm-8"><code>/app/data/logging.db</code></dd>
|
||||
<dt class="col-sm-4">Worker</dt><dd class="col-sm-8"><code>php bin/consume</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-book me-1"></i>Quick Reference</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-1"><strong>File sources</strong> — path to a log file on the worker container</p>
|
||||
<p class="mb-1"><strong>TCP/UDP sources</strong> — <code>tcp://0.0.0.0:9514</code> or <code>udp://0.0.0.0:9514</code></p>
|
||||
<p class="mb-0"><strong>Rules</strong> — use PHP regex patterns, e.g. <code>/error/i</code></p>
|
||||
<p class="mb-1"><strong>TCP/UDP sources</strong> — <code>tcp://0.0.0.0:9514</code></p>
|
||||
<p class="mb-0"><strong>Rules</strong> — PHP regex patterns, e.g. <code>/error/i</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-shield-check me-1"></i>Security</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Allowed User Tokens</label>
|
||||
<p class="small text-secondary">Only these Jakach user tokens can access this system. Leave empty to allow any authenticated Jakach user.</p>
|
||||
<textarea class="form-control font-monospace" id="allowedTokensInput" rows="4" placeholder="One user_token per line"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="saveTokensBtn"><i class="bi bi-floppy"></i> Save</button>
|
||||
<small id="tokenSaveStatus" class="ms-2"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-1"></i>How to get your user token</div>
|
||||
<div class="card-body small">
|
||||
<ol class="mb-0 ps-3">
|
||||
<li>Log in at <a href="https://auth.jakach.ch" target="_blank">auth.jakach.ch</a></li>
|
||||
<li>Your <code>user_token</code> is shown in your profile</li>
|
||||
<li>Paste it above to grant access</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,6 +258,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Detail Modal -->
|
||||
<div class="modal fade" id="detailModal" tabindex="-1">
|
||||
@@ -299,10 +350,45 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
||||
<script>
|
||||
const API = window.location.origin;
|
||||
|
||||
let state = { alerts: [], sources: [], rules: [], counts: [], alertPage: 0, alertPageSize: 50 };
|
||||
let state = { alerts: [], sources: [], rules: [], counts: [], user: null };
|
||||
let autoRefreshInterval = null;
|
||||
let currentAlertId = null;
|
||||
|
||||
// --- Auth ---
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await api('/auth/me');
|
||||
if (res.user) {
|
||||
state.user = res.user;
|
||||
document.getElementById('userDisplay').textContent = res.user.username;
|
||||
document.getElementById('settingsUser').textContent = res.user.username + ' (' + res.user.user_token.substring(0, 12) + '...)';
|
||||
document.getElementById('appLogin').style.display = 'none';
|
||||
document.getElementById('appMain').style.display = '';
|
||||
initApp();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {}
|
||||
showLogin();
|
||||
return false;
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('appLogin').style.display = '';
|
||||
document.getElementById('appMain').style.display = 'none';
|
||||
const loginUrl = window.location.origin + '/oauth.php?redirect=' + encodeURIComponent(window.location.href);
|
||||
document.getElementById('loginBtn').href = 'https://auth.jakach.ch/?send_to=' + encodeURIComponent(loginUrl);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api('/auth/logout', { method: 'POST' });
|
||||
} catch (e) {}
|
||||
state.user = null;
|
||||
showLogin();
|
||||
}
|
||||
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
|
||||
// --- Navigation ---
|
||||
document.querySelectorAll('[data-page]').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
@@ -335,8 +421,15 @@ async function api(path, opts = {}) {
|
||||
headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && data.login_url) {
|
||||
window.location.href = data.login_url;
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function toast(msg, type = 'success') {
|
||||
@@ -405,13 +498,12 @@ async function loadDashboard() {
|
||||
} else {
|
||||
tbody.innerHTML = alerts.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
||||
<td>${severityBadge(a.severity)}</td>
|
||||
<td>${a.rule_name}</td>
|
||||
<td>${esc(a.rule_name)}</td>
|
||||
<td class="log-line">${esc(a.message)}</td>
|
||||
<td class="text-secondary" style="white-space:nowrap">${timeAgo(a.created_at)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// Mini bar chart using CSS
|
||||
const chartEl = document.getElementById('chartContainer');
|
||||
const severityCounts = { critical: 0, warning: 0, info: 0 };
|
||||
counts.forEach(c => { if (severityCounts[c.severity] !== undefined) severityCounts[c.severity] += parseInt(c.count); });
|
||||
@@ -430,7 +522,7 @@ async function loadDashboard() {
|
||||
async function loadAlerts() {
|
||||
const severity = document.getElementById('filterSeverity').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const params = new URLSearchParams({ limit: state.alertPageSize, offset: state.alertPage * state.alertPageSize });
|
||||
const params = new URLSearchParams({ limit: 100, offset: 0 });
|
||||
if (severity) params.set('severity', severity);
|
||||
if (status) params.set('status', status);
|
||||
|
||||
@@ -464,7 +556,7 @@ function showAlert(id) {
|
||||
document.getElementById('detailBody').innerHTML = `
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">ID</dt><dd class="col-sm-9">#${a.id}</dd>
|
||||
<dt class="col-sm-3">Rule</dt><dd class="col-sm-9">${esc(a.rule_name)} (ID ${a.rule_id})</dd>
|
||||
<dt class="col-sm-3">Rule</dt><dd class="col-sm-9">${esc(a.rule_name)}</dd>
|
||||
<dt class="col-sm-3">Severity</dt><dd class="col-sm-9">${severityBadge(a.severity)}</dd>
|
||||
<dt class="col-sm-3">Status</dt><dd class="col-sm-9">${statusBadge(a.status)}</dd>
|
||||
<dt class="col-sm-3">Source</dt><dd class="col-sm-9">${esc(a.source_name || '—')}</dd>
|
||||
@@ -489,8 +581,8 @@ async function ackAlert(id) {
|
||||
} catch (e) { toast('Failed to acknowledge', 'danger'); }
|
||||
}
|
||||
|
||||
document.getElementById('filterSeverity').addEventListener('change', () => { state.alertPage = 0; loadAlerts(); });
|
||||
document.getElementById('filterStatus').addEventListener('change', () => { state.alertPage = 0; loadAlerts(); });
|
||||
document.getElementById('filterSeverity').addEventListener('change', loadAlerts);
|
||||
document.getElementById('filterStatus').addEventListener('change', loadAlerts);
|
||||
document.getElementById('refreshAlertsBtn').addEventListener('click', loadAlerts);
|
||||
|
||||
// --- SOURCES ---
|
||||
@@ -583,6 +675,7 @@ document.getElementById('ruleForm').addEventListener('submit', async e => {
|
||||
} catch (err) { toast('Failed to add rule', 'danger'); }
|
||||
});
|
||||
|
||||
// --- SETTINGS ---
|
||||
async function loadSettings() {
|
||||
try {
|
||||
await api('/health');
|
||||
@@ -592,8 +685,37 @@ async function loadSettings() {
|
||||
document.getElementById('sysHealth').textContent = 'Unreachable';
|
||||
document.getElementById('sysHealth').className = 'badge bg-danger';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api('/config/allowed_tokens');
|
||||
const tokens = res.tokens || [];
|
||||
document.getElementById('allowedTokensInput').value = tokens.join('\n');
|
||||
} catch (e) { console.error('load tokens error', e); }
|
||||
}
|
||||
|
||||
document.getElementById('saveTokensBtn').addEventListener('click', async () => {
|
||||
const raw = document.getElementById('allowedTokensInput').value;
|
||||
const tokens = raw.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
||||
const statusEl = document.getElementById('tokenSaveStatus');
|
||||
statusEl.textContent = 'Saving...';
|
||||
statusEl.className = 'ms-2 text-secondary';
|
||||
try {
|
||||
await api('/config/allowed_tokens', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ tokens }),
|
||||
});
|
||||
statusEl.textContent = 'Saved';
|
||||
statusEl.className = 'ms-2 text-success';
|
||||
toast('Allowed tokens updated');
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Failed';
|
||||
statusEl.className = 'ms-2 text-danger';
|
||||
toast('Failed to save tokens', 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
@@ -624,9 +746,19 @@ document.getElementById('refreshBtn').addEventListener('click', () => {
|
||||
if (active) loadPage(active.id.replace('page-', ''));
|
||||
});
|
||||
|
||||
// --- Bootstrap ---
|
||||
startAutoRefresh();
|
||||
loadDashboard();
|
||||
// --- Init ---
|
||||
function initApp() {
|
||||
startAutoRefresh();
|
||||
loadDashboard();
|
||||
document.querySelectorAll('[data-page]').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
showPage(el.dataset.page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Jakach\Logging\Storage\Database;
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 86400 * 7,
|
||||
'path' => '/',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
|
||||
$authToken = $_GET['auth'] ?? '';
|
||||
|
||||
if (!$authToken) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Missing auth token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$checkUrl = 'https://auth.jakach.ch/api/auth/check_auth_key.php?auth_token=' . urlencode($authToken);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $checkUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || !$response) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(502);
|
||||
echo json_encode(['error' => 'Auth server unreachable']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
if (!isset($data['status']) || $data['status'] !== 'success') {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authentication failed', 'msg' => $data['msg'] ?? 'unknown']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$_SESSION['loggedin'] = true;
|
||||
$_SESSION['username'] = $data['username'] ?? 'unknown';
|
||||
$_SESSION['id'] = $data['id'] ?? '';
|
||||
$_SESSION['email'] = $data['email'] ?? '';
|
||||
$_SESSION['telegram_id'] = $data['telegram_id'] ?? '';
|
||||
$_SESSION['user_token'] = $data['user_token'] ?? '';
|
||||
|
||||
if (!headers_sent()) {
|
||||
$db = new Database();
|
||||
$repo = new \Jakach\Logging\Storage\Repository($db);
|
||||
$allowedTokens = $repo->getAllowedUserTokens();
|
||||
|
||||
if (!empty($allowedTokens) && !in_array($_SESSION['user_token'], $allowedTokens, true)) {
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Your account is not authorized to access this system']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$redirect = $_GET['redirect'] ?? '/';
|
||||
header('Location: ' . $redirect);
|
||||
exit;
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Jakach\Logging\Api;
|
||||
|
||||
use Jakach\Logging\Storage\Repository;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
private Repository $repo;
|
||||
|
||||
public function __construct(Repository $repo)
|
||||
{
|
||||
$this->repo = $repo;
|
||||
}
|
||||
|
||||
public function requireAuth(): ?array
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 86400 * 7,
|
||||
'path' => '/',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (empty($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$allowedTokens = $this->repo->getAllowedUserTokens();
|
||||
if (!empty($allowedTokens)) {
|
||||
$userToken = $_SESSION['user_token'] ?? '';
|
||||
if (!in_array($userToken, $allowedTokens, true)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'username' => $_SESSION['username'] ?? 'unknown',
|
||||
'user_token' => $_SESSION['user_token'] ?? '',
|
||||
'email' => $_SESSION['email'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
+125
-33
@@ -8,11 +8,13 @@ use Jakach\Logging\Storage\{Database, Repository};
|
||||
class Router
|
||||
{
|
||||
private Repository $repo;
|
||||
private AuthMiddleware $auth;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$db = new Database();
|
||||
$this->repo = new Repository($db);
|
||||
$this->auth = new AuthMiddleware($this->repo);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
@@ -20,51 +22,123 @@ class Router
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$path = rtrim($path, '/');
|
||||
$path = $path ?: '/';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
$result = match (true) {
|
||||
$path === '/sources' && $method === 'GET' => $this->repo->getSources(),
|
||||
$path === '/sources' && $method === 'POST' => $this->createSource(),
|
||||
$path === '/rules' && $method === 'GET' => $this->repo->getRules(),
|
||||
$path === '/rules' && $method === 'POST' => $this->createRule(),
|
||||
$path === '/alerts' && $method === 'GET' => $this->getAlerts(),
|
||||
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST' => $this->ackAlert((int) $m[1]),
|
||||
preg_match('#^/alerts/counts$#', $path) && $method === 'GET' => $this->repo->getAlertCounts(),
|
||||
$path === '/health' && $method === 'GET' => ['status' => 'ok'],
|
||||
default => throw new \RuntimeException('Not found', 404),
|
||||
};
|
||||
|
||||
if ($result instanceof \UnitEnum || is_scalar($result)) {
|
||||
$result = ['data' => $result];
|
||||
} elseif (is_array($result) && !empty($result) && $result[array_key_first($result)] instanceof \UnitEnum) {
|
||||
$result = ['data' => $result];
|
||||
} elseif (is_array($result)) {
|
||||
$needsWrap = false;
|
||||
foreach ($result as $key => $val) {
|
||||
if (is_object($val) && method_exists($val, 'toArray')) {
|
||||
$result[$key] = $val->toArray();
|
||||
} else {
|
||||
$needsWrap = true;
|
||||
}
|
||||
$publicPaths = ['/health', '/oauth', '/auth/me', '/auth/logout'];
|
||||
$isPublic = false;
|
||||
foreach ($publicPaths as $pp) {
|
||||
if ($path === $pp || str_starts_with($path, $pp . '/')) {
|
||||
$isPublic = true;
|
||||
break;
|
||||
}
|
||||
if ($needsWrap || empty($result)) {
|
||||
$result = ['data' => $result];
|
||||
}
|
||||
} elseif (is_object($result) && method_exists($result, 'toArray')) {
|
||||
$result = ['data' => $result->toArray()];
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (!$isPublic) {
|
||||
$user = $this->auth->requireAuth();
|
||||
if (!$user) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized', 'login_url' => $this->loginUrl()]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$result = match (true) {
|
||||
$path === '/health' && $method === 'GET' => ['status' => 'ok'],
|
||||
|
||||
$path === '/auth/me' && $method === 'GET' => $this->getMe(),
|
||||
$path === '/auth/logout' && $method === 'POST' => $this->logout(),
|
||||
|
||||
$path === '/sources' && $method === 'GET' => $this->repo->getSources(),
|
||||
$path === '/sources' && $method === 'POST' => $this->createSource(),
|
||||
preg_match('#^/sources/(\d+)$#', $path, $m) && $method === 'DELETE'
|
||||
=> $this->deleteEntity('source', (int) $m[1]),
|
||||
|
||||
$path === '/rules' && $method === 'GET' => $this->repo->getRules(),
|
||||
$path === '/rules' && $method === 'POST' => $this->createRule(),
|
||||
preg_match('#^/rules/(\d+)$#', $path, $m) && $method === 'DELETE'
|
||||
=> $this->deleteEntity('rule', (int) $m[1]),
|
||||
|
||||
$path === '/alerts' && $method === 'GET' => $this->getAlerts(),
|
||||
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST'
|
||||
=> $this->ackAlert((int) $m[1]),
|
||||
preg_match('#^/alerts/counts$#', $path) && $method === 'GET'
|
||||
=> $this->repo->getAlertCounts(),
|
||||
|
||||
$path === '/config/allowed_tokens' && $method === 'GET'
|
||||
=> ['tokens' => $this->repo->getAllowedUserTokens()],
|
||||
$path === '/config/allowed_tokens' && $method === 'PUT'
|
||||
=> $this->updateAllowedTokens(),
|
||||
|
||||
default => throw new \RuntimeException('Not found', 404),
|
||||
};
|
||||
|
||||
$this->respond(200, $result);
|
||||
|
||||
} catch (\RuntimeException $e) {
|
||||
http_response_code($e->getCode() ?: 500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
$this->respond($e->getCode() ?: 500, ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function respond(int $code, mixed $result): void
|
||||
{
|
||||
http_response_code($code);
|
||||
if (is_array($result)) {
|
||||
$hasObjects = false;
|
||||
foreach ($result as $key => $val) {
|
||||
if (is_object($val) && method_exists($val, 'toArray')) {
|
||||
$result[$key] = $val->toArray();
|
||||
$hasObjects = true;
|
||||
}
|
||||
}
|
||||
if ($hasObjects) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
if (array_is_list($result) && (empty($result) || !isset($result['data']))) {
|
||||
$result = ['data' => $result];
|
||||
}
|
||||
} elseif (is_object($result) && method_exists($result, 'toArray')) {
|
||||
$result = ['data' => $result->toArray()];
|
||||
}
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
private function loginUrl(): string
|
||||
{
|
||||
$redirect = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/oauth.php';
|
||||
return 'https://auth.jakach.ch/?send_to=' . urlencode($redirect);
|
||||
}
|
||||
|
||||
private function getMe(): array
|
||||
{
|
||||
$user = $this->auth->requireAuth();
|
||||
if (!$user) {
|
||||
http_response_code(401);
|
||||
return ['error' => 'Not logged in', 'login_url' => $this->loginUrl()];
|
||||
}
|
||||
return ['user' => $user];
|
||||
}
|
||||
|
||||
private function logout(): array
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$_SESSION = [];
|
||||
if (ini_get("session.use_cookies")) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params["path"], $params["domain"],
|
||||
$params["secure"], $params["httponly"]
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
return ['status' => 'logged_out'];
|
||||
}
|
||||
|
||||
private function createSource(): array
|
||||
{
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -88,6 +162,16 @@ class Router
|
||||
);
|
||||
}
|
||||
|
||||
private function deleteEntity(string $type, int $id): array
|
||||
{
|
||||
if ($type === 'source') {
|
||||
$this->repo->deleteSource($id);
|
||||
} else {
|
||||
$this->repo->deleteRule($id);
|
||||
}
|
||||
return ['status' => 'deleted', 'id' => $id];
|
||||
}
|
||||
|
||||
private function getAlerts(): array
|
||||
{
|
||||
$limit = (int) ($_GET['limit'] ?? 100);
|
||||
@@ -102,4 +186,12 @@ class Router
|
||||
$this->repo->updateAlertStatus($id, AlertStatus::Acknowledged);
|
||||
return ['status' => 'acknowledged', 'id' => $id];
|
||||
}
|
||||
|
||||
private function updateAllowedTokens(): array
|
||||
{
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$tokens = $body['tokens'] ?? [];
|
||||
$this->repo->setAllowedUserTokens($tokens);
|
||||
return ['status' => 'saved', 'tokens' => $this->repo->getAllowedUserTokens()];
|
||||
}
|
||||
}
|
||||
@@ -83,5 +83,12 @@ class Database
|
||||
PRIMARY KEY (rule_id, window_start)
|
||||
)
|
||||
");
|
||||
|
||||
$this->pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,28 @@ class Repository
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
|
||||
public function getAllowedUserTokens(): array
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare("SELECT value FROM config WHERE key = ?");
|
||||
$stmt->execute(['allowed_user_tokens']);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row || empty($row['value'])) {
|
||||
return [];
|
||||
}
|
||||
return json_decode($row['value'], true) ?? [];
|
||||
}
|
||||
|
||||
public function setAllowedUserTokens(array $tokens): void
|
||||
{
|
||||
$stmt = $this->db->pdo()->prepare(
|
||||
"INSERT INTO config (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
);
|
||||
$stmt->execute(['allowed_user_tokens', json_encode(array_values($tokens))]);
|
||||
}
|
||||
|
||||
// --- Rate Limiting ---
|
||||
|
||||
public function checkRateLimit(int $ruleId, int $windowSeconds): bool
|
||||
|
||||
Reference in New Issue
Block a user