Add self-hosted RCS backend, extension, and ops tooling

Ship the Go/SQLite API and Web UI, Chrome/Brave capture addon,
Docker Compose, Pangolin reverse-proxy support, and a user-crontab
watchdog so the binary stays running without systemd.
This commit is contained in:
2026-08-06 22:02:45 +02:00
parent 3f05c97e1f
commit 5a0e2e630b
30 changed files with 4334 additions and 2 deletions
+134
View File
@@ -0,0 +1,134 @@
body {
width: 320px;
margin: 0;
padding: 14px;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: #121820;
color: #e8eef4;
}
h1 {
margin: 0;
font-size: 1.25rem;
letter-spacing: 0.04em;
}
.sub {
margin: 0.2rem 0 1rem;
color: #8b98a5;
font-size: 0.85rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.7rem;
font-size: 0.8rem;
color: #8b98a5;
}
input {
padding: 0.45rem 0.55rem;
border-radius: 6px;
border: 1px solid #2c3640;
background: #0f1419;
color: #e8eef4;
}
button {
width: 100%;
border: 0;
border-radius: 6px;
padding: 0.55rem 0.7rem;
background: #ff4500;
color: #fff;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: #2c3640;
margin-top: 0.6rem;
}
.error {
color: #ff8f8f;
font-size: 0.8rem;
}
a {
color: #4a9eff;
}
.muted {
color: #8b98a5;
font-size: 0.8rem;
}
.blocklist {
margin: 1rem 0 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid #2c3640;
}
.blocklist h2 {
margin: 0 0 0.25rem;
font-size: 0.95rem;
}
.blocklist .hint {
margin: 0 0 0.6rem;
color: #8b98a5;
font-size: 0.75rem;
}
.blocklist-add {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.blocklist-add input {
flex: 1;
min-width: 0;
}
.blocklist-add button {
width: auto;
padding: 0.45rem 0.7rem;
}
.blocklist-items {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
max-height: 160px;
overflow: auto;
}
.blocklist-items li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid #1c2430;
font-size: 0.85rem;
}
.blocklist-items button.remove {
width: auto;
padding: 0.25rem 0.5rem;
background: #2c3640;
font-size: 0.75rem;
}
#blocklistStatus {
margin: 0.25rem 0;
}
#blocklistStatus.error {
color: #ff8f8f;
}
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>RCS</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<h1>RCS</h1>
<p class="sub">Reddit Comment Saver</p>
<div id="setup" hidden>
<p>Enter your backend URL to start saving comments.</p>
<label>
Backend URL
<input id="backendUrl" type="url" placeholder="http://127.0.0.1:8080" required>
</label>
<label>
API key (optional)
<input id="apiKey" type="password" placeholder="leave blank if open">
</label>
<button id="save" type="button">Save &amp; connect</button>
<p id="setupError" class="error" hidden></p>
</div>
<div id="status" hidden>
<p id="statusLine"></p>
<p><a id="openBackend" href="#" target="_blank" rel="noopener">Open Web UI</a></p>
<section class="blocklist" id="blocklistSection">
<h2>Blocked authors</h2>
<p class="hint">Comments from these users are not saved. Synced via backend.</p>
<div class="blocklist-add">
<input id="blockUserInput" type="text" placeholder="AutoModerator" autocomplete="off">
<button id="blockAdd" type="button">Add</button>
</div>
<p id="blocklistStatus" class="muted" hidden></p>
<ul id="blocklist" class="blocklist-items"></ul>
</section>
<button id="edit" type="button" class="secondary">Edit connection</button>
</div>
<script src="../lib/api.js"></script>
<script src="popup.js"></script>
</body>
</html>
+198
View File
@@ -0,0 +1,198 @@
const setupEl = document.getElementById("setup");
const statusEl = document.getElementById("status");
const backendUrlInput = document.getElementById("backendUrl");
const apiKeyInput = document.getElementById("apiKey");
const setupError = document.getElementById("setupError");
const statusLine = document.getElementById("statusLine");
const openBackend = document.getElementById("openBackend");
const blocklistEl = document.getElementById("blocklist");
const blockUserInput = document.getElementById("blockUserInput");
const blocklistStatus = document.getElementById("blocklistStatus");
function showSetup(settings) {
setupEl.hidden = false;
statusEl.hidden = true;
backendUrlInput.value = settings.backendUrl || "http://127.0.0.1:8080";
apiKeyInput.value = settings.apiKey || "";
}
function setBlocklistStatus(text, isError) {
if (!text) {
blocklistStatus.hidden = true;
blocklistStatus.textContent = "";
return;
}
blocklistStatus.hidden = false;
blocklistStatus.textContent = text;
blocklistStatus.classList.toggle("error", Boolean(isError));
}
function renderBlocklist(usernames) {
blocklistEl.innerHTML = "";
if (!usernames.length) {
const empty = document.createElement("li");
empty.className = "muted";
empty.textContent = "No blocked authors.";
blocklistEl.appendChild(empty);
return;
}
usernames.forEach((name) => {
const li = document.createElement("li");
const label = document.createElement("span");
label.textContent = name;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "remove";
remove.textContent = "Remove";
remove.addEventListener("click", async () => {
setBlocklistStatus("Syncing…");
try {
const list = await rcsRemoveBlocked(name);
renderBlocklist(list);
setBlocklistStatus("Synced");
} catch (err) {
setBlocklistStatus(String(err && err.message ? err.message : err), true);
}
});
li.appendChild(label);
li.appendChild(remove);
blocklistEl.appendChild(li);
});
}
async function syncBlocklistFromBackend() {
setBlocklistStatus("Syncing…");
try {
const list = await rcsPullBlocklist();
renderBlocklist(list);
setBlocklistStatus(`Synced (${list.length})`);
} catch (err) {
const cached = await rcsGetCachedBlocklist();
renderBlocklist(cached);
setBlocklistStatus("Sync failed — showing cache. " + (err && err.message ? err.message : err), true);
}
}
function showStatus(settings, healthOk, healthHint) {
setupEl.hidden = true;
statusEl.hidden = false;
if (healthOk) {
statusLine.textContent = `Connected to ${settings.backendUrl}`;
} else if (healthHint) {
statusLine.textContent = healthHint;
} else {
statusLine.textContent = `Saved ${settings.backendUrl} (health check failed — is the backend running?)`;
}
openBackend.href = settings.backendUrl;
if (healthOk) {
syncBlocklistFromBackend();
} else {
rcsGetCachedBlocklist().then(renderBlocklist);
setBlocklistStatus("Connect to sync blocklist", true);
}
}
async function load() {
const settings = await chrome.storage.sync.get({ backendUrl: "", apiKey: "" });
if (!settings.backendUrl) {
showSetup(settings);
return;
}
try {
const headers = { Accept: "application/json" };
if (settings.apiKey) headers["X-API-Key"] = settings.apiKey;
const response = await fetch(`${settings.backendUrl.replace(/\/+$/, "")}/api/health`, {
headers,
credentials: "include",
});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
const looksHtml = typeof text === "string" && /^\s*</.test(text);
if (response.ok && data && data.ok === true) {
showStatus(settings, true);
return;
}
if (response.status === 401 || response.status === 403 || looksHtml) {
showStatus(
settings,
false,
`Saved ${settings.backendUrl} — open the Web UI, log in through Pangolin, then retry.`
);
return;
}
showStatus(settings, false);
} catch {
showStatus(settings, false);
}
}
async function ensureHostPermission(backendUrl) {
try {
const origin = new URL(backendUrl).origin + "/*";
const already = await chrome.permissions.contains({ origins: [origin] });
if (already) return true;
return chrome.permissions.request({ origins: [origin] });
} catch {
return false;
}
}
document.getElementById("save").addEventListener("click", async () => {
setupError.hidden = true;
const backendUrl = backendUrlInput.value.trim().replace(/\/+$/, "");
const apiKey = apiKeyInput.value.trim();
if (!backendUrl) {
setupError.textContent = "Backend URL is required.";
setupError.hidden = false;
return;
}
try {
new URL(backendUrl);
} catch {
setupError.textContent = "Invalid URL.";
setupError.hidden = false;
return;
}
const granted = await ensureHostPermission(backendUrl);
if (!granted) {
setupError.textContent = "Permission to reach the backend was denied.";
setupError.hidden = false;
return;
}
await chrome.storage.sync.set({ backendUrl, apiKey });
await load();
});
document.getElementById("edit").addEventListener("click", async () => {
const settings = await chrome.storage.sync.get({ backendUrl: "", apiKey: "" });
showSetup(settings);
});
document.getElementById("blockAdd").addEventListener("click", async () => {
const username = blockUserInput.value.trim();
if (!username) return;
setBlocklistStatus("Syncing…");
try {
const list = await rcsAddBlocked(username);
blockUserInput.value = "";
renderBlocklist(list);
setBlocklistStatus("Synced");
} catch (err) {
setBlocklistStatus(String(err && err.message ? err.message : err), true);
}
});
blockUserInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
document.getElementById("blockAdd").click();
}
});
load();