adding auth
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
FROM php:8.3-fpm-alpine
|
FROM php:8.3-fpm-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache linux-headers \
|
RUN apk add --no-cache linux-headers curl-dev \
|
||||||
&& docker-php-ext-install pcntl sockets || \
|
&& docker-php-ext-install curl pcntl sockets || \
|
||||||
docker-php-ext-install pcntl
|
docker-php-ext-install curl pcntl
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
FROM php:8.3-cli-alpine
|
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 \
|
RUN docker-php-ext-install curl pcntl sockets 2>/dev/null || \
|
||||||
&& docker-php-ext-install pcntl sockets || \
|
docker-php-ext-install curl pcntl
|
||||||
docker-php-ext-install pcntl
|
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
|||||||
+149
-17
@@ -30,22 +30,49 @@ body { font-size: .875rem; }
|
|||||||
.toast-container { z-index: 1060; }
|
.toast-container { z-index: 1060; }
|
||||||
#detailModal .modal-body { max-height: 70vh; overflow-y: auto; }
|
#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; }
|
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; } }
|
@media (max-width: 768px) { .sidebar { display: none; } .main { margin-left: 0; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<nav class="navbar navbar-expand navbar-dark bg-dark fixed-top">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="#"><i class="bi bi-terminal-plus"></i>Jakach Logging</a>
|
<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">
|
<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-danger d-none" id="criticalBadge">0</span>
|
||||||
<span class="badge bg-warning text-dark d-none" id="warningBadge">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>
|
<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">
|
<div class="form-check form-switch ms-2">
|
||||||
<input class="form-check-input" type="checkbox" id="autoRefresh" checked>
|
<input class="form-check-input" type="checkbox" id="autoRefresh" checked>
|
||||||
<label class="form-check-label" for="autoRefresh" style="font-size:.8rem">Auto</label>
|
<label class="form-check-label" for="autoRefresh" style="font-size:.8rem">Auto</label>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" id="logoutBtn" title="Logout"><i class="bi bi-box-arrow-right"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -74,7 +101,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<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>
|
<a href="#" data-page="alerts" class="btn btn-outline-secondary btn-sm">View all</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@@ -130,7 +157,6 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center py-2">
|
<div class="card-footer d-flex justify-content-between align-items-center py-2">
|
||||||
<small class="text-secondary" id="alertsCount">0 alerts</small>
|
<small class="text-secondary" id="alertsCount">0 alerts</small>
|
||||||
<nav><ul class="pagination pagination-sm mb-0" id="alertsPagination"></ul></nav>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,24 +208,48 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
</div>
|
</div>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<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-header"><i class="bi bi-info-circle me-1"></i>System Info</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row mb-0">
|
<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">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">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>
|
<dt class="col-sm-4">Worker</dt><dd class="col-sm-8"><code>php bin/consume</code></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><i class="bi bi-book me-1"></i>Quick Reference</div>
|
<div class="card-header"><i class="bi bi-book me-1"></i>Quick Reference</div>
|
||||||
<div class="card-body">
|
<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>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-1"><strong>TCP/UDP sources</strong> — <code>tcp://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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,6 +258,7 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Alert Detail Modal -->
|
<!-- Alert Detail Modal -->
|
||||||
<div class="modal fade" id="detailModal" tabindex="-1">
|
<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>
|
<script>
|
||||||
const API = window.location.origin;
|
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 autoRefreshInterval = null;
|
||||||
let currentAlertId = 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 ---
|
// --- Navigation ---
|
||||||
document.querySelectorAll('[data-page]').forEach(el => {
|
document.querySelectorAll('[data-page]').forEach(el => {
|
||||||
el.addEventListener('click', e => {
|
el.addEventListener('click', e => {
|
||||||
@@ -335,8 +421,15 @@ async function api(path, opts = {}) {
|
|||||||
headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
|
headers: { 'Accept': 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}) },
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
const data = await res.json();
|
||||||
return 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') {
|
function toast(msg, type = 'success') {
|
||||||
@@ -405,13 +498,12 @@ async function loadDashboard() {
|
|||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = alerts.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
tbody.innerHTML = alerts.map(a => `<tr class="alert-row" onclick="showAlert(${a.id})">
|
||||||
<td>${severityBadge(a.severity)}</td>
|
<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="log-line">${esc(a.message)}</td>
|
||||||
<td class="text-secondary" style="white-space:nowrap">${timeAgo(a.created_at)}</td>
|
<td class="text-secondary" style="white-space:nowrap">${timeAgo(a.created_at)}</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini bar chart using CSS
|
|
||||||
const chartEl = document.getElementById('chartContainer');
|
const chartEl = document.getElementById('chartContainer');
|
||||||
const severityCounts = { critical: 0, warning: 0, info: 0 };
|
const severityCounts = { critical: 0, warning: 0, info: 0 };
|
||||||
counts.forEach(c => { if (severityCounts[c.severity] !== undefined) severityCounts[c.severity] += parseInt(c.count); });
|
counts.forEach(c => { if (severityCounts[c.severity] !== undefined) severityCounts[c.severity] += parseInt(c.count); });
|
||||||
@@ -430,7 +522,7 @@ async function loadDashboard() {
|
|||||||
async function loadAlerts() {
|
async function loadAlerts() {
|
||||||
const severity = document.getElementById('filterSeverity').value;
|
const severity = document.getElementById('filterSeverity').value;
|
||||||
const status = document.getElementById('filterStatus').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 (severity) params.set('severity', severity);
|
||||||
if (status) params.set('status', status);
|
if (status) params.set('status', status);
|
||||||
|
|
||||||
@@ -464,7 +556,7 @@ function showAlert(id) {
|
|||||||
document.getElementById('detailBody').innerHTML = `
|
document.getElementById('detailBody').innerHTML = `
|
||||||
<dl class="row mb-0">
|
<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">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">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">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>
|
<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'); }
|
} catch (e) { toast('Failed to acknowledge', 'danger'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('filterSeverity').addEventListener('change', () => { state.alertPage = 0; loadAlerts(); });
|
document.getElementById('filterSeverity').addEventListener('change', loadAlerts);
|
||||||
document.getElementById('filterStatus').addEventListener('change', () => { state.alertPage = 0; loadAlerts(); });
|
document.getElementById('filterStatus').addEventListener('change', loadAlerts);
|
||||||
document.getElementById('refreshAlertsBtn').addEventListener('click', loadAlerts);
|
document.getElementById('refreshAlertsBtn').addEventListener('click', loadAlerts);
|
||||||
|
|
||||||
// --- SOURCES ---
|
// --- SOURCES ---
|
||||||
@@ -583,6 +675,7 @@ document.getElementById('ruleForm').addEventListener('submit', async e => {
|
|||||||
} catch (err) { toast('Failed to add rule', 'danger'); }
|
} catch (err) { toast('Failed to add rule', 'danger'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- SETTINGS ---
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
await api('/health');
|
await api('/health');
|
||||||
@@ -592,8 +685,37 @@ async function loadSettings() {
|
|||||||
document.getElementById('sysHealth').textContent = 'Unreachable';
|
document.getElementById('sysHealth').textContent = 'Unreachable';
|
||||||
document.getElementById('sysHealth').className = 'badge bg-danger';
|
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) {
|
function esc(s) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.textContent = s || '';
|
d.textContent = s || '';
|
||||||
@@ -624,9 +746,19 @@ document.getElementById('refreshBtn').addEventListener('click', () => {
|
|||||||
if (active) loadPage(active.id.replace('page-', ''));
|
if (active) loadPage(active.id.replace('page-', ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Bootstrap ---
|
// --- Init ---
|
||||||
|
function initApp() {
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
|
document.querySelectorAll('[data-page]').forEach(el => {
|
||||||
|
el.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
showPage(el.dataset.page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
-18
@@ -8,11 +8,13 @@ use Jakach\Logging\Storage\{Database, Repository};
|
|||||||
class Router
|
class Router
|
||||||
{
|
{
|
||||||
private Repository $repo;
|
private Repository $repo;
|
||||||
|
private AuthMiddleware $auth;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
$this->repo = new Repository($db);
|
$this->repo = new Repository($db);
|
||||||
|
$this->auth = new AuthMiddleware($this->repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
@@ -20,49 +22,121 @@ class Router
|
|||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
$path = rtrim($path, '/');
|
$path = rtrim($path, '/');
|
||||||
|
$path = $path ?: '/';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$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 (!$isPublic) {
|
||||||
|
$user = $this->auth->requireAuth();
|
||||||
|
if (!$user) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized', 'login_url' => $this->loginUrl()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = match (true) {
|
$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 === 'GET' => $this->repo->getSources(),
|
||||||
$path === '/sources' && $method === 'POST' => $this->createSource(),
|
$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 === 'GET' => $this->repo->getRules(),
|
||||||
$path === '/rules' && $method === 'POST' => $this->createRule(),
|
$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(),
|
$path === '/alerts' && $method === 'GET' => $this->getAlerts(),
|
||||||
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST' => $this->ackAlert((int) $m[1]),
|
preg_match('#^/alerts/(\d+)/ack$#', $path, $m) && $method === 'POST'
|
||||||
preg_match('#^/alerts/counts$#', $path) && $method === 'GET' => $this->repo->getAlertCounts(),
|
=> $this->ackAlert((int) $m[1]),
|
||||||
$path === '/health' && $method === 'GET' => ['status' => 'ok'],
|
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),
|
default => throw new \RuntimeException('Not found', 404),
|
||||||
};
|
};
|
||||||
|
|
||||||
if ($result instanceof \UnitEnum || is_scalar($result)) {
|
$this->respond(200, $result);
|
||||||
$result = ['data' => $result];
|
|
||||||
} elseif (is_array($result) && !empty($result) && $result[array_key_first($result)] instanceof \UnitEnum) {
|
} catch (\RuntimeException $e) {
|
||||||
$result = ['data' => $result];
|
$this->respond($e->getCode() ?: 500, ['error' => $e->getMessage()]);
|
||||||
} elseif (is_array($result)) {
|
}
|
||||||
$needsWrap = false;
|
}
|
||||||
|
|
||||||
|
private function respond(int $code, mixed $result): void
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
if (is_array($result)) {
|
||||||
|
$hasObjects = false;
|
||||||
foreach ($result as $key => $val) {
|
foreach ($result as $key => $val) {
|
||||||
if (is_object($val) && method_exists($val, 'toArray')) {
|
if (is_object($val) && method_exists($val, 'toArray')) {
|
||||||
$result[$key] = $val->toArray();
|
$result[$key] = $val->toArray();
|
||||||
} else {
|
$hasObjects = true;
|
||||||
$needsWrap = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($needsWrap || empty($result)) {
|
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];
|
$result = ['data' => $result];
|
||||||
}
|
}
|
||||||
} elseif (is_object($result) && method_exists($result, 'toArray')) {
|
} elseif (is_object($result) && method_exists($result, 'toArray')) {
|
||||||
$result = ['data' => $result->toArray()];
|
$result = ['data' => $result->toArray()];
|
||||||
}
|
}
|
||||||
|
|
||||||
http_response_code(200);
|
|
||||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
} catch (\RuntimeException $e) {
|
|
||||||
http_response_code($e->getCode() ?: 500);
|
|
||||||
echo json_encode(['error' => $e->getMessage()]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
private function createSource(): array
|
||||||
@@ -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
|
private function getAlerts(): array
|
||||||
{
|
{
|
||||||
$limit = (int) ($_GET['limit'] ?? 100);
|
$limit = (int) ($_GET['limit'] ?? 100);
|
||||||
@@ -102,4 +186,12 @@ class Router
|
|||||||
$this->repo->updateAlertStatus($id, AlertStatus::Acknowledged);
|
$this->repo->updateAlertStatus($id, AlertStatus::Acknowledged);
|
||||||
return ['status' => 'acknowledged', 'id' => $id];
|
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)
|
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();
|
)->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 ---
|
// --- Rate Limiting ---
|
||||||
|
|
||||||
public function checkRateLimit(int $ruleId, int $windowSeconds): bool
|
public function checkRateLimit(int $ruleId, int $windowSeconds): bool
|
||||||
|
|||||||
Reference in New Issue
Block a user