Files
Squid 5a0e2e630b 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.
2026-08-06 22:02:45 +02:00

172 lines
4.9 KiB
JavaScript

// Background service worker: API proxy + screenshot crop.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === "rcs.api") {
(async () => {
try {
const data = await proxyApi(message);
sendResponse({ ok: true, data });
} catch (err) {
sendResponse({ ok: false, error: String(err && err.message ? err.message : err) });
}
})();
return true;
}
if (message.type === "rcs.redditJson") {
(async () => {
try {
const data = await fetchRedditJson(message.path);
sendResponse({ ok: true, data });
} catch (err) {
sendResponse({ ok: false, error: String(err && err.message ? err.message : err) });
}
})();
return true;
}
if (message.type === "rcs.captureCrop") {
const tabId = sender.tab && sender.tab.id;
if (tabId == null) {
sendResponse({ ok: false, error: "no tab" });
return false;
}
(async () => {
try {
const dataUrl = await chrome.tabs.captureVisibleTab(sender.tab.windowId, { format: "png" });
const cropped = await cropDataUrl(dataUrl, message.rect, message.dpr || 1);
sendResponse({ ok: true, dataUrl: cropped });
} catch (err) {
sendResponse({ ok: false, error: String(err && err.message ? err.message : err) });
}
})();
return true;
}
return false;
});
async function fetchRedditJson(path) {
const url = new URL(path, "https://www.reddit.com");
if (url.hostname !== "www.reddit.com" && url.hostname !== "old.reddit.com") {
throw new Error("reddit host not allowed");
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
credentials: "include",
});
const text = await response.text();
if (!response.ok) {
throw new Error(`reddit ${response.status}: ${text.slice(0, 160)}`);
}
try {
return JSON.parse(text);
} catch {
throw new Error("reddit response was not JSON (blocked or logged-out wall?)");
}
}
async function getSettings() {
const stored = await chrome.storage.sync.get({
backendUrl: "",
apiKey: "",
});
return {
backendUrl: (stored.backendUrl || "").replace(/\/+$/, ""),
apiKey: stored.apiKey || "",
};
}
async function proxyApi(message) {
const settings = await getSettings();
if (!settings.backendUrl) {
throw new Error("Backend URL not configured");
}
const headers = Object.assign(
{ Accept: "application/json" },
message.headers || {}
);
if (settings.apiKey) {
headers["X-API-Key"] = settings.apiKey;
}
const init = {
method: message.method || "GET",
headers,
credentials: "include",
};
if (message.body != null && message.method && message.method.toUpperCase() !== "GET") {
init.body = message.body;
}
const response = await fetch(`${settings.backendUrl}${message.path}`, init);
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(proxyAuthErrorMessage(response.status, text, data));
}
// Pangolin may return 200 HTML login/redirect instead of JSON
if (data && data.raw != null && looksLikeHtml(data.raw)) {
throw new Error(proxyAuthErrorMessage(response.status, text, data));
}
return data;
}
function looksLikeHtml(text) {
return typeof text === "string" && /^\s*</.test(text);
}
function proxyAuthErrorMessage(status, text, data) {
if (status === 401 || status === 403 || looksLikeHtml(text) || (data && looksLikeHtml(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 cropDataUrl(dataUrl, rect, dpr) {
const response = await fetch(dataUrl);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const scale = dpr || 1;
const sx = Math.max(0, Math.floor(rect.x * scale));
const sy = Math.max(0, Math.floor(rect.y * scale));
const sw = Math.max(1, Math.floor(rect.width * scale));
const sh = Math.max(1, Math.floor(rect.height * scale));
const width = Math.min(sw, bitmap.width - sx);
const height = Math.min(sh, bitmap.height - sy);
if (width <= 0 || height <= 0) {
throw new Error("invalid crop rect");
}
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, sx, sy, width, height, 0, 0, width, height);
const out = await canvas.convertToBlob({ type: "image/png" });
return blobToDataURL(out);
}
function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}