From 7b350f569b1172f0217b7c88c491ae0137772f77 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 25 Oct 2026 00:18:59 +0800 Subject: [PATCH] feat: add OAuth callback relay --- AGENTS.md | 2 +- public/_headers | 7 ++ public/oauth/anilist/callback/index.html | 33 ++++++ public/oauth/bangumi/callback/index.html | 33 ++++++ public/oauth/mal/callback/index.html | 33 ++++++ public/oauth/mangabaka/callback/index.html | 33 ++++++ public/oauth/relay.css | 122 +++++++++++++++++++++ public/oauth/relay.js | 86 +++++++++++++++ 8 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 public/_headers create mode 100644 public/oauth/anilist/callback/index.html create mode 100644 public/oauth/bangumi/callback/index.html create mode 100644 public/oauth/mal/callback/index.html create mode 100644 public/oauth/mangabaka/callback/index.html create mode 100644 public/oauth/relay.css create mode 100644 public/oauth/relay.js diff --git a/AGENTS.md b/AGENTS.md index e0a14e5..53892c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Read and follow [AGENT.md](./AGENT.md) — the canonical guide for the Nimbus ma pnpm typecheck && pnpm build && pnpm exec nimbus-docs check && pnpm lint:docs ``` -Then confirm `dist/` still serves every public route: `/`, `/server/`, `/reader/`, `/faq/`, `/compare/`, `/architecture/`, `/server/{intro,installation,configuration,webui,search,compatibility,enhancements,limitations,development,migration}/`, `/server/enhancements/{webhooks,history-events,epub2-metadata,natural-sort,komf,thumbnail-storage,komga-riir,next-previous,reading-stats,smart-lists,server-stats,update-check,sse-recovery}/`, `/reader/{intro,reading,offline,library,translation,settings,privacy}/`. +Then confirm `dist/` still serves every public route: `/`, `/server/`, `/reader/`, `/faq/`, `/compare/`, `/architecture/`, `/server/{intro,installation,configuration,webui,search,compatibility,enhancements,limitations,development,migration}/`, `/server/enhancements/{webhooks,history-events,epub2-metadata,natural-sort,komf,thumbnail-storage,komga-riir,next-previous,reading-stats,smart-lists,server-stats,update-check,sse-recovery}/`, `/reader/{intro,reading,offline,library,translation,settings,privacy}/`, `/oauth/{anilist,mal,bangumi,mangabaka}/callback/`. ## Content conventions diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..9fab789 --- /dev/null +++ b/public/_headers @@ -0,0 +1,7 @@ +/oauth/* + Cache-Control: no-store + Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' + Referrer-Policy: no-referrer + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + X-Robots-Tag: noindex, nofollow, noarchive diff --git a/public/oauth/anilist/callback/index.html b/public/oauth/anilist/callback/index.html new file mode 100644 index 0000000..6c03f79 --- /dev/null +++ b/public/oauth/anilist/callback/index.html @@ -0,0 +1,33 @@ + + + + + + + + AniList authorization · kmworks + + + + +
+

kmworks · OAuth relay

+

AniList authorization

+

+ Returning the authorization result to your KMServer instance. +

+
+ Processing authorization… +
+

+ You will be redirected automatically. +

+ +
+ + diff --git a/public/oauth/bangumi/callback/index.html b/public/oauth/bangumi/callback/index.html new file mode 100644 index 0000000..effbd7c --- /dev/null +++ b/public/oauth/bangumi/callback/index.html @@ -0,0 +1,33 @@ + + + + + + + + Bangumi authorization · kmworks + + + + +
+

kmworks · OAuth relay

+

Bangumi authorization

+

+ Returning the authorization result to your KMServer instance. +

+
+ Processing authorization… +
+

+ You will be redirected automatically. +

+ +
+ + diff --git a/public/oauth/mal/callback/index.html b/public/oauth/mal/callback/index.html new file mode 100644 index 0000000..a229d63 --- /dev/null +++ b/public/oauth/mal/callback/index.html @@ -0,0 +1,33 @@ + + + + + + + + MyAnimeList authorization · kmworks + + + + +
+

kmworks · OAuth relay

+

MyAnimeList authorization

+

+ Returning the authorization result to your KMServer instance. +

+
+ Processing authorization… +
+

+ You will be redirected automatically. +

+ +
+ + diff --git a/public/oauth/mangabaka/callback/index.html b/public/oauth/mangabaka/callback/index.html new file mode 100644 index 0000000..8a6060c --- /dev/null +++ b/public/oauth/mangabaka/callback/index.html @@ -0,0 +1,33 @@ + + + + + + + + MangaBaka authorization · kmworks + + + + +
+

kmworks · OAuth relay

+

MangaBaka authorization

+

+ Returning the authorization result to your KMServer instance. +

+
+ Processing authorization… +
+

+ You will be redirected automatically. +

+ +
+ + diff --git a/public/oauth/relay.css b/public/oauth/relay.css new file mode 100644 index 0000000..d0d364e --- /dev/null +++ b/public/oauth/relay.css @@ -0,0 +1,122 @@ +:root { + color-scheme: light dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + background: #f4f5f7; + color: #191c20; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + padding: 24px; + display: grid; + place-items: center; +} + +.relay-card { + width: min(100%, 32rem); + padding: 32px; + border: 1px solid #d9dde3; + border-radius: 16px; + background: #ffffff; + box-shadow: 0 16px 48px rgb(25 28 32 / 8%); +} + +.relay-kicker { + margin: 0 0 20px; + color: #59616d; + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(1.5rem, 5vw, 2rem); + line-height: 1.2; +} + +.relay-description, +.relay-detail, +.relay-noscript { + color: #59616d; + line-height: 1.6; +} + +.relay-description { + margin: 12px 0 24px; +} + +.relay-status { + padding: 12px 14px; + border-radius: 8px; + background: #edf1f5; + font-size: 0.9rem; + font-weight: 650; +} + +.relay-status.is-success { + background: #e4f4e9; + color: #17632d; +} + +.relay-status.is-error { + background: #fbe9e7; + color: #98261f; +} + +.relay-detail, +.relay-noscript { + margin: 16px 0 0; + font-size: 0.875rem; + overflow-wrap: anywhere; +} + +.relay-noscript { + color: #98261f; +} + +@media (prefers-color-scheme: dark) { + :root { + background: #111418; + color: #edf0f3; + } + + .relay-card { + border-color: #30363e; + background: #191d22; + box-shadow: none; + } + + .relay-kicker, + .relay-description, + .relay-detail, + .relay-noscript { + color: #a5adb8; + } + + .relay-status { + background: #272d35; + } + + .relay-status.is-success { + background: #173d26; + color: #8ce0a6; + } + + .relay-status.is-error { + background: #48201f; + color: #ffaaa3; + } + + .relay-noscript { + color: #ffaaa3; + } +} diff --git a/public/oauth/relay.js b/public/oauth/relay.js new file mode 100644 index 0000000..71bd46c --- /dev/null +++ b/public/oauth/relay.js @@ -0,0 +1,86 @@ +const relayPath = /^\/oauth\/(anilist|mal|bangumi|mangabaka)\/callback\/?$/; + +export function buildRelayTarget(url) { + const match = relayPath.exec(url.pathname); + if (!match) { + throw new Error("This OAuth callback address is not supported."); + } + const provider = match[1]; + const providerError = url.searchParams.get("error"); + if (providerError) { + const description = url.searchParams.get("error_description"); + throw new Error(description ? `${providerError}: ${description}` : providerError); + } + + const code = url.searchParams.get("code"); + if (!code) { + throw new Error("The authorization response is missing its code."); + } + const state = url.searchParams.get("state"); + if (!state) { + throw new Error("The authorization response is missing its state."); + } + + let payload; + try { + payload = JSON.parse(state); + } catch { + throw new Error("The authorization state is invalid."); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("The authorization state is invalid."); + } + if (typeof payload.nonce !== "string" || !payload.nonce) { + throw new Error("The authorization state is missing its nonce."); + } + if (typeof payload.redirectUrl !== "string") { + throw new Error("The authorization state is missing its return address."); + } + + let target; + try { + target = new URL(payload.redirectUrl); + } catch { + throw new Error("The authorization return address is invalid."); + } + if (target.protocol !== "https:" && target.protocol !== "http:") { + throw new Error("The authorization return address must use HTTP or HTTPS."); + } + if (target.username || target.password || target.search || target.hash) { + throw new Error("The authorization return address contains unsupported parts."); + } + const targetPath = new RegExp( + `^/(?:[A-Za-z0-9_-]+/)*api/oauth/${provider}/callback$`, + ); + if (!targetPath.test(target.pathname)) { + throw new Error("The authorization return address does not match this provider."); + } + + target.searchParams.set("code", code); + target.searchParams.set("state", state); + return target.toString(); +} + +function runRelay() { + const status = document.getElementById("relay-status"); + const detail = document.getElementById("relay-detail"); + if (!status || !detail) { + return; + } + + try { + const target = buildRelayTarget(new URL(window.location.href)); + status.textContent = "Authorization received"; + status.classList.add("is-success"); + detail.textContent = "Returning to KMServer…"; + window.location.replace(target); + } catch (error) { + status.textContent = "Authorization could not be completed"; + status.classList.add("is-error"); + detail.textContent = error instanceof Error ? error.message : "Invalid authorization response."; + } +} + +if (typeof window !== "undefined" && typeof document !== "undefined") { + runRelay(); +}