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
+176
View File
@@ -0,0 +1,176 @@
// Shared API helpers.
// Content scripts must NEVER fetch the backend directly (Chrome Private Network
// Access would prompt "allow Reddit to access local network"). All page-context
// calls go through the background service worker instead.
async function rcsGetSettings() {
const stored = await chrome.storage.sync.get({
backendUrl: "",
apiKey: "",
});
return {
backendUrl: (stored.backendUrl || "").replace(/\/+$/, ""),
apiKey: stored.apiKey || "",
};
}
function rcsMustProxyViaBackground() {
// Popup/options are chrome-extension:// and may fetch directly.
// Content scripts run on https://reddit.com and must proxy.
try {
return typeof location === "undefined" || location.protocol !== "chrome-extension:";
} catch {
return true;
}
}
async function rcsFetchViaBackground(path, options = {}) {
const response = await chrome.runtime.sendMessage({
type: "rcs.api",
path,
method: options.method || "GET",
headers: options.headers || {},
body: options.body || null,
});
if (!response || !response.ok) {
throw new Error((response && response.error) || "backend request failed");
}
return response.data;
}
async function rcsFetchDirect(path, options = {}) {
const settings = await rcsGetSettings();
if (!settings.backendUrl) {
throw new Error("Backend URL not configured");
}
const headers = Object.assign(
{ Accept: "application/json" },
options.headers || {}
);
if (settings.apiKey) {
headers["X-API-Key"] = settings.apiKey;
}
const response = await fetch(`${settings.backendUrl}${path}`, {
...options,
headers,
credentials: "include",
});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!response.ok) {
throw new Error(rcsProxyAuthErrorMessage(response.status, text, data));
}
if (data && data.raw != null && rcsLooksLikeHtml(data.raw)) {
throw new Error(rcsProxyAuthErrorMessage(response.status, text, data));
}
return data;
}
function rcsLooksLikeHtml(text) {
return typeof text === "string" && /^\s*</.test(text);
}
function rcsProxyAuthErrorMessage(status, text, data) {
if (status === 401 || status === 403 || rcsLooksLikeHtml(text) || (data && rcsLooksLikeHtml(data.raw))) {
return "Open the RCS Web UI in this browser, log in through the reverse proxy (Pangolin), then retry.";
}
return (data && data.error) || (status ? `HTTP ${status}` : null) || "request failed";
}
async function rcsFetch(path, options = {}) {
if (rcsMustProxyViaBackground()) {
return rcsFetchViaBackground(path, options);
}
return rcsFetchDirect(path, options);
}
async function rcsCommentExists(commentId) {
const data = await rcsFetch(`/api/comments/exists?id=${encodeURIComponent(commentId)}`);
return {
exists: Boolean(data && data.exists),
has_screenshot: Boolean(data && data.has_screenshot),
};
}
async function rcsPostExists(postId) {
const data = await rcsFetch(`/api/posts/exists?id=${encodeURIComponent(postId)}`);
return Boolean(data && data.exists);
}
async function rcsSaveComment(payload) {
return rcsFetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
async function rcsAttachScreenshot(commentId, screenshotBase64) {
return rcsFetch(`/api/comments/${encodeURIComponent(commentId)}/screenshot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ screenshot_base64: screenshotBase64 }),
});
}
async function rcsSavePost(payload) {
return rcsFetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
async function rcsHealth() {
return rcsFetch("/api/health");
}
function rcsNormalizeUsername(name) {
return String(name || "")
.trim()
.replace(/^\/?u\//i, "")
.trim()
.toLowerCase();
}
async function rcsCacheBlocklist(usernames) {
const normalized = (usernames || [])
.map(rcsNormalizeUsername)
.filter(Boolean);
const unique = [...new Set(normalized)].sort();
await chrome.storage.sync.set({ blockedUsernames: unique });
return unique;
}
async function rcsGetCachedBlocklist() {
const stored = await chrome.storage.sync.get({ blockedUsernames: [] });
return Array.isArray(stored.blockedUsernames) ? stored.blockedUsernames : [];
}
async function rcsPullBlocklist() {
const data = await rcsFetch("/api/blocklist");
return rcsCacheBlocklist(data && data.usernames ? data.usernames : []);
}
async function rcsAddBlocked(username) {
const data = await rcsFetch("/api/blocklist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
return rcsCacheBlocklist(data && data.usernames ? data.usernames : []);
}
async function rcsRemoveBlocked(username) {
const encoded = encodeURIComponent(rcsNormalizeUsername(username));
const data = await rcsFetch(`/api/blocklist/${encoded}`, {
method: "DELETE",
});
return rcsCacheBlocklist(data && data.usernames ? data.usernames : []);
}