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.
172 lines
4.9 KiB
JavaScript
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);
|
|
});
|
|
}
|