Add audit log

This commit is contained in:
2026-01-15 16:19:09 +01:00
parent 10507c070e
commit 18dff66373
16 changed files with 818 additions and 111 deletions

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS channel_audit_log (
id TEXT PRIMARY KEY,
broadcaster TEXT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
details TEXT,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (broadcaster) REFERENCES channels(broadcaster) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS channel_audit_log_broadcaster_idx ON channel_audit_log (broadcaster);
CREATE INDEX IF NOT EXISTS channel_audit_log_created_at_idx ON channel_audit_log (created_at);

View File

@@ -2386,3 +2386,98 @@ button:disabled:hover {
justify-content: flex-start;
}
}
.audit-body {
background:
radial-gradient(circle at 0% 30%, rgba(14, 116, 144, 0.12), transparent 32%),
radial-gradient(circle at 85% 0%, rgba(59, 130, 246, 0.16), transparent 30%), #0f172a;
}
.audit-frame {
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px clamp(20px, 5vw, 48px) 48px;
}
.audit-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
background: rgba(15, 23, 42, 0.9);
border: 1px solid #1f2937;
border-radius: 16px;
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.35);
}
.audit-title {
display: flex;
flex-direction: column;
gap: 6px;
}
.audit-title h1 {
margin: 0;
}
.audit-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.audit-content {
display: flex;
}
.audit-panel {
width: 100%;
background: rgba(11, 18, 32, 0.92);
border: 1px solid #1f2937;
border-radius: 18px;
padding: 24px;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.4);
}
.audit-panel-header h2 {
margin: 0 0 6px;
}
.audit-table-wrapper {
margin-top: 16px;
overflow-x: auto;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.audit-table thead {
text-align: left;
background: rgba(15, 23, 42, 0.9);
}
.audit-table th,
.audit-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
vertical-align: top;
}
.audit-table tbody tr:hover {
background: rgba(30, 41, 59, 0.45);
}
.audit-empty {
margin-top: 16px;
padding: 16px;
border-radius: 12px;
background: rgba(30, 41, 59, 0.4);
color: #cbd5e1;
}

View File

@@ -0,0 +1,62 @@
const auditBody = document.getElementById("audit-log-body");
const auditEmpty = document.getElementById("audit-empty");
const formatTimestamp = (value) => {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
};
const renderEntries = (entries) => {
auditBody.innerHTML = "";
if (!entries || entries.length === 0) {
auditEmpty.classList.remove("hidden");
return;
}
auditEmpty.classList.add("hidden");
entries.forEach((entry) => {
const row = document.createElement("tr");
const timeCell = document.createElement("td");
timeCell.textContent = formatTimestamp(entry.createdAt);
row.appendChild(timeCell);
const actorCell = document.createElement("td");
actorCell.textContent = entry.actor || "system";
row.appendChild(actorCell);
const actionCell = document.createElement("td");
actionCell.textContent = entry.action;
row.appendChild(actionCell);
const detailCell = document.createElement("td");
detailCell.textContent = entry.details || "";
row.appendChild(detailCell);
auditBody.appendChild(row);
});
};
const loadAuditLog = () =>
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/audit`)
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to load audit log (${response.status})`);
}
return response.json();
})
.then((entries) => {
renderEntries(entries);
})
.catch((error) => {
console.error(error);
auditEmpty.textContent = "Unable to load audit entries.";
auditEmpty.classList.remove("hidden");
});
loadAuditLog();

View File

@@ -44,6 +44,12 @@
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span class="sr-only">Back to dashboard</span>
</a>
<a
class="button ghost"
th:if="${#strings.equalsIgnoreCase(username, broadcaster)}"
th:href="${'/view/' + broadcaster + '/audit'}"
>Audit log</a
>
<a
class="button ghost"
th:href="${'/view/' + broadcaster + '/broadcast'}"

View File

@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>Imgfloat Audit Log</title>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body class="audit-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="audit-frame">
<header class="audit-topbar">
<div class="audit-title">
<p class="eyebrow subtle">CHANNEL AUDIT LOG</p>
<h1 th:text="${broadcaster}"></h1>
</div>
<div class="audit-actions">
<a class="button ghost" th:href="${'/view/' + broadcaster + '/admin'}">Back to admin</a>
<a class="button" th:href="@{/}">Dashboard</a>
</div>
</header>
<main class="audit-content">
<section class="audit-panel">
<div class="audit-panel-header">
<div>
<h2>Recent activity</h2>
<p class="subtle">Latest 200 entries for your channel.</p>
</div>
</div>
<div class="audit-table-wrapper">
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Details</th>
</tr>
</thead>
<tbody id="audit-log-body"></tbody>
</table>
<div id="audit-empty" class="audit-empty hidden">No audit entries yet.</div>
</div>
</section>
</main>
</div>
<script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ "";
</script>
<script type="module" src="/js/audit-log.js"></script>
</body>
</html>