diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e79ce796..c42d3f48 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,8 +6,8 @@
# Coding standards
-- **Functions that return a value** — use arrow functions (e.g. `const getX = () => value`).
-- **Functions that do not return** (side-effect only) — use regular `function` declarations.
-- **Internal / private helpers** — prepend with `_` (e.g. `_parseQuery`, `_formatDate`).
-- **CSS** — Reuse existing app classes where possible (see [Styling](docs/styling.html) for a list). Must use SCSS/CSS variables (e.g. `$primary or var(--text-primary)`) so themes and light/dark mode keep working wel.
-- **Structure** — Keep things modular; follow the existing folder structure (e.g. one folder per plugin, one file or folder per engine).
+- **Functions that return a value** - use arrow functions (e.g. `const getX = () => value`).
+- **Functions that do not return** (side-effect only) - use regular `function` declarations.
+- **Internal / private helpers** - prepend with `_` (e.g. `_parseQuery`, `_formatDate`).
+- **CSS** - Reuse existing app classes where possible (see [Styling](docs/styling.html) for a list). Must use SCSS/CSS variables (e.g. `$primary or var(--text-primary)`) so themes and light/dark mode keep working wel.
+- **Structure** - Keep things modular; follow the existing folder structure (e.g. one folder per plugin, one file or folder per engine).
diff --git a/Dockerfile b/Dockerfile
index 241f2c0b..6c06d56e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,7 @@ COPY . .
RUN bun run build
FROM base AS release
-RUN apk add --no-cache git ca-certificates gosu curl
+RUN apk add --no-cache git ca-certificates su-exec curl
COPY --from=install /app/node_modules ./node_modules
COPY --from=build /app/src ./src
diff --git a/bun.lock b/bun.lock
index 29ff049a..00c9caef 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,7 +7,7 @@
"dependencies": {
"cheerio": "1.2.0",
"dompurify": "^3.4.2",
- "hono": "4.12.7",
+ "hono": "4.12.14",
"marked": "^18.0.2",
"sass": "1.85.0",
"socks": "2.8.7",
@@ -25,7 +25,7 @@
"overrides": {
"brace-expansion": "5.0.5",
"flatted": "3.4.2",
- "hono": "4.12.7",
+ "hono": "4.12.14",
"picomatch": "4.0.4",
"undici": "7.24.0",
},
@@ -252,7 +252,7 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
- "hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="],
+ "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
diff --git a/docs/aliases.html b/docs/aliases.html
index c7864ab7..4f60490c 100644
--- a/docs/aliases.html
+++ b/docs/aliases.html
@@ -3,7 +3,7 @@
- Aliases — Degoog docs
+ Aliases - Degoog docs
`;
+ result = result.replace("", `${nonceScript}\n `);
+ }
+
result = result.replace("", `${translationsScript}\n `);
- return translateHTML(result, t);
+ result = translateHTML(result, t);
+
+ if (BASE_URL) {
+ const baseScript = ``;
+ result = result.replace("", `${baseScript}\n `);
+ result = result.replace(
+ /(<(?:link|script|a|form)[^>]*(?:href|src|action)=")\/(?!\/)/g,
+ `$1${BASE_URL}/`,
+ );
+ }
+
+ return result;
}
function isFullDocument(html: string): boolean {
@@ -254,8 +279,23 @@ async function buildThemedLayoutPage(
return applyPagePlaceholders(html, t);
}
+const _apiKeySection = `
+
+
+
+
+
`;
+
+const _apiKeySectionLocked = `{{t:settings-page.server.api-key-no-password}}
`;
+
async function buildPage(filename: string, locale?: string): Promise {
- const html = await Bun.file(`src/public/${filename}`).text();
+ let html = await Bun.file(`src/public/${filename}`).text();
+ if (html.includes("__API_KEY_SECTION__")) {
+ const content = isPasswordRequired()
+ ? _apiKeySection
+ : _apiKeySectionLocked;
+ html = html.replace("__API_KEY_SECTION__", content);
+ }
const t = await getTranslator(locale);
return applyPagePlaceholders(html, t);
}
@@ -264,7 +304,7 @@ router.get("/", async (c) => {
const q = c.req.query("q");
if (q?.trim()) {
const params = new URLSearchParams(c.req.url.split("?")[1] || "");
- return c.redirect(`/search?${params.toString()}`, 302);
+ return c.redirect(`${BASE_URL || BASE_PATH}/search?${params.toString()}`, 302);
}
const locale = getLocale(c);
const override = await getThemeHtml("index");
@@ -324,7 +364,7 @@ router.get("/search", async (c) => {
return c.html(_injectIntoHead(html, actionsScript));
});
-router.get("/settings/", (c) => c.redirect("/settings", 301));
+router.get("/settings/", (c) => c.redirect(`${BASE_URL || BASE_PATH}/settings`, 301));
router.get("/settings", async (c) => {
const locale = getLocale(c);
if (isPublicInstance())
@@ -336,10 +376,10 @@ router.get("/settings", async (c) => {
});
router.get("/settings/:tab", async (c) => {
- if (isPublicInstance()) return c.redirect("/settings", 302);
+ if (isPublicInstance()) return c.redirect(`${BASE_URL || BASE_PATH}/settings`, 302);
const tab = c.req.param("tab");
if (!(SETTINGS_TABS as readonly string[]).includes(tab)) {
- return c.redirect("/settings", 302);
+ return c.redirect(`${BASE_URL || BASE_PATH}/settings`, 302);
}
const locale = getLocale(c);
if (await shouldServeSettingsGate(c)) {
@@ -363,7 +403,16 @@ router.get("/opensearch.xml", (c) => {
c.req.header("x-forwarded-host") ||
c.req.header("host") ||
new URL(c.req.url).host;
- return c.body(buildOpenSearchXml(`${proto}://${host}`), 200, {
+ const basePath = BASE_URL
+ ? (() => {
+ try {
+ return new URL(BASE_URL).pathname.replace(/\/+$/, "");
+ } catch {
+ return BASE_URL;
+ }
+ })()
+ : "";
+ return c.body(buildOpenSearchXml(`${proto}://${host}${basePath}`), 200, {
"Content-Type": "application/opensearchdescription+xml; charset=utf-8",
});
});
diff --git a/src/server/routes/proxy.ts b/src/server/routes/proxy.ts
index 4c823972..60a5ef90 100644
--- a/src/server/routes/proxy.ts
+++ b/src/server/routes/proxy.ts
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { getSettings, asString } from "../utils/plugin-settings";
import { outgoingFetch, isUrlAllowedForOutgoing } from "../utils/outgoing";
+import { verifyProxyUrl } from "../utils/proxy-sign";
import { getRandomUserAgent } from "../utils/user-agents";
const router = new Hono();
@@ -56,6 +57,12 @@ router.get("/api/proxy/image", async (c) => {
return c.body("Invalid protocol", 400);
}
+ const sig = c.req.query("sig");
+ const allowed = (sig && verifyProxyUrl(url, sig)) || isUrlAllowedForOutgoing(url);
+ if (!allowed) {
+ return c.body("URL not allowed for outgoing fetch", 403);
+ }
+
const authId = c.req.query("auth_id");
const headers: Record = {
"User-Agent": getRandomUserAgent(),
@@ -78,7 +85,7 @@ router.get("/api/proxy/image", async (c) => {
if (parsed.hostname === serverHost) {
headers[headerName] = apiKey;
}
- } catch {}
+ } catch { }
}
}
@@ -89,10 +96,11 @@ router.get("/api/proxy/image", async (c) => {
const res = await outgoingFetch(url, {
signal: controller.signal,
headers,
- redirect: "follow",
+ redirect: "manual",
});
clearTimeout(timeout);
+ if (res.status >= 300 && res.status < 400) return c.body("Upstream error", 502);
if (!res.ok) return c.body("Upstream error", 502);
const contentType =
@@ -123,6 +131,47 @@ router.get("/api/proxy/image", async (c) => {
}
});
+const FAVICON_TIMEOUT_MS = 5_000;
+const FAVICON_CONTENT_TYPES = ["image/", "text/html"];
+
+router.get("/api/proxy/favicon", async (c) => {
+ const domain = c.req.query("domain")?.trim();
+ if (!domain || !/^[a-zA-Z0-9.-]+$/.test(domain)) {
+ return c.body("Invalid domain", 400);
+ }
+
+ const candidates = [
+ `https://www.google.com/s2/favicons?domain=${domain}&sz=32`,
+ `https://icons.duckduckgo.com/ip3/${domain}.ico`,
+ ];
+
+ for (const faviconUrl of candidates) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), FAVICON_TIMEOUT_MS);
+ try {
+ const res = await outgoingFetch(faviconUrl, {
+ signal: controller.signal,
+ headers: { "User-Agent": getRandomUserAgent() },
+ redirect: "follow",
+ });
+ clearTimeout(timeout);
+ if (!res.ok) continue;
+ const contentType = res.headers.get("content-type")?.split(";")[0]?.trim() ?? "";
+ if (!FAVICON_CONTENT_TYPES.some((t) => contentType.startsWith(t))) continue;
+ const body = await res.arrayBuffer();
+ return c.body(body, 200, {
+ "Content-Type": contentType || "image/x-icon",
+ "Cache-Control": "public, max-age=86400",
+ "X-Content-Type-Options": "nosniff",
+ });
+ } catch {
+ clearTimeout(timeout);
+ }
+ }
+
+ return c.body("Favicon not found", 404);
+});
+
router.post("/api/proxy/fetch", async (c) => {
let body: { url?: string; headers?: Record };
try {
diff --git a/src/server/routes/search-stream.ts b/src/server/routes/search-stream.ts
index a4c212c0..7e8f2177 100644
--- a/src/server/routes/search-stream.ts
+++ b/src/server/routes/search-stream.ts
@@ -25,7 +25,9 @@ import {
isValidQuery,
parseEngineConfig,
} from "../utils/search";
+import { guardApiKey } from "../utils/api-key-guard";
import { applyDomainRules } from "./search/_domain-rules";
+import { signResultThumbnails } from "../utils/proxy-sign";
import { parsePage } from "./search/_parsers";
const router = new Hono();
@@ -33,6 +35,8 @@ const router = new Hono();
router.get("/api/search/stream", async (c) => {
const limitRes = await _applyRateLimit(c);
if (limitRes) return limitRes;
+ const authRes = await guardApiKey(c, "apiKeySearchEnabled");
+ if (authRes) return authRes;
const query = c.req.query("q") ?? "";
@@ -59,7 +63,7 @@ router.get("/api/search/stream", async (c) => {
const cached = cache.get(key);
if (cached) {
- const liveResults = await applyDomainRules(cached.results);
+ const liveResults = signResultThumbnails(await applyDomainRules(cached.results));
const encoder = new TextEncoder();
const body = new ReadableStream({
start(controller) {
@@ -188,7 +192,7 @@ router.get("/api/search/stream", async (c) => {
_send("engine-result", {
engine: engineName,
timing,
- results: await applyDomainRules(scoreResults(allRawResults)),
+ results: signResultThumbnails(await applyDomainRules(scoreResults(allRawResults))),
retry: isRetry,
attempt,
});
diff --git a/src/server/routes/search/_search-handlers.ts b/src/server/routes/search/_search-handlers.ts
index 40ff20b2..406e4f71 100644
--- a/src/server/routes/search/_search-handlers.ts
+++ b/src/server/routes/search/_search-handlers.ts
@@ -8,6 +8,7 @@ import type {
} from "../../types";
import * as cache from "../../utils/cache";
import { cacheKey } from "../../utils/search";
+import { signResultThumbnails } from "../../utils/proxy-sign";
import { applyDomainRules } from "./_domain-rules";
export async function handleSearch(params: SearchParams) {
@@ -34,7 +35,7 @@ export async function handleSearch(params: SearchParams) {
const cached = cache.get(key);
if (cached) {
- return { ...cached, results: await applyDomainRules(cached.results) };
+ return { ...cached, results: signResultThumbnails(await applyDomainRules(cached.results)) };
}
const response = await search(
@@ -55,7 +56,7 @@ export async function handleSearch(params: SearchParams) {
: undefined;
cache.set(key, response, ttl);
- return { ...response, results: await applyDomainRules(response.results) };
+ return { ...response, results: signResultThumbnails(await applyDomainRules(response.results)) };
}
export async function handleRetry(params: SearchParams & { engineName: string }) {
@@ -110,7 +111,7 @@ export async function handleRetry(params: SearchParams & { engineName: string })
updated,
cache.hasFailedEngines(updated) ? cache.SHORT_TTL_MS : undefined,
);
- return { ...updated, results: await applyDomainRules(merged) };
+ return { ...updated, results: signResultThumbnails(await applyDomainRules(merged)) };
}
return {
diff --git a/src/server/routes/search/_search-routes.ts b/src/server/routes/search/_search-routes.ts
index afdacd85..daa27ea6 100644
--- a/src/server/routes/search/_search-routes.ts
+++ b/src/server/routes/search/_search-routes.ts
@@ -1,6 +1,16 @@
import type { Hono } from "hono";
-import type { SearchBody, SearchType, TimeFilter, RetryPostBody } from "../../types";
-import { _applyRateLimit, isValidQuery, parseEngineConfig } from "../../utils/search";
+import type {
+ SearchBody,
+ SearchType,
+ TimeFilter,
+ RetryPostBody,
+} from "../../types";
+import {
+ _applyRateLimit,
+ isValidQuery,
+ parseEngineConfig,
+} from "../../utils/search";
+import { guardApiKey } from "../../utils/api-key-guard";
import { parseEnginesFromBody, parsePage } from "./_parsers";
import { handleRetry, handleSearch } from "./_search-handlers";
@@ -8,6 +18,8 @@ export function registerSearchRoutes(router: Hono): void {
router.get("/api/search", async (c) => {
const limitRes = await _applyRateLimit(c);
if (limitRes) return limitRes;
+ const authRes = await guardApiKey(c, "apiKeySearchEnabled");
+ if (authRes) return authRes;
const query = c.req.query("q") ?? "";
if (!isValidQuery(query))
@@ -30,8 +42,42 @@ export function registerSearchRoutes(router: Hono): void {
router.post("/api/search", async (c) => {
const limitRes = await _applyRateLimit(c);
if (limitRes) return limitRes;
-
- const body = await c.req.json();
+ const authRes = await guardApiKey(c, "apiKeySearchEnabled");
+ if (authRes) return authRes;
+
+ const contentType = c.req.header("content-type") ?? "";
+
+ if (contentType.includes("application/x-www-form-urlencoded")) {
+ let form: FormData;
+ try {
+ form = await c.req.formData();
+ } catch {
+ return c.json({ error: "Invalid form data" }, 400);
+ }
+ const query = (form.get("q") as string | null) ?? "";
+ if (!isValidQuery(query))
+ return c.json({ error: "Missing or invalid query parameter 'q'" }, 400);
+
+ const result = await handleSearch({
+ query,
+ engines: parseEnginesFromBody(undefined),
+ searchType: ((form.get("type") as string | null) || "web") as SearchType,
+ page: parsePage(form.get("page")),
+ timeFilter: ((form.get("time") as string | null) || "any") as TimeFilter,
+ lang: (form.get("lang") as string | null) || "",
+ dateFrom: (form.get("dateFrom") as string | null) || "",
+ dateTo: (form.get("dateTo") as string | null) || "",
+ });
+
+ return c.json(result);
+ }
+
+ let body: SearchBody;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
const query = body.query ?? "";
if (!isValidQuery(query))
return c.json({ error: "Missing or invalid query parameter 'q'" }, 400);
@@ -53,6 +99,8 @@ export function registerSearchRoutes(router: Hono): void {
router.get("/api/search/retry", async (c) => {
const limitRes = await _applyRateLimit(c);
if (limitRes) return limitRes;
+ const authRes = await guardApiKey(c, "apiKeySearchEnabled");
+ if (authRes) return authRes;
const query = c.req.query("q");
const engineName = c.req.query("engine");
@@ -77,8 +125,15 @@ export function registerSearchRoutes(router: Hono): void {
router.post("/api/search/retry", async (c) => {
const limitRes = await _applyRateLimit(c);
if (limitRes) return limitRes;
-
- const body = await c.req.json();
+ const authRes = await guardApiKey(c, "apiKeySearchEnabled");
+ if (authRes) return authRes;
+
+ let body: RetryPostBody;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
const query = body.query ?? "";
const engineName = body.engine ?? "";
if (!query || !engineName)
diff --git a/src/server/routes/settings-auth.ts b/src/server/routes/settings-auth.ts
index 5528b051..067a0049 100644
--- a/src/server/routes/settings-auth.ts
+++ b/src/server/routes/settings-auth.ts
@@ -1,39 +1,77 @@
-import { readFile, writeFile } from "fs/promises";
import { Hono, type Context } from "hono";
import { getMiddleware } from "../extensions/middleware/registry";
-import { outgoingFetch } from "../utils/outgoing";
-import { defaultEnginesFile } from "../utils/paths";
-import { asString, getSettings, setSettings } from "../utils/plugin-settings";
+import { asString, getSettings } from "../utils/plugin-settings";
import { isPublicInstance } from "../utils/public-instance";
-import { getRandomUserAgent } from "../utils/user-agents";
-
-const DEGOOG_SETTINGS_ID = "degoog-settings";
+import { logger } from "../utils/logger";
+import {
+ TOKEN_TTL_MS,
+ checkAuthRate,
+ generateSettingsToken,
+ passwordMatches,
+ recordAuthFailure,
+ tokenStore,
+} from "../utils/settings-tokens";
const router = new Hono();
-const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const COOKIE_NAME = "settings-token";
-const validTokens = new Map();
const MIDDLEWARE_SETTINGS_ID = "middleware";
const SETTINGS_GATE_KEY = "settingsGate";
+const _clientIp = (c: Context): string =>
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
+ c.req.header("x-real-ip") ||
+ "unknown";
+
function getTokenFromCookie(c: Context): string | undefined {
const raw = c.req.header("cookie");
- if (!raw) return undefined;
+ if (!raw) {
+ logger.debug("settings-auth", "no cookie header present");
+ return undefined;
+ }
const match = raw
.split(";")
.find((s) => s.trim().startsWith(COOKIE_NAME + "="));
- if (!match) return undefined;
+ if (!match) {
+ logger.debug(
+ "settings-auth",
+ `cookie header present but '${COOKIE_NAME}' not found`,
+ );
+ return undefined;
+ }
const value = match.split("=")[1]?.trim();
+ logger.debug(
+ "settings-auth",
+ `'${COOKIE_NAME}' cookie found (length: ${value?.length ?? 0})`,
+ );
return value || undefined;
}
export function getSettingsTokenFromRequest(c: Context): string | undefined {
- return (
- c.req.header("x-settings-token") ??
- c.req.query("token") ??
- getTokenFromCookie(c)
- );
+ const fromHeader = c.req.header("x-settings-token");
+ if (fromHeader) {
+ logger.debug("settings-auth", "token source: x-settings-token header");
+ return fromHeader;
+ }
+ const fromQuery = c.req.query("token");
+ if (fromQuery) {
+ logger.debug("settings-auth", "token source: query param");
+ return fromQuery;
+ }
+ return getTokenFromCookie(c);
+}
+
+export async function guardSettingsRoute(
+ c: Context,
+ route: string,
+): Promise {
+ const token = getSettingsTokenFromRequest(c);
+ const valid = await validateSettingsToken(token);
+ if (!valid) {
+ logger.debug("settings-auth", `401 on ${route}`);
+ return c.json({ error: "Unauthorized" }, 401);
+ }
+ return null;
}
export async function shouldServeSettingsGate(c: Context): Promise {
@@ -52,37 +90,38 @@ function getPasswords(): string[] {
.filter(Boolean);
}
-function isPasswordRequired(): boolean {
+export function isPasswordRequired(): boolean {
return getPasswords().length > 0;
}
-function generateToken(): string {
- const bytes = new Uint8Array(32);
- crypto.getRandomValues(bytes);
- return Array.from(bytes)
- .map((b) => b.toString(16).padStart(2, "0"))
- .join("");
-}
-
-function pruneExpired(): void {
- const now = Date.now();
- for (const [token, expiresAt] of validTokens) {
- if (now > expiresAt) validTokens.delete(token);
- }
-}
-
export async function validateSettingsToken(
token: string | undefined,
): Promise {
if (isPublicInstance()) return false;
const required = await isAuthRequired();
if (!required) return true;
- if (!token) return false;
- const expiresAt = validTokens.get(token);
- if (!expiresAt || Date.now() > expiresAt) {
- if (expiresAt) validTokens.delete(token);
+ if (!token) {
+ logger.debug("settings-auth", "token validation failed: no token provided");
return false;
}
+ const expiresAt = tokenStore.get(token);
+ if (!expiresAt) {
+ logger.debug(
+ "settings-auth",
+ `token validation failed: token not found in store (${tokenStore.size()} active tokens)`,
+ );
+ return false;
+ }
+ if (Date.now() > expiresAt) {
+ tokenStore.delete(token);
+ logger.debug("settings-auth", "token validation failed: token expired");
+ return false;
+ }
+ const ttlMs = expiresAt - Date.now();
+ logger.debug(
+ "settings-auth",
+ `token valid (expires in ${Math.round(ttlMs / 1000 / 60)}m)`,
+ );
return true;
}
@@ -104,23 +143,29 @@ async function isAuthRequired(): Promise {
}
router.get("/api/settings/auth", async (c) => {
- const m = await getSelectedMiddlewareForSettingsGate();
- if (!m) {
- if (!isPasswordRequired()) return c.json({ required: false, valid: true });
- const token = getSettingsTokenFromRequest(c);
- if (await validateSettingsToken(token))
- return c.json({ required: true, valid: true });
- return c.json({ required: true, valid: false });
- }
+ const required = await isAuthRequired();
+ if (!required) return c.json({ required: false, valid: true });
+
const token = getSettingsTokenFromRequest(c);
if (await validateSettingsToken(token))
return c.json({ required: true, valid: true });
+
+ const m = await getSelectedMiddlewareForSettingsGate();
+ if (!m) {
+ if (isPasswordRequired()) return c.json({ required: true, valid: false });
+ logger.warn(
+ "settings-auth",
+ "settingsGate references a middleware that is not loaded; refusing to grant access",
+ );
+ return c.json({
+ required: true,
+ valid: false,
+ error: "auth-misconfigured",
+ });
+ }
+
const result = await m.handle(c.req.raw, { route: "settings-auth" });
if (result instanceof Response) return result;
- if (result === null) {
- if (!isPasswordRequired()) return c.json({ required: false, valid: true });
- return c.json({ required: true, valid: false });
- }
return c.json({ required: true, valid: false });
});
@@ -133,9 +178,9 @@ router.get("/api/settings/auth/callback", async (c) => {
!(result instanceof Response) &&
"redirect" in result
) {
- pruneExpired();
- const sessionToken = generateToken();
- validTokens.set(sessionToken, Date.now() + TOKEN_TTL_MS);
+ tokenStore.pruneExpired();
+ const sessionToken = generateSettingsToken();
+ tokenStore.set(sessionToken, Date.now() + TOKEN_TTL_MS);
const sep = result.redirect.includes("?") ? "&" : "?";
return c.redirect(`${result.redirect}${sep}token=${sessionToken}`);
}
@@ -145,6 +190,17 @@ router.get("/api/settings/auth/callback", async (c) => {
router.post("/api/settings/auth", async (c) => {
if (isPublicInstance()) return c.json({ error: "Unauthorized" }, 401);
+ const ip = _clientIp(c);
+ const rate = checkAuthRate(ip);
+ if (!rate.allowed) {
+ logger.warn(
+ "settings-auth",
+ `auth rate-limited for ${ip} (retry in ${rate.retryAfter}s)`,
+ );
+ return c.json({ ok: false, error: "Too many attempts" }, 429, {
+ "Retry-After": String(rate.retryAfter),
+ });
+ }
const m = await getSelectedMiddlewareForSettingsGate();
if (m) {
const result = await m.handle(c.req.raw, { route: "settings-auth-post" });
@@ -152,278 +208,26 @@ router.post("/api/settings/auth", async (c) => {
return c.json({ ok: false, error: "Use the login flow" }, 400);
}
if (!isPasswordRequired()) return c.json({ ok: true, token: null });
- const body = await c.req.json<{ password?: string }>();
+ let body: { password?: string };
+ try {
+ body = await c.req.json<{ password?: string }>();
+ } catch {
+ recordAuthFailure(ip);
+ return c.json({ ok: false }, 400);
+ }
const passwords = getPasswords();
- if (!body.password || !passwords.includes(body.password)) {
+ const candidate = typeof body.password === "string" ? body.password : "";
+ if (!candidate || !passwordMatches(candidate, passwords)) {
+ recordAuthFailure(ip);
return c.json({ ok: false }, 401);
}
- pruneExpired();
- const token = generateToken();
- validTokens.set(token, Date.now() + TOKEN_TTL_MS);
+ tokenStore.pruneExpired();
+ const token = generateSettingsToken();
+ tokenStore.set(token, Date.now() + TOKEN_TTL_MS);
const cookie = `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${TOKEN_TTL_MS / 1000}`;
return c.json({ ok: true, token }, 200, {
"Set-Cookie": cookie,
});
});
-router.get("/api/settings/general", async (c) => {
- const token = getSettingsTokenFromRequest(c);
- if (!(await validateSettingsToken(token))) {
- return c.json({ error: "Unauthorized" }, 401);
- }
- const settings = await getSettings(DEGOOG_SETTINGS_ID);
- return c.json(settings);
-});
-
-router.post("/api/settings/general", async (c) => {
- const token = getSettingsTokenFromRequest(c);
- if (!(await validateSettingsToken(token))) {
- return c.json({ error: "Unauthorized" }, 401);
- }
- let body: Record;
- try {
- body = await c.req.json>();
- } catch {
- return c.json({ error: "Invalid JSON" }, 400);
- }
- const existing = await getSettings(DEGOOG_SETTINGS_ID);
- const allowed = [
- "proxyEnabled",
- "proxyUrls",
- "rateLimitEnabled",
- "rateLimitBurstWindow",
- "rateLimitBurstMax",
- "rateLimitLongWindow",
- "rateLimitLongMax",
- "languagesEnabled",
- "languages",
- "streamingEnabled",
- "streamingAutoRetry",
- "streamingMaxRetries",
- "postMethodEnabled",
- "defaultTheme",
- "domainBlockEnabled",
- "domainBlockList",
- "domainBlockUiEnabled",
- "domainReplaceEnabled",
- "domainReplaceList",
- "domainReplaceUiEnabled",
- "domainScoreEnabled",
- "domainScoreList",
- "domainScoreUiEnabled",
- "customCss",
- ];
- const updates: Record = {};
- for (const key of allowed) {
- if (key in body && typeof body[key] === "string") {
- updates[key] = body[key];
- }
- }
- await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates });
- return c.json({ ok: true });
-});
-
-const _normalizeHostname = (raw: string): string =>
- raw
- .trim()
- .toLowerCase()
- .replace(/^https?:\/\//, "")
- .replace(/\/.*$/, "");
-
-const _appendBlock = (existing: string, source: string): string => {
- const lines = existing
- .split("\n")
- .map((l) => l.trim())
- .filter((l) => l.length > 0);
- if (lines.includes(source)) return existing;
- lines.push(source);
- return lines.join("\n");
-};
-
-const _appendReplace = (
- existing: string,
- source: string,
- target: string,
-): string => {
- const lines = existing
- .split("\n")
- .map((l) => l.trim())
- .filter((l) => l.length > 0);
- const next = lines.filter((l) => {
- const [src] = l.split("->").map((s) => s.trim());
- return src !== source;
- });
- next.push(`${source} -> ${target}`);
- return next.join("\n");
-};
-
-const _upsertScore = (
- existing: string,
- source: string,
- score: number,
-): string => {
- const lines = existing
- .split("\n")
- .map((l) => l.trim())
- .filter((l) => l.length > 0);
- const next = lines.filter((l) => {
- const [src] = l.split("|").map((s) => s.trim());
- return src !== source;
- });
- next.push(`${source}|${score}`);
- return next.join("\n");
-};
-
-router.post("/api/settings/domain-action", async (c) => {
- const token = getSettingsTokenFromRequest(c);
- if (!(await validateSettingsToken(token))) {
- return c.json({ error: "Unauthorized" }, 401);
- }
-
- let body: {
- kind?: string;
- source?: string;
- target?: string;
- score?: number;
- };
- try {
- body = await c.req.json();
- } catch {
- return c.json({ error: "Invalid JSON" }, 400);
- }
-
- const kind = body.kind;
- const source = _normalizeHostname(body.source ?? "");
- if (!source) return c.json({ error: "Missing source" }, 400);
-
- const existing = await getSettings(DEGOOG_SETTINGS_ID);
- const updates: Record = {};
-
- if (kind === "block") {
- if (asString(existing.domainBlockUiEnabled) !== "true") {
- return c.json({ error: "Forbidden" }, 403);
- }
- updates.domainBlockList = _appendBlock(
- asString(existing.domainBlockList),
- source,
- );
- } else if (kind === "replace") {
- if (asString(existing.domainReplaceUiEnabled) !== "true") {
- return c.json({ error: "Forbidden" }, 403);
- }
- const target = _normalizeHostname(body.target ?? "");
- if (!target) return c.json({ error: "Missing target" }, 400);
- updates.domainReplaceList = _appendReplace(
- asString(existing.domainReplaceList),
- source,
- target,
- );
- } else if (kind === "score") {
- if (asString(existing.domainScoreUiEnabled) !== "true") {
- return c.json({ error: "Forbidden" }, 403);
- }
- const score = Number(body.score);
- if (!Number.isFinite(score)) {
- return c.json({ error: "Invalid score" }, 400);
- }
- updates.domainScoreList = _upsertScore(
- asString(existing.domainScoreList),
- source,
- Math.trunc(score),
- );
- } else {
- return c.json({ error: "Invalid kind" }, 400);
- }
-
- await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates });
- return c.json({ ok: true });
-});
-
-const IP_CHECK_URL = "https://api.ipify.org?format=json";
-const IP_CHECK_TIMEOUT_MS = 8_000;
-
-async function fetchIp(useFn: typeof fetch): Promise {
- try {
- const res = await useFn(IP_CHECK_URL, {
- signal: AbortSignal.timeout(IP_CHECK_TIMEOUT_MS),
- headers: {
- "User-Agent": getRandomUserAgent(),
- Accept: "application/json,text/plain,*/*;q=0.8",
- "Accept-Language": "en-US,en;q=0.9",
- },
- });
- if (!res.ok) return null;
- const data = (await res.json()) as { ip?: string };
- return data.ip ?? null;
- } catch {
- return null;
- }
-}
-
-router.get("/api/settings/proxy-test", async (c) => {
- const token = getSettingsTokenFromRequest(c);
- if (!(await validateSettingsToken(token))) {
- return c.json({ error: "Unauthorized" }, 401);
- }
-
- const settings = await getSettings(DEGOOG_SETTINGS_ID);
- const enabled = asString(settings.proxyEnabled) === "true";
- const proxyUrls = asString(settings.proxyUrls);
-
- const directIp = await fetchIp(fetch);
-
- if (!enabled || !proxyUrls.trim()) {
- return c.json({
- enabled: false,
- directIp,
- proxyIp: null,
- match: null,
- });
- }
-
- const proxyIp = await fetchIp(outgoingFetch as typeof fetch);
-
- return c.json({
- enabled: true,
- directIp,
- proxyIp,
- match: directIp !== null && proxyIp !== null && directIp === proxyIp,
- });
-});
-
-router.get("/api/settings/appearance", async (c) => {
- const settings = await getSettings(DEGOOG_SETTINGS_ID);
- return c.json({
- theme: asString(settings.defaultTheme) || "system",
- });
-});
-
-router.get("/api/settings/default-engines", async (c) => {
- const token = getSettingsTokenFromRequest(c);
- if (!(await validateSettingsToken(token))) {
- return c.json({ error: "Unauthorized" }, 401);
- }
- try {
- const raw = await readFile(defaultEnginesFile(), "utf-8");
- return c.json(JSON.parse(raw));
- } catch {
- return c.json({});
- }
-});
-
-router.post("/api/settings/default-engines", async (c) => {
- const token = getSettingsTokenFromRequest(c);
- if (!(await validateSettingsToken(token))) {
- return c.json({ error: "Unauthorized" }, 401);
- }
- let body: Record;
- try {
- body = await c.req.json>();
- } catch {
- return c.json({ error: "Invalid JSON" }, 400);
- }
- await writeFile(defaultEnginesFile(), JSON.stringify(body, null, 2), "utf-8");
- return c.json({ ok: true });
-});
-
export default router;
diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts
index e03f9cb5..49629d62 100644
--- a/src/server/routes/settings.ts
+++ b/src/server/routes/settings.ts
@@ -1,9 +1,111 @@
+import { readFile, writeFile } from "fs/promises";
import { Hono } from "hono";
-import { asString, getSettings } from "../utils/plugin-settings";
+import { outgoingFetch } from "../utils/outgoing";
+import { defaultEnginesFile } from "../utils/paths";
+import { asString, getSettings, setSettings } from "../utils/plugin-settings";
+import { getRandomUserAgent } from "../utils/user-agents";
import { DEFAULT_LANGUAGES, DEGOOG_SETTINGS_ID } from "../utils/search";
+import { getServerKeyHex, regenerateServerKey } from "../utils/server-key";
+import { guardSettingsRoute, isPasswordRequired } from "./settings-auth";
const router = new Hono();
+const GENERAL_ALLOWED_KEYS = [
+ "proxyEnabled",
+ "proxyUrls",
+ "rateLimitEnabled",
+ "rateLimitBurstWindow",
+ "rateLimitBurstMax",
+ "rateLimitLongWindow",
+ "rateLimitLongMax",
+ "languagesEnabled",
+ "languages",
+ "streamingEnabled",
+ "streamingAutoRetry",
+ "streamingMaxRetries",
+ "postMethodEnabled",
+ "defaultTheme",
+ "domainBlockEnabled",
+ "domainBlockList",
+ "domainBlockUiEnabled",
+ "domainReplaceEnabled",
+ "domainReplaceList",
+ "domainReplaceUiEnabled",
+ "domainScoreEnabled",
+ "domainScoreList",
+ "domainScoreUiEnabled",
+ "customCss",
+ "apiKeySearchEnabled",
+ "apiKeySuggestEnabled",
+] as const;
+
+const _normalizeHostname = (raw: string): string =>
+ raw
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, "")
+ .replace(/\/.*$/, "");
+
+const _splitLines = (raw: string): string[] =>
+ raw
+ .split("\n")
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0);
+
+const _appendBlock = (existing: string, source: string): string => {
+ const lines = _splitLines(existing);
+ if (lines.includes(source)) return existing;
+ lines.push(source);
+ return lines.join("\n");
+};
+
+const _appendReplace = (
+ existing: string,
+ source: string,
+ target: string,
+): string => {
+ const next = _splitLines(existing).filter((l) => {
+ const [src] = l.split("->").map((s) => s.trim());
+ return src !== source;
+ });
+ next.push(`${source} -> ${target}`);
+ return next.join("\n");
+};
+
+const _upsertScore = (
+ existing: string,
+ source: string,
+ score: number,
+): string => {
+ const next = _splitLines(existing).filter((l) => {
+ const [src] = l.split("|").map((s) => s.trim());
+ return src !== source;
+ });
+ next.push(`${source}|${score}`);
+ return next.join("\n");
+};
+
+const IP_CHECK_URL = "https://api.ipify.org?format=json";
+const IP_CHECK_TIMEOUT_MS = 8_000;
+
+const fetchIp = async (useFn: typeof fetch): Promise => {
+ try {
+ const res = await useFn(IP_CHECK_URL, {
+ signal: AbortSignal.timeout(IP_CHECK_TIMEOUT_MS),
+ headers: {
+ "User-Agent": getRandomUserAgent(),
+ Accept: "application/json,text/plain,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.9",
+ },
+ });
+ if (!res.ok) return null;
+ const data = (await res.json()) as { ip?: string };
+ return data.ip ?? null;
+ } catch {
+ return null;
+ }
+};
+
router.get("/api/settings/streaming", async (c) => {
const settings = await getSettings(DEGOOG_SETTINGS_ID);
return c.json({
@@ -26,4 +128,186 @@ router.get("/api/settings/languages", async (c) => {
return c.json({ languages: codes.length > 0 ? codes : DEFAULT_LANGUAGES });
});
+router.get("/api/settings/general", async (c) => {
+ const denied = await guardSettingsRoute(c, "GET /api/settings/general");
+ if (denied) return denied;
+ const settings = await getSettings(DEGOOG_SETTINGS_ID);
+ return c.json(settings);
+});
+
+router.post("/api/settings/general", async (c) => {
+ const denied = await guardSettingsRoute(c, "POST /api/settings/general");
+ if (denied) return denied;
+ let body: Record;
+ try {
+ body = await c.req.json>();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
+ const existing = await getSettings(DEGOOG_SETTINGS_ID);
+ const updates: Record = {};
+ for (const key of GENERAL_ALLOWED_KEYS) {
+ if (key in body && typeof body[key] === "string") {
+ updates[key] = body[key];
+ }
+ }
+ await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates });
+ return c.json({ ok: true });
+});
+
+router.post("/api/settings/domain-action", async (c) => {
+ const denied = await guardSettingsRoute(
+ c,
+ "POST /api/settings/domain-action",
+ );
+ if (denied) return denied;
+
+ let body: {
+ kind?: string;
+ source?: string;
+ target?: string;
+ score?: number;
+ };
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
+
+ const kind = body.kind;
+ const source = _normalizeHostname(body.source ?? "");
+ if (!source) return c.json({ error: "Missing source" }, 400);
+
+ const existing = await getSettings(DEGOOG_SETTINGS_ID);
+ const updates: Record = {};
+
+ if (kind === "block") {
+ if (asString(existing.domainBlockUiEnabled) !== "true") {
+ return c.json({ error: "Forbidden" }, 403);
+ }
+ updates.domainBlockList = _appendBlock(
+ asString(existing.domainBlockList),
+ source,
+ );
+ } else if (kind === "replace") {
+ if (asString(existing.domainReplaceUiEnabled) !== "true") {
+ return c.json({ error: "Forbidden" }, 403);
+ }
+ const target = _normalizeHostname(body.target ?? "");
+ if (!target) return c.json({ error: "Missing target" }, 400);
+ updates.domainReplaceList = _appendReplace(
+ asString(existing.domainReplaceList),
+ source,
+ target,
+ );
+ } else if (kind === "score") {
+ if (asString(existing.domainScoreUiEnabled) !== "true") {
+ return c.json({ error: "Forbidden" }, 403);
+ }
+ const score = Number(body.score);
+ if (!Number.isFinite(score)) {
+ return c.json({ error: "Invalid score" }, 400);
+ }
+ updates.domainScoreList = _upsertScore(
+ asString(existing.domainScoreList),
+ source,
+ Math.trunc(score),
+ );
+ } else {
+ return c.json({ error: "Invalid kind" }, 400);
+ }
+
+ await setSettings(DEGOOG_SETTINGS_ID, { ...existing, ...updates });
+ return c.json({ ok: true });
+});
+
+router.get("/api/settings/api-key", async (c) => {
+ if (!isPasswordRequired()) return c.json({ error: "Forbidden" }, 403);
+ const denied = await guardSettingsRoute(c, "GET /api/settings/api-key");
+ if (denied) return denied;
+ const settings = await getSettings(DEGOOG_SETTINGS_ID);
+ return c.json({
+ key: getServerKeyHex() ?? "",
+ searchEnabled: asString(settings.apiKeySearchEnabled) === "true",
+ suggestEnabled: asString(settings.apiKeySuggestEnabled) === "true",
+ });
+});
+
+router.post("/api/settings/api-key/regenerate", async (c) => {
+ if (!isPasswordRequired()) return c.json({ error: "Forbidden" }, 403);
+ const denied = await guardSettingsRoute(
+ c,
+ "POST /api/settings/api-key/regenerate",
+ );
+ if (denied) return denied;
+ await regenerateServerKey();
+ return c.json({ key: getServerKeyHex() ?? "" });
+});
+
+router.get("/api/settings/proxy-test", async (c) => {
+ const denied = await guardSettingsRoute(c, "GET /api/settings/proxy-test");
+ if (denied) return denied;
+
+ const settings = await getSettings(DEGOOG_SETTINGS_ID);
+ const enabled = asString(settings.proxyEnabled) === "true";
+ const proxyUrls = asString(settings.proxyUrls);
+
+ const directIp = await fetchIp(fetch);
+
+ if (!enabled || !proxyUrls.trim()) {
+ return c.json({
+ enabled: false,
+ directIp,
+ proxyIp: null,
+ match: null,
+ });
+ }
+
+ const proxyIp = await fetchIp(outgoingFetch as typeof fetch);
+
+ return c.json({
+ enabled: true,
+ directIp,
+ proxyIp,
+ match: directIp !== null && proxyIp !== null && directIp === proxyIp,
+ });
+});
+
+router.get("/api/settings/appearance", async (c) => {
+ const settings = await getSettings(DEGOOG_SETTINGS_ID);
+ return c.json({
+ theme: asString(settings.defaultTheme) || "system",
+ });
+});
+
+router.get("/api/settings/default-engines", async (c) => {
+ const denied = await guardSettingsRoute(
+ c,
+ "GET /api/settings/default-engines",
+ );
+ if (denied) return denied;
+ try {
+ const raw = await readFile(defaultEnginesFile(), "utf-8");
+ return c.json(JSON.parse(raw));
+ } catch {
+ return c.json({});
+ }
+});
+
+router.post("/api/settings/default-engines", async (c) => {
+ const denied = await guardSettingsRoute(
+ c,
+ "POST /api/settings/default-engines",
+ );
+ if (denied) return denied;
+ let body: Record;
+ try {
+ body = await c.req.json>();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
+ await writeFile(defaultEnginesFile(), JSON.stringify(body, null, 2), "utf-8");
+ return c.json({ ok: true });
+});
+
export default router;
diff --git a/src/server/routes/slots.ts b/src/server/routes/slots.ts
index baf78012..f7a5bc8c 100644
--- a/src/server/routes/slots.ts
+++ b/src/server/routes/slots.ts
@@ -6,10 +6,12 @@ import {
SlotPanelResult,
SlotPluginContext,
} from "../types";
+import { createCache } from "../utils/cache";
import { getLocale } from "../utils/hono";
import { logger } from "../utils/logger";
import { outgoingFetch } from "../utils/outgoing";
import { isDisabled } from "../utils/plugin-settings";
+import { buildSignedProxyUrl } from "../utils/proxy-sign";
import { getClientIp } from "../utils/request";
import { _applyRateLimit, runSlotPlugins } from "../utils/search";
import { injectScope, translateHTML } from "../utils/translation";
@@ -68,6 +70,8 @@ router.post("/api/slots/glance", async (c) => {
clientIp: clientIp ?? undefined,
results: body.results,
fetch: outgoingFetch as SlotPluginContext["fetch"],
+ signProxyUrl: buildSignedProxyUrl,
+ createCache,
};
const t0 = performance.now();
const out = await plugin.execute(body.query!.trim(), context);
@@ -86,7 +90,7 @@ router.post("/api/slots/glance", async (c) => {
position: plugin.position,
gridSize: plugin.gridSize,
});
- } catch {}
+ } catch { }
}
return c.json({ panels });
});
diff --git a/src/server/routes/store.ts b/src/server/routes/store.ts
index fa6a81db..0465e466 100644
--- a/src/server/routes/store.ts
+++ b/src/server/routes/store.ts
@@ -1,6 +1,9 @@
-import { Context, Hono } from "hono";
+import { Hono } from "hono";
import { existsSync } from "fs";
-import { validateSettingsToken } from "./settings-auth";
+import {
+ getSettingsTokenFromRequest,
+ validateSettingsToken,
+} from "./settings-auth";
import { resolve, relative } from "path";
import {
@@ -24,10 +27,6 @@ import { ExtensionStoreType } from "../types";
const router = new Hono();
-function getStoreToken(c: Context): string | undefined {
- return c.req.header("x-settings-token") ?? c.req.query("token");
-}
-
const VALID_TYPES: ExtensionStoreType[] = Object.values(ExtensionStoreType);
function isValidType(type: string): type is ExtensionStoreType {
@@ -35,14 +34,14 @@ function isValidType(type: string): type is ExtensionStoreType {
}
router.get("/api/store/repos", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const repos = await getRepos();
return c.json({ repos });
});
router.get("/api/store/repos/:repoSlug/asset", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const repoSlug = c.req.param("repoSlug");
const pathParam = c.req.query("path");
@@ -67,14 +66,14 @@ router.get("/api/store/repos/:repoSlug/asset", async (c) => {
});
router.get("/api/store/repos/status", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const statuses = await getReposStatus();
return c.json({ statuses });
});
router.post("/api/store/repos", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json<{ url?: string }>();
const url = body?.url?.trim();
@@ -89,7 +88,7 @@ router.post("/api/store/repos", async (c) => {
});
router.delete("/api/store/repos", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const body = (await c.req.json<{ url?: string }>().catch(() => ({}))) as {
url?: string;
@@ -107,7 +106,7 @@ router.delete("/api/store/repos", async (c) => {
});
router.post("/api/store/repos/refresh", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const body = (await c.req.json<{ url?: string }>().catch(() => ({}))) as {
url?: string;
@@ -127,14 +126,14 @@ router.post("/api/store/repos/refresh", async (c) => {
});
router.get("/api/store/items", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const items = await listRepoItems();
return c.json({ items });
});
router.get("/api/store/items/:repoSlug", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const repoSlug = c.req.param("repoSlug");
const repos = await getRepos();
@@ -145,7 +144,7 @@ router.get("/api/store/items/:repoSlug", async (c) => {
});
router.post("/api/store/install", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json<{
repoUrl?: string;
@@ -169,7 +168,7 @@ router.post("/api/store/install", async (c) => {
});
router.post("/api/store/uninstall", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json<{
repoUrl?: string;
@@ -193,7 +192,7 @@ router.post("/api/store/uninstall", async (c) => {
});
router.post("/api/store/update", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json<{
repoUrl?: string;
@@ -217,7 +216,7 @@ router.post("/api/store/update", async (c) => {
});
router.post("/api/store/update-all", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
try {
const result = await updateAllItems();
@@ -229,7 +228,7 @@ router.post("/api/store/update-all", async (c) => {
});
router.get("/api/store/installed", async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const installed = await getInstalledItems();
return c.json({ installed });
@@ -238,7 +237,7 @@ router.get("/api/store/installed", async (c) => {
router.get(
"/api/store/screenshots/:repoSlug/:type/:item/:filename",
async (c) => {
- if (!(await validateSettingsToken(getStoreToken(c))))
+ if (!(await validateSettingsToken(getSettingsTokenFromRequest(c))))
return c.json({ error: "Unauthorized" }, 401);
const repoSlug = c.req.param("repoSlug");
const typeParam = c.req.param("type");
diff --git a/src/server/routes/suggest.ts b/src/server/routes/suggest.ts
index 9f1837c1..6c509d29 100644
--- a/src/server/routes/suggest.ts
+++ b/src/server/routes/suggest.ts
@@ -1,5 +1,7 @@
import { Hono } from "hono";
import type { SuggestPostBody } from "../types/search";
+import { _applyRateLimit } from "../utils/search";
+import { guardApiKey } from "../utils/api-key-guard";
const router = new Hono();
@@ -40,16 +42,33 @@ async function getSuggestions(query: string): Promise {
}
router.get("/api/suggest", async (c) => {
+ const limitRes = await _applyRateLimit(c);
+ if (limitRes) return limitRes;
+ const authRes = await guardApiKey(c, "apiKeySuggestEnabled");
+ if (authRes) return authRes;
const query = c.req.query("q") ?? "";
return c.json(await getSuggestions(query));
});
router.post("/api/suggest", async (c) => {
- const { query } = await c.req.json();
- return c.json(await getSuggestions(query ?? ""));
+ const limitRes = await _applyRateLimit(c);
+ if (limitRes) return limitRes;
+ const authRes = await guardApiKey(c, "apiKeySuggestEnabled");
+ if (authRes) return authRes;
+ let body: SuggestPostBody;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
+ return c.json(await getSuggestions(body.query ?? ""));
});
router.get("/api/suggest/opensearch", async (c) => {
+ const limitRes = await _applyRateLimit(c);
+ if (limitRes) return limitRes;
+ const authRes = await guardApiKey(c, "apiKeySuggestEnabled");
+ if (authRes) return authRes;
const query = c.req.query("q") ?? "";
const suggestions = await getSuggestions(query);
return c.json([query, suggestions], 200, {
diff --git a/src/server/routes/themes.ts b/src/server/routes/themes.ts
index 839b5e6f..91b60024 100644
--- a/src/server/routes/themes.ts
+++ b/src/server/routes/themes.ts
@@ -5,7 +5,10 @@ import {
getActiveThemeId,
setActiveTheme,
} from "../extensions/themes/registry";
-import { getSettingsTokenFromRequest, validateSettingsToken } from "./settings-auth";
+import {
+ getSettingsTokenFromRequest,
+ validateSettingsToken,
+} from "./settings-auth";
const router = new Hono();
@@ -27,7 +30,12 @@ router.post("/api/theme/active", async (c) => {
const token = getSettingsTokenFromRequest(c);
if (!(await validateSettingsToken(token)))
return c.json({ error: "Unauthorized" }, 401);
- const body = await c.req.json<{ id: string | null }>();
+ let body: { id: string | null };
+ try {
+ body = await c.req.json<{ id: string | null }>();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
const ok = await setActiveTheme(body.id ?? null);
if (!ok) return c.json({ error: "Theme not found" }, 400);
return c.json({ ok: true, activeId: body.id });
diff --git a/src/server/search.ts b/src/server/search.ts
index ace51125..fd6060ed 100644
--- a/src/server/search.ts
+++ b/src/server/search.ts
@@ -19,7 +19,9 @@ import type {
} from "./types";
import { extractImageUrl } from "./utils/extract-image";
import { outgoingFetch, parseOutgoingTransport } from "./utils/outgoing";
+import { stripHtml } from "./utils/text";
import { asString, getSettings } from "./utils/plugin-settings";
+import { buildSignedProxyUrl } from "./utils/proxy-sign";
const MAX_PAGE = 10;
const ENGINE_TIMEOUT_MS = 10_000;
@@ -99,8 +101,9 @@ const _mergeIntoMap = (
if (!existing.sources.includes(r.source)) {
existing.sources.push(r.source);
}
- if (r.snippet.length > existing.snippet.length) {
- existing.snippet = r.snippet;
+ const cleanSnippet = stripHtml(r.snippet);
+ if (cleanSnippet.length > existing.snippet.length) {
+ existing.snippet = cleanSnippet;
}
if (r.thumbnail && !existing.thumbnail) {
existing.thumbnail = r.thumbnail;
@@ -109,6 +112,8 @@ const _mergeIntoMap = (
} else {
urlMap.set(normalized, {
...r,
+ title: stripHtml(r.title),
+ snippet: stripHtml(r.snippet),
url: normalized,
score: positionScore,
sources: [r.source],
@@ -237,6 +242,7 @@ export const createSearchEngineContext = (
dateTo: dateTo || undefined,
buildAcceptLanguage: () => _buildAcceptLanguage(resolvedLang),
extractImageUrl: extractImageUrl as EngineContext["extractImageUrl"],
+ signProxyUrl: buildSignedProxyUrl,
};
};
@@ -302,10 +308,10 @@ export const search = async (
type === "web"
? await getActiveWebEngines(config)
: getEnginesForSearchType(type, config).map((e) => ({
- id: e.id,
- instance: e.instance,
- score: 1,
- }));
+ id: e.id,
+ instance: e.instance,
+ score: 1,
+ }));
if (rawActiveEngines.length === 0) {
return {
diff --git a/src/server/types/extension.ts b/src/server/types/extension.ts
index 87cf34db..deb672ba 100644
--- a/src/server/types/extension.ts
+++ b/src/server/types/extension.ts
@@ -1,3 +1,4 @@
+import type { CreateCache } from "../utils/cache";
import type {
SearchResult,
ScoredResult,
@@ -71,12 +72,16 @@ export interface ExtensionMeta {
extensionDocsAvailable?: boolean;
defaultEnabled?: boolean;
defaultFeedUrls?: string[];
+ requiresNewerVersion?: boolean;
}
export interface PluginContext {
dir: string;
template: string;
readFile: (filename: string) => Promise;
+ signProxyUrl: (url: string) => string;
+ fetch?: (url: string, init?: RequestInit) => Promise;
+ createCache: CreateCache;
}
export interface SearchEngine {
@@ -116,6 +121,8 @@ export interface SlotPluginContext {
clientIp?: string;
results?: ScoredResult[];
fetch?: (url: string, init?: RequestInit) => Promise;
+ signProxyUrl?: (url: string) => string;
+ createCache: CreateCache;
}
export interface SlotPlugin {
@@ -148,6 +155,7 @@ export interface CommandResult {
export interface CommandContext {
clientIp?: string;
page?: number;
+ signProxyUrl?: (url: string) => string;
}
export interface BangCommand {
diff --git a/src/server/types/search.ts b/src/server/types/search.ts
index 0db1283d..3644abcf 100644
--- a/src/server/types/search.ts
+++ b/src/server/types/search.ts
@@ -83,6 +83,7 @@ export interface EngineContext {
baseUrl?: string,
selectors?: string[],
) => string;
+ signProxyUrl?: (url: string) => string;
}
export interface SearchResponse {
diff --git a/src/server/types/store.ts b/src/server/types/store.ts
index aab09291..9584d99b 100644
--- a/src/server/types/store.ts
+++ b/src/server/types/store.ts
@@ -31,6 +31,8 @@ export interface StoreItem {
updateAvailable?: boolean;
pluginType?: string;
engineType?: string;
+ minDegoogVersion?: string;
+ requiresNewerVersion?: boolean;
}
export interface InstalledItem {
@@ -40,6 +42,7 @@ export interface InstalledItem {
installedAs: string;
installedAt: string;
version: string;
+ minDegoogVersion?: string;
}
export interface ReposData {
@@ -58,6 +61,7 @@ export interface RepoPackageJson {
version?: string;
type?: string;
dependencies?: string[];
+ minDegoogVersion?: string;
}>;
themes?: Array<{
path: string;
@@ -65,6 +69,7 @@ export interface RepoPackageJson {
description?: string;
version?: string;
dependencies?: string[];
+ minDegoogVersion?: string;
}>;
engines?: Array<{
path: string;
@@ -73,6 +78,7 @@ export interface RepoPackageJson {
version?: string;
type?: string;
dependencies?: string[];
+ minDegoogVersion?: string;
}>;
transports?: Array<{
path: string;
@@ -80,6 +86,7 @@ export interface RepoPackageJson {
description?: string;
version?: string;
dependencies?: string[];
+ minDegoogVersion?: string;
}>;
"repo-image"?: string;
}
diff --git a/src/server/utils/api-key-guard.ts b/src/server/utils/api-key-guard.ts
new file mode 100644
index 00000000..42fb7ce1
--- /dev/null
+++ b/src/server/utils/api-key-guard.ts
@@ -0,0 +1,28 @@
+import type { Context } from "hono";
+import { asString, getSettings } from "./plugin-settings";
+import { DEGOOG_SETTINGS_ID } from "./search";
+import { verifySearchNonce } from "./search-nonce";
+import { verifyServerKeyHex } from "./server-key";
+
+const _verifyNonce = (c: Context): boolean => {
+ const n = c.req.header("x-search-nonce") ?? c.req.query("searchNonce") ?? "";
+ const s = c.req.header("x-search-sig") ?? c.req.query("searchSig") ?? "";
+ return !!n && !!s && verifySearchNonce(n, s);
+};
+
+const _bearerMatches = (c: Context): boolean => {
+ const raw = c.req.header("Authorization") ?? c.req.header("authorization") ?? "";
+ const m = /^Bearer\s+(\S+)/i.exec(raw.trim());
+ if (!m) return false;
+ return verifyServerKeyHex(m[1]);
+};
+
+export async function guardApiKey(
+ c: Context,
+ settingKey: string,
+): Promise {
+ const settings = await getSettings(DEGOOG_SETTINGS_ID);
+ if (asString(settings[settingKey]) !== "true") return null;
+ if (_verifyNonce(c) || _bearerMatches(c)) return null;
+ return c.json({ error: "Unauthorized" }, 401);
+}
diff --git a/src/server/utils/base-url.ts b/src/server/utils/base-url.ts
new file mode 100644
index 00000000..bb3c2d72
--- /dev/null
+++ b/src/server/utils/base-url.ts
@@ -0,0 +1,16 @@
+const _baseUrl = (process.env.DEGOOG_BASE_URL ?? "").trim().replace(/\/+$/, "");
+
+const _basePath = (() => {
+ if (!_baseUrl) return "";
+ if (!/^https?:\/\//i.test(_baseUrl)) return _baseUrl;
+ try {
+ const u = new URL(_baseUrl);
+ const p = u.pathname.replace(/\/+$/, "");
+ return p === "/" ? "" : p;
+ } catch {
+ return _baseUrl;
+ }
+})();
+
+export const getBaseUrl = (): string => _baseUrl;
+export const getBasePath = (): string => _basePath;
diff --git a/src/server/utils/cache.ts b/src/server/utils/cache.ts
index 27939ef1..66d73d4a 100644
--- a/src/server/utils/cache.ts
+++ b/src/server/utils/cache.ts
@@ -1,33 +1,45 @@
import type { SearchResponse } from "../types";
-const TTL_MS = 12 * 60 * 60 * 1000;
-const SHORT_TTL_MS = 2 * 60 * 1000;
-const NEWS_TTL_MS = 30 * 60 * 1000;
-const store = new Map();
+export const TTL_MS = 12 * 60 * 60 * 1000;
+export const SHORT_TTL_MS = 2 * 60 * 1000;
+export const NEWS_TTL_MS = 30 * 60 * 1000;
-export function get(key: string): SearchResponse | null {
- const entry = store.get(key);
- if (!entry || Date.now() > entry.expiresAt) {
- if (entry) store.delete(key);
- return null;
- }
- return entry.value;
-}
+export type TtlCache = {
+ get(key: string): T | null;
+ set(key: string, value: T, ttlMs?: number): void;
+ clear(): void;
+};
-export function set(
- key: string,
- value: SearchResponse,
- ttlMs: number = TTL_MS,
-): void {
- store.set(key, { value, expiresAt: Date.now() + ttlMs });
+export function createCache(defaultTtlMs: number): TtlCache {
+ const store = new Map();
+ return {
+ get(key: string): T | null {
+ const entry = store.get(key);
+ if (!entry) return null;
+ if (Date.now() > entry.expiresAt) {
+ store.delete(key);
+ return null;
+ }
+ return entry.value;
+ },
+ set(key: string, value: T, ttlMs: number = defaultTtlMs): void {
+ store.set(key, { value, expiresAt: Date.now() + ttlMs });
+ },
+ clear(): void {
+ store.clear();
+ },
+ };
}
-export function hasFailedEngines(response: SearchResponse): boolean {
- return response.engineTimings.some((et) => et.resultCount === 0);
-}
+export type CreateCache = typeof createCache;
-export { TTL_MS, SHORT_TTL_MS, NEWS_TTL_MS };
+const _searchCache = createCache(TTL_MS);
-export function clear(): void {
- store.clear();
+export const get = (key: string): SearchResponse | null => _searchCache.get(key);
+export const set = (key: string, value: SearchResponse, ttlMs: number = TTL_MS): void =>
+ _searchCache.set(key, value, ttlMs);
+export const clear = (): void => _searchCache.clear();
+
+export function hasFailedEngines(response: SearchResponse): boolean {
+ return response.engineTimings.some((et) => et.resultCount === 0);
}
diff --git a/src/server/utils/extension-docs.ts b/src/server/utils/extension-docs.ts
index 99ae5a4e..67edf742 100644
--- a/src/server/utils/extension-docs.ts
+++ b/src/server/utils/extension-docs.ts
@@ -12,11 +12,13 @@ const _destDirFromId = (id: string): string | null => {
return null;
};
+const _SAFE_FOLDER = /^[A-Za-z0-9._-]+$/;
+
export const getExtensionReadmePath = (id: string): string | null => {
const base = _destDirFromId(id);
if (!base) return null;
const folder = id.replace(/^[a-z-]+-/, "");
- if (!folder.trim()) return null;
+ if (!folder.trim() || !_SAFE_FOLDER.test(folder)) return null;
return join(base, folder, "README.md");
};
diff --git a/src/server/utils/http-proxy-fetch.ts b/src/server/utils/http-proxy-fetch.ts
index befd5f62..09fadb6e 100644
--- a/src/server/utils/http-proxy-fetch.ts
+++ b/src/server/utils/http-proxy-fetch.ts
@@ -6,9 +6,24 @@ import type { TransportFetchOptions as OutgoingFetchOptions } from "../types";
const MAX_REDIRECTS = 5;
const CONNECT_TIMEOUT_MS = 8_000;
-function parseProxyUrl(proxyUrl: string): { host: string; port: number } {
+function parseProxyUrl(proxyUrl: string): {
+ host: string;
+ port: number;
+ auth: string | undefined;
+} {
const url = new URL(proxyUrl);
- return { host: url.hostname, port: Number(url.port) || 8080 };
+ const username = decodeURIComponent(url.username);
+ const password = decodeURIComponent(url.password);
+ const auth =
+ username || password
+ ? `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
+ : undefined;
+
+ return {
+ host: url.hostname,
+ port: Number(url.port) || (url.protocol === "http:" ? 80 : 443),
+ auth,
+ };
}
const _openConnectTunnel = (
@@ -16,6 +31,7 @@ const _openConnectTunnel = (
proxyPort: number,
targetHost: string,
targetPort: number,
+ proxyAuth: string | undefined,
timeoutMs: number = CONNECT_TIMEOUT_MS,
): Promise =>
new Promise((resolve, reject) => {
@@ -26,8 +42,9 @@ const _openConnectTunnel = (
}, timeoutMs);
sock.once("connect", () => {
+ const authHeader = proxyAuth ? `Proxy-Authorization: ${proxyAuth}\r\n` : "";
sock.write(
- `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n\r\n`,
+ `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`,
);
});
@@ -59,6 +76,7 @@ const _openConnectTunnel = (
async function _openProxySocket(
proxyHost: string,
proxyPort: number,
+ proxyAuth: string | undefined,
targetHost: string,
targetPort: number,
useTls: boolean,
@@ -69,6 +87,7 @@ async function _openProxySocket(
proxyPort,
targetHost,
targetPort,
+ proxyAuth,
timeoutMs,
);
if (!useTls) return sock;
@@ -174,7 +193,8 @@ export async function fetchViaHttpProxy(
options: OutgoingFetchOptions = {},
timeoutMs?: number,
): Promise {
- const { host: proxyHost, port: proxyPort } = parseProxyUrl(proxyUrl);
+ const { host: proxyHost, port: proxyPort, auth: proxyAuth } =
+ parseProxyUrl(proxyUrl);
const followRedirects = (options.redirect ?? "follow") !== "manual";
const method = options.method ?? "GET";
@@ -189,6 +209,7 @@ export async function fetchViaHttpProxy(
const sock = await _openProxySocket(
proxyHost,
proxyPort,
+ proxyAuth,
parsed.hostname,
port,
useTls,
diff --git a/src/server/utils/paths.ts b/src/server/utils/paths.ts
index b29f7add..d915d2ef 100644
--- a/src/server/utils/paths.ts
+++ b/src/server/utils/paths.ts
@@ -23,3 +23,6 @@ export const pluginSettingsFile = (): string =>
export const defaultEnginesFile = (): string =>
process.env.DEGOOG_DEFAULT_ENGINES_FILE ?? join(_dataDir(), "default-engines.json");
+
+export const settingsTokensFile = (): string =>
+ process.env.DEGOOG_SETTINGS_TOKENS_FILE ?? join(_dataDir(), "settings-tokens.json");
diff --git a/src/server/utils/plugin-assets.ts b/src/server/utils/plugin-assets.ts
index b1ab13e3..fdc61029 100644
--- a/src/server/utils/plugin-assets.ts
+++ b/src/server/utils/plugin-assets.ts
@@ -68,6 +68,9 @@ export function getScriptFolderSource(
import { join } from "path";
import type { PluginContext, SettingField } from "../types";
+import { createCache } from "./cache";
+import { outgoingFetch } from "./outgoing";
+import { buildSignedProxyUrl } from "./proxy-sign";
import {
getSettings,
mergeDefaults,
@@ -113,6 +116,9 @@ export async function initPlugin(
template,
readFile: (filename: string) =>
readFile(join(entryPath, filename), "utf-8"),
+ signProxyUrl: buildSignedProxyUrl,
+ fetch: outgoingFetch as PluginContext["fetch"],
+ createCache,
};
await Promise.resolve(plugin.init(ctx));
}
diff --git a/src/server/utils/proxy-sign.ts b/src/server/utils/proxy-sign.ts
new file mode 100644
index 00000000..a3fdb935
--- /dev/null
+++ b/src/server/utils/proxy-sign.ts
@@ -0,0 +1,18 @@
+import type { ScoredResult } from "../types";
+import { signData, verifyData } from "./server-key";
+
+export const buildSignedProxyUrl = (url: string): string => {
+ const sig = signData(url);
+ return `/api/proxy/image?url=${encodeURIComponent(url)}&sig=${sig}`;
+};
+
+export const verifyProxyUrl = (url: string, sig: string): boolean =>
+ verifyData(url, sig);
+
+export function signResultThumbnails(results: ScoredResult[]): ScoredResult[] {
+ return results.map((r) => ({
+ ...r,
+ ...(r.thumbnail ? { thumbnail: buildSignedProxyUrl(r.thumbnail) } : {}),
+ ...(r.imageUrl ? { imageUrl: buildSignedProxyUrl(r.imageUrl) } : {}),
+ }));
+}
diff --git a/src/server/utils/search-nonce.ts b/src/server/utils/search-nonce.ts
new file mode 100644
index 00000000..6b3275cf
--- /dev/null
+++ b/src/server/utils/search-nonce.ts
@@ -0,0 +1,17 @@
+import { randomBytes } from "crypto";
+import { signData, verifyData } from "./server-key";
+
+const NONCE_TTL_MS = 60 * 60 * 1000;
+
+export const generateSearchNonce = (): { n: string; s: string } => {
+ const ts = Date.now().toString(16).padStart(12, "0");
+ const rand = randomBytes(16).toString("hex");
+ const n = ts + rand;
+ return { n, s: signData(n) };
+};
+
+export const verifySearchNonce = (n: string, s: string): boolean => {
+ if (!verifyData(n, s)) return false;
+ const ts = parseInt(n.slice(0, 12), 16);
+ return !isNaN(ts) && Date.now() - ts < NONCE_TTL_MS;
+};
diff --git a/src/server/utils/search.ts b/src/server/utils/search.ts
index d9c8fd80..41a07474 100644
--- a/src/server/utils/search.ts
+++ b/src/server/utils/search.ts
@@ -11,10 +11,12 @@ import {
SlotPluginContext,
TimeFilter,
} from "../types";
+import { createCache } from "./cache";
import { logger } from "./logger";
import { outgoingFetch } from "./outgoing";
import { asString, getSettings, isDisabled } from "./plugin-settings";
import { checkRateLimit } from "./rate-limit";
+import { buildSignedProxyUrl } from "./proxy-sign";
import { getClientIp } from "./request";
import { injectScope, translateHTML } from "./translation";
@@ -182,6 +184,8 @@ export async function runSlotPlugins(
clientIp,
results: plugin.waitForResults ? results : undefined,
fetch: outgoingFetch as SlotPluginContext["fetch"],
+ signProxyUrl: buildSignedProxyUrl,
+ createCache,
};
const t0 = performance.now();
const out = await plugin.execute(query, context);
@@ -200,7 +204,7 @@ export async function runSlotPlugins(
position: effectivePosition,
gridSize: plugin.gridSize,
});
- } catch {}
+ } catch { }
}
return panels;
}
diff --git a/src/server/utils/server-key.ts b/src/server/utils/server-key.ts
new file mode 100644
index 00000000..92dcd621
--- /dev/null
+++ b/src/server/utils/server-key.ts
@@ -0,0 +1,57 @@
+import { createHmac, randomBytes, timingSafeEqual } from "crypto";
+import { getSettings, setSettings } from "./plugin-settings";
+
+const SETTINGS_ID = "degoog-api-secret";
+const KEY_FIELD = "key";
+
+let _key: Buffer | null = null;
+
+export async function initServerKey(): Promise {
+ const stored = await getSettings(SETTINGS_ID);
+ const existing = stored[KEY_FIELD];
+ if (typeof existing === "string" && existing.length === 64) {
+ _key = Buffer.from(existing, "hex");
+ return;
+ }
+ const generated = randomBytes(32);
+ await setSettings(SETTINGS_ID, { [KEY_FIELD]: generated.toString("hex") });
+ _key = generated;
+}
+
+export function signData(data: string): string {
+ if (!_key) throw new Error("Server key not initialized");
+ return createHmac("sha256", _key).update(data).digest("hex");
+}
+
+export const getServerKeyHex = (): string | null =>
+ _key ? _key.toString("hex") : null;
+
+export function verifyServerKeyHex(provided: string): boolean {
+ if (!_key || provided.length !== 64) return false;
+ if (!/^[0-9a-fA-F]{64}$/.test(provided)) return false;
+ try {
+ const a = Buffer.from(provided, "hex");
+ if (a.length !== _key.length) return false;
+ return timingSafeEqual(a, _key);
+ } catch {
+ return false;
+ }
+}
+
+export async function regenerateServerKey(): Promise {
+ const generated = randomBytes(32);
+ await setSettings(SETTINGS_ID, { [KEY_FIELD]: generated.toString("hex") });
+ _key = generated;
+}
+
+export function verifyData(data: string, sig: string): boolean {
+ if (!_key) return false;
+ try {
+ const expected = Buffer.from(signData(data), "hex");
+ const provided = Buffer.from(sig, "hex");
+ if (expected.length !== provided.length) return false;
+ return timingSafeEqual(expected, provided);
+ } catch {
+ return false;
+ }
+}
diff --git a/src/server/utils/settings-tokens.ts b/src/server/utils/settings-tokens.ts
new file mode 100644
index 00000000..ee1eaa38
--- /dev/null
+++ b/src/server/utils/settings-tokens.ts
@@ -0,0 +1,141 @@
+import { readFile, writeFile, rename } from "fs/promises";
+import { timingSafeEqual } from "crypto";
+import { logger } from "./logger";
+import { settingsTokensFile } from "./paths";
+
+export const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
+
+const _validTokens = new Map();
+
+let _persistTimer: ReturnType | null = null;
+let _persisting = false;
+
+const _persistNow = async (): Promise => {
+ if (_persisting) {
+ if (!_persistTimer) schedulePersist();
+ return;
+ }
+ _persisting = true;
+ const file = settingsTokensFile();
+ const tmp = `${file}.tmp`;
+ try {
+ const snapshot = Object.fromEntries(_validTokens);
+ await writeFile(tmp, JSON.stringify(snapshot), "utf-8");
+ await rename(tmp, file);
+ } catch (e) {
+ logger.warn(
+ "settings-auth",
+ `failed to persist tokens: ${e instanceof Error ? e.message : String(e)}`,
+ );
+ } finally {
+ _persisting = false;
+ }
+};
+
+export const schedulePersist = (): void => {
+ if (_persistTimer) return;
+ _persistTimer = setTimeout(() => {
+ _persistTimer = null;
+ void _persistNow();
+ }, 200);
+};
+
+const _loadPersistedTokens = async (): Promise => {
+ try {
+ const raw = await readFile(settingsTokensFile(), "utf-8");
+ const data = JSON.parse(raw) as Record;
+ const now = Date.now();
+ let loaded = 0;
+ for (const [token, expiresAt] of Object.entries(data)) {
+ if (typeof expiresAt === "number" && expiresAt > now) {
+ _validTokens.set(token, expiresAt);
+ loaded++;
+ }
+ }
+ if (loaded > 0) {
+ logger.debug("settings-auth", `restored ${loaded} persisted token(s)`);
+ }
+ } catch {}
+};
+
+void _loadPersistedTokens();
+
+export const tokenStore = {
+ get: (token: string): number | undefined => _validTokens.get(token),
+ set: (token: string, expiresAt: number): void => {
+ _validTokens.set(token, expiresAt);
+ schedulePersist();
+ },
+ delete: (token: string): void => {
+ if (_validTokens.delete(token)) schedulePersist();
+ },
+ size: (): number => _validTokens.size,
+ pruneExpired: (): void => {
+ const now = Date.now();
+ let pruned = 0;
+ for (const [token, expiresAt] of _validTokens) {
+ if (now > expiresAt) {
+ _validTokens.delete(token);
+ pruned++;
+ }
+ }
+ if (pruned > 0) schedulePersist();
+ },
+};
+
+export const generateSettingsToken = (): string => {
+ const bytes = new Uint8Array(32);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+};
+
+const _safeEqual = (a: string, b: string): boolean => {
+ const aBuf = Buffer.from(a);
+ const bBuf = Buffer.from(b);
+ if (aBuf.length !== bBuf.length) {
+ timingSafeEqual(aBuf, aBuf);
+ return false;
+ }
+ return timingSafeEqual(aBuf, bBuf);
+};
+
+export const passwordMatches = (
+ candidate: string,
+ allowed: string[],
+): boolean => {
+ let matched = false;
+ for (const p of allowed) {
+ if (_safeEqual(candidate, p)) matched = true;
+ }
+ return matched;
+};
+
+const AUTH_RATE_WINDOW_MS = 60_000;
+const AUTH_RATE_MAX_FAILURES = 10;
+const _authAttempts = new Map();
+
+export const checkAuthRate = (
+ ip: string,
+): { allowed: boolean; retryAfter: number } => {
+ const now = Date.now();
+ const cutoff = now - AUTH_RATE_WINDOW_MS;
+ const attempts = (_authAttempts.get(ip) ?? []).filter((t) => t >= cutoff);
+ _authAttempts.set(ip, attempts);
+ if (attempts.length >= AUTH_RATE_MAX_FAILURES) {
+ const retryAfter = Math.ceil(
+ (attempts[0] + AUTH_RATE_WINDOW_MS - now) / 1000,
+ );
+ return { allowed: false, retryAfter: Math.max(1, retryAfter) };
+ }
+ return { allowed: true, retryAfter: 0 };
+};
+
+export const recordAuthFailure = (ip: string): void => {
+ const now = Date.now();
+ const cutoff = now - AUTH_RATE_WINDOW_MS;
+ const attempts = (_authAttempts.get(ip) ?? []).filter((t) => t >= cutoff);
+ attempts.push(now);
+ _authAttempts.set(ip, attempts);
+};
diff --git a/src/server/utils/text.ts b/src/server/utils/text.ts
new file mode 100644
index 00000000..e0e77949
--- /dev/null
+++ b/src/server/utils/text.ts
@@ -0,0 +1,20 @@
+export const stripHtml = (text: string): string =>
+ text.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
+
+const _DATE_PREFIX =
+ /^(?:\d{1,2}\s+)?(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{4}|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago/i;
+
+export const stripSnippetPrefix = (text: string): string => {
+ const stripped = text.replace(
+ new RegExp(`^(?:${_DATE_PREFIX.source})\\s*[-–—·]\\s*`, "i"),
+ "",
+ );
+ return stripped || text;
+};
+
+export const looksLikeProse = (text: string): boolean => {
+ if (/\{[^}]{0,500}\}/.test(text)) return false;
+ const specialChars = (text.match(/[^a-zA-Z0-9\s.,!?'"()\-–—]/g) ?? []).length;
+ const words = text.split(/\s+/).filter(Boolean);
+ return specialChars / text.length < 0.10 && words.length >= 8;
+};
diff --git a/src/server/utils/version.ts b/src/server/utils/version.ts
new file mode 100644
index 00000000..efcad845
--- /dev/null
+++ b/src/server/utils/version.ts
@@ -0,0 +1,17 @@
+import appPkg from "../../../package.json";
+
+const _parseSemver = (v: string): [number, number, number] => {
+ const clean = v.split("-")[0] ?? "";
+ const parts = clean.split(".").map(Number);
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
+};
+
+export const getAppVersion = (): string => appPkg.version.split("-")[0] ?? "";
+
+export const isVersionAtLeast = (current: string, required: string): boolean => {
+ const [cMaj, cMin, cPatch] = _parseSemver(current);
+ const [rMaj, rMin, rPatch] = _parseSemver(required);
+ if (cMaj !== rMaj) return cMaj > rMaj;
+ if (cMin !== rMin) return cMin > rMin;
+ return cPatch >= rPatch;
+};
diff --git a/src/styles/components/_extension-cards.scss b/src/styles/components/_extension-cards.scss
index f3d2d447..e0607f6c 100644
--- a/src/styles/components/_extension-cards.scss
+++ b/src/styles/components/_extension-cards.scss
@@ -445,6 +445,11 @@ select.ext-field-input.ext-field-select {
}
}
+.ext-version-warning {
+ font-size: $font-size-small;
+ color: $warning;
+}
+
.ext-configured-badge,
.ext-needs-config-badge {
align-items: center;
diff --git a/src/styles/components/_glance.scss b/src/styles/components/_glance.scss
index af4e61a4..9e2cb189 100644
--- a/src/styles/components/_glance.scss
+++ b/src/styles/components/_glance.scss
@@ -6,8 +6,6 @@
background: var(--bg-light);
border-radius: $radius-lg;
max-width: $results-max-width;
- box-shadow: $box-shadow;
- border: 1px solid transparent;
}
.glance-snippet {
diff --git a/src/styles/components/_logo.scss b/src/styles/components/_logo.scss
index 63e87cf8..a5b457e9 100644
--- a/src/styles/components/_logo.scss
+++ b/src/styles/components/_logo.scss
@@ -20,7 +20,7 @@
&-d { color: var(--brand-blue); }
&-e { color: $danger; }
- &-g1 { color: $warning; }
+ &-g1 { color: var(--brand-yellow); }
&-o1 { color: var(--brand-blue); }
&-o2 { color: $success; }
&-g2 { color: $danger; }
diff --git a/src/styles/components/_settings.scss b/src/styles/components/_settings.scss
index 4570e3f0..887c0167 100644
--- a/src/styles/components/_settings.scss
+++ b/src/styles/components/_settings.scss
@@ -10,7 +10,7 @@
min-height: 100vh;
@media (min-width: 768px) {
- max-width: 60rem;
+ max-width: 70rem;
}
}
@@ -54,8 +54,50 @@
}
}
-.settings-nav-mobile {
+.settings-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: $space-1;
margin-bottom: $space-6;
+
+ @media (min-width: 768px) {
+ width: 14rem;
+ flex-shrink: 0;
+ align-self: flex-start;
+ position: sticky;
+ top: $space-8;
+ margin-bottom: 0;
+ }
+}
+
+.settings-nav-search {
+ display: flex;
+ align-items: center;
+ gap: $space-2;
+ background: var(--bg-hover);
+ box-shadow: $box-shadow;
+ border: 1px solid transparent;
+ border-radius: $radius-lg;
+ padding: $space-3;
+ color: var(--text-secondary);
+
+ input {
+ background: none;
+ border: none;
+ outline: none;
+ flex: 1;
+ font-size: $font-size-base;
+ color: var(--text-primary);
+ min-width: 0;
+
+ &::placeholder {
+ color: var(--text-secondary);
+ }
+ }
+}
+
+
+.settings-nav-mobile {
position: relative;
&:after {
@@ -103,12 +145,7 @@
@media (min-width: 768px) {
display: flex;
flex-direction: column;
- flex-shrink: 0;
- width: 11.25rem;
gap: 2px;
- position: sticky;
- top: $space-8;
- align-self: flex-start;
}
}
@@ -149,6 +186,18 @@
}
}
+.settings-search-active .settings-tab-panel {
+ display: block;
+}
+
+.settings-search-active .settings-page-actions {
+ display: none;
+}
+
+.settings-search-active.settings-search-empty .settings-tab-panel {
+ display: none;
+}
+
.settings-page-main {
display: flex;
flex-direction: column;
@@ -339,6 +388,14 @@
flex: 1;
}
+#settings-api-key-value {
+ overflow-x: auto;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
.settings-proxy-urls-wrap {
margin-top: 0;
}
@@ -576,7 +633,7 @@
.settings-auth-submit {
background: $primary;
- color: #fff;
+ color: var(--white);
border: none;
border-radius: $radius-xl;
padding: $space-4 $space-6;
@@ -591,7 +648,7 @@
.settings-save {
background: $primary;
- color: #fff;
+ color: var(--white);
border: none;
border-radius: $radius-sm;
padding: $space-2 $space-6;
diff --git a/src/styles/components/_store.scss b/src/styles/components/_store.scss
index 04874e33..c77c8127 100644
--- a/src/styles/components/_store.scss
+++ b/src/styles/components/_store.scss
@@ -272,8 +272,8 @@
-webkit-appearance: none;
flex: 1;
min-width: 0;
- background: var(--search-bar-bg);
- border-radius: $search-bar-radius;
+ background: var(--bg-hover);
+ border-radius: $radius-lg;
color: var(--text-primary);
font-size: $font-size-base;
padding: $space-3;
@@ -400,12 +400,10 @@
color: var(--text-secondary);
line-height: 1.45;
display: -webkit-box;
- line-clamp: 3;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
flex: 1;
min-height: 0;
+ max-height: 100px;
+ overflow-y: auto;
}
.store-card-version {
@@ -419,6 +417,12 @@
opacity: 0.6;
}
+.store-card-version-warning {
+ font-size: $font-size-xs;
+ color: $warning;
+ margin-top: 2px;
+}
+
.store-card-footer {
display: flex;
align-items: center;
diff --git a/src/styles/style.scss b/src/styles/style.scss
index 909cd44b..c3c8b493 100644
--- a/src/styles/style.scss
+++ b/src/styles/style.scss
@@ -40,8 +40,9 @@
--primary-hover: #3b547c;
--primary-rgb: 66, 133, 244;
--brand-blue: #4285f4;
+ --brand-yellow: #fbbc05;
--danger: #ea4335;
- --warning: #fbbc05;
+ --warning: #c17d00;
--success: #34a853;
--bg: #f1f1f1;
--bg-light: #fafafa;
@@ -67,6 +68,7 @@
--primary-hover: #3b547c;
--primary-rgb: 66, 133, 244;
--brand-blue: #4285f4;
+ --brand-yellow: #fbbc05;
--danger: #ea4335;
--warning: #fbbc05;
--success: #34a853;
diff --git a/tests/engines/registry.test.ts b/tests/engines/registry.test.ts
index 840dcfde..2cdb1160 100644
--- a/tests/engines/registry.test.ts
+++ b/tests/engines/registry.test.ts
@@ -50,9 +50,9 @@ describe("engines registry", () => {
expect("duckduckgo" in config || "google" in config).toBe(true);
});
- test("getOutgoingAllowlist returns non-empty array", () => {
+ test("getOutgoingAllowlist returns deduped hostnames array", () => {
const list = getOutgoingAllowlist();
expect(Array.isArray(list)).toBe(true);
- expect(list.length).toBeGreaterThan(0);
+ expect(list).toEqual([...new Set(list)]);
});
});
diff --git a/tests/public/constants-state-timeFilter.test.ts b/tests/public/constants-state-timeFilter.test.ts
index c09de5d8..2b5a8c9a 100644
--- a/tests/public/constants-state-timeFilter.test.ts
+++ b/tests/public/constants-state-timeFilter.test.ts
@@ -8,7 +8,6 @@ import {
MAX_PAGE,
} from "../../src/client/constants";
import { state } from "../../src/client/state";
-import { initOptionsDropdown } from "../../src/client/utils/time-filter";
describe("public/constants", () => {
test("DB_NAME is string", () => {
@@ -38,9 +37,3 @@ describe("public/state", () => {
expect(state).toHaveProperty("currentTimeFilter", "any");
});
});
-
-describe("public/timeFilter", () => {
- test("initOptionsDropdown is function", () => {
- expect(typeof initOptionsDropdown).toBe("function");
- });
-});
diff --git a/tests/public/url.test.ts b/tests/public/url.test.ts
index 7f65797b..bb47dc74 100644
--- a/tests/public/url.test.ts
+++ b/tests/public/url.test.ts
@@ -1,8 +1,14 @@
-import { describe, test, expect } from "bun:test";
+import { describe, test, expect, beforeAll } from "bun:test";
import { buildSearchUrl, proxyImageUrl, faviconUrl } from "../../src/client/utils/url";
import { state } from "../../src/client/state";
describe("public/url", () => {
+ beforeAll(() => {
+ const g = globalThis as unknown as { window?: { __DEGOOG_BASE_URL__?: string } };
+ if (!g.window) g.window = {};
+ g.window.__DEGOOG_BASE_URL__ = "";
+ });
+
test("proxyImageUrl returns empty for empty url", () => {
expect(proxyImageUrl("")).toBe("");
});
@@ -19,7 +25,9 @@ describe("public/url", () => {
test("faviconUrl returns proxy path for valid url", () => {
const out = faviconUrl("https://example.com/page");
- expect(out).toContain("/api/proxy/image");
+ expect(out).toContain("/api/proxy/favicon");
+ expect(out).toContain("domain=");
+ expect(out).toContain("example.com");
});
test("buildSearchUrl includes query and engine params", () => {
diff --git a/tests/routes/gated-apis.test.ts b/tests/routes/gated-apis.test.ts
index b4ae8840..3074636b 100644
--- a/tests/routes/gated-apis.test.ts
+++ b/tests/routes/gated-apis.test.ts
@@ -7,14 +7,20 @@ type Router = {
const GATED_APIS: Array<{
method: "GET" | "POST" | "DELETE";
path: string;
- routerKey: "store" | "settings-auth" | "themes" | "extensions" | "pages";
+ routerKey:
+ | "store"
+ | "settings-auth"
+ | "settings"
+ | "themes"
+ | "extensions"
+ | "pages";
body?: string;
}> = [
- { method: "GET", path: "/api/settings/general", routerKey: "settings-auth" },
+ { method: "GET", path: "/api/settings/general", routerKey: "settings" },
{
method: "POST",
path: "/api/settings/general",
- routerKey: "settings-auth",
+ routerKey: "settings",
body: "{}",
},
{
@@ -83,17 +89,25 @@ let envRestore: string | undefined;
beforeAll(async () => {
envRestore = process.env.DEGOOG_PUBLIC_INSTANCE;
process.env.DEGOOG_PUBLIC_INSTANCE = "true";
- const [storeMod, settingsAuthMod, themesMod, extensionsMod, pagesMod] =
- await Promise.all([
- import("../../src/server/routes/store"),
- import("../../src/server/routes/settings-auth"),
- import("../../src/server/routes/themes"),
- import("../../src/server/routes/extensions"),
- import("../../src/server/routes/pages"),
- ]);
+ const [
+ storeMod,
+ settingsAuthMod,
+ settingsMod,
+ themesMod,
+ extensionsMod,
+ pagesMod,
+ ] = await Promise.all([
+ import("../../src/server/routes/store"),
+ import("../../src/server/routes/settings-auth"),
+ import("../../src/server/routes/settings"),
+ import("../../src/server/routes/themes"),
+ import("../../src/server/routes/extensions"),
+ import("../../src/server/routes/pages"),
+ ]);
routers = {
store: storeMod.default,
"settings-auth": settingsAuthMod.default,
+ settings: settingsMod.default,
themes: themesMod.default,
extensions: extensionsMod.default,
pages: pagesMod.default,
diff --git a/tests/routes/pages.test.ts b/tests/routes/pages.test.ts
index 15bd5bcc..cca14ccd 100644
--- a/tests/routes/pages.test.ts
+++ b/tests/routes/pages.test.ts
@@ -1,10 +1,12 @@
import { describe, test, expect, beforeAll } from "bun:test";
+import { initServerKey } from "../../src/server/utils/server-key";
let pagesRouter: {
request: (req: Request | string) => Response | Promise;
};
beforeAll(async () => {
+ await initServerKey();
const mod = await import("../../src/server/routes/pages");
pagesRouter = mod.default;
});
diff --git a/tests/routes/search.test.ts b/tests/routes/search.test.ts
index e465daa1..c9958d60 100644
--- a/tests/routes/search.test.ts
+++ b/tests/routes/search.test.ts
@@ -1,18 +1,24 @@
import { describe, test, expect, beforeAll } from "bun:test";
+import { getServerKeyHex, initServerKey } from "../../src/server/utils/server-key";
let searchRouter: {
request: (req: Request | string) => Response | Promise;
};
beforeAll(async () => {
+ await initServerKey();
const mod = await import("../../src/server/routes/search");
searchRouter = mod.default;
});
describe("routes/search", () => {
test("GET /api/search without q returns 400", async () => {
+ const key = getServerKeyHex();
+ if (!key) throw new Error("server key not loaded");
const res = await searchRouter.request(
- "http://localhost/api/search?google=true",
+ new Request("http://localhost/api/search?google=true", {
+ headers: { Authorization: `Bearer ${key}` },
+ }),
);
expect(res.status).toBe(400);
const body = await res.json();
diff --git a/tests/routes/suggest.test.ts b/tests/routes/suggest.test.ts
index cb7809c0..229adb57 100644
--- a/tests/routes/suggest.test.ts
+++ b/tests/routes/suggest.test.ts
@@ -1,18 +1,28 @@
import { describe, test, expect, beforeAll } from "bun:test";
+import { getServerKeyHex, initServerKey } from "../../src/server/utils/server-key";
let suggestRouter: {
request: (req: Request | string) => Response | Promise;
};
beforeAll(async () => {
+ await initServerKey();
const mod = await import("../../src/server/routes/suggest");
suggestRouter = mod.default;
});
+const _authHeaders = (): Record => {
+ const key = getServerKeyHex();
+ if (!key) throw new Error("server key not loaded");
+ return { Authorization: `Bearer ${key}` };
+};
+
describe("routes/suggest", () => {
test("GET /api/suggest returns 200 and array", async () => {
const res = await suggestRouter.request(
- "http://localhost/api/suggest?q=test",
+ new Request("http://localhost/api/suggest?q=test", {
+ headers: _authHeaders(),
+ }),
);
expect(res.status).toBe(200);
const body = await res.json();
@@ -21,7 +31,9 @@ describe("routes/suggest", () => {
test("GET /api/suggest/opensearch returns 200 and [query, suggestions]", async () => {
const res = await suggestRouter.request(
- "http://localhost/api/suggest/opensearch?q=foo",
+ new Request("http://localhost/api/suggest/opensearch?q=foo", {
+ headers: _authHeaders(),
+ }),
);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain(