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(