diff --git a/README.md b/README.md index d5e9324..cd1a9d3 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,7 @@ Other keys share the namespace. They use uppercase prefixes, which can never col | `FREE:::` (e.g. `FREE:wikimedia:2:orange cat`) | Same as above | 90 days | | `AI:::` (e.g. `AI:auroraimg-6m:2:frog wizard`) | Same as above | 90 days | | `OPENVERSE_TOKEN` | Openverse access token | Token lifetime minus 10 min | +| `SURREAL_TOKEN` | SurrealDB access token, sent instead of the password, which SurrealDB takes ~50ms to check on every request | Token lifetime minus 10 min | An AI image served because a search found nothing is stored under the search's own key, like any other image. A search where that also fails, or a `free.` search with a `src` (which gets no AI image), stores `{"t":…,"err":true}` for 24h. diff --git a/functions/[[path]].js b/functions/[[path]].js index 0a84deb..4d33534 100644 --- a/functions/[[path]].js +++ b/functions/[[path]].js @@ -3,6 +3,7 @@ import { bingImageSearchFallback } from "./_utils/bing.js"; import { freeImageUrls, parseFreeSource, FREE_UA } from "./_utils/free.js"; import { isFlickrPlaceholder } from "./_utils/flickr.js"; import { aiImage, parseAiSource, DEFAULT_AI_SOURCE } from "./_utils/ai.js"; +import { surreal } from "./_utils/surreal.js"; import * as LIMITS from "../assets/rate-limit.js"; const TTL_SECONDS = 90 * 24 * 60 * 60; @@ -80,16 +81,7 @@ export async function onRequest(context) { const limit = free ? LIMITS.FREE_DAILY_LIMIT : ai ? LIMITS.AI_DAILY_LIMIT : LIMITS.DAILY_LIMIT; let count = 1, wait = false, locked = false, list = null; - const db = env.SURREAL_URL && env.SURREAL_USER && env.SURREAL_PASS && (body => fetch(`${env.SURREAL_URL}/sql`, { - method: "POST", - headers: { - "Accept": "application/json", - "Authorization": `Basic ${btoa(`${env.SURREAL_USER}:${env.SURREAL_PASS}`)}`, - "surreal-ns": "direct_img", - "surreal-db": "rate_limit", - }, - body - })); + const db = surreal(env); if (db) { // Counts the search and claims this query's lock in the same round trip, so concurrent misses (on any isolate or diff --git a/functions/_utils/surreal.js b/functions/_utils/surreal.js new file mode 100644 index 0000000..4ead6b8 --- /dev/null +++ b/functions/_utils/surreal.js @@ -0,0 +1,45 @@ +// Uppercase so it can't collide with query cache keys, which are always lowercased +const TOKEN_KEY = "SURREAL_TOKEN"; +// SurrealDB re-checks a password against its deliberately slow hash on every request (~47ms), but a token in ~1ms. A +// token lasts 60 min, so it's kept in this isolate's memory and in KV until 10 min before it expires. +let cached = null; + +// A token from memory, then KV, then a new /signin (fresh skips memory and KV). null if signing in fails. +async function getToken(env, fresh) { + if (!fresh && cached?.until > Date.now()) return cached.token; + let token = !fresh && await env.DIRECT_IMG_CACHE.get(TOKEN_KEY), signedIn = !token; + if (signedIn) { + const res = await fetch(`${env.SURREAL_URL}/signin`, { + method: "POST", + headers: { "Accept": "application/json" }, + body: JSON.stringify({ user: env.SURREAL_USER, pass: env.SURREAL_PASS }), + }); + token = res.ok && (await res.json()).token; + if (!token) return null; + } + const ttl = Math.floor(JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).exp - Date.now() / 1000) - 600; + if (signedIn && ttl >= 60) await env.DIRECT_IMG_CACHE.put(TOKEN_KEY, token, { expirationTtl: ttl }).catch(() => {}); + cached = ttl > 0 ? { token, until: Date.now() + ttl * 1000 } : null; + return token; +} + +// Runs SurrealQL in direct_img/rate_limit and resolves to the Response, or null if SurrealDB isn't configured. Uses the +// password only if no token can be had, so it still works, just slower. +export function surreal(env) { + if (!env.SURREAL_URL || !env.SURREAL_USER || !env.SURREAL_PASS) return null; + const send = (token, body) => fetch(`${env.SURREAL_URL}/sql`, { + method: "POST", + headers: { + "Accept": "application/json", + "Authorization": token ? `Bearer ${token}` : `Basic ${btoa(`${env.SURREAL_USER}:${env.SURREAL_PASS}`)}`, + "surreal-ns": "direct_img", + "surreal-db": "rate_limit", + }, + body + }); + return async body => { + const token = await getToken(env).catch(() => null), res = await send(token, body); + // A refused token (expired early or revoked) means the query never ran, so it's safe to retry with a new one + return res.status === 401 && token ? send(await getToken(env, true).catch(() => null), body) : res; + }; +}