Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions public/oauth/anilist/callback/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>AniList authorization · kmworks</title>
<link rel="stylesheet" href="/oauth/relay.css" />
<script type="module" src="/oauth/relay.js"></script>
</head>
<body>
<main class="relay-card" aria-labelledby="relay-title">
<p class="relay-kicker">kmworks · OAuth relay</p>
<h1 id="relay-title">AniList authorization</h1>
<p class="relay-description">
Returning the authorization result to your KMServer instance.
</p>
<div id="relay-status" class="relay-status" role="status">
Processing authorization…
</div>
<p id="relay-detail" class="relay-detail" aria-live="polite">
You will be redirected automatically.
</p>
<noscript>
<p class="relay-noscript">
JavaScript is required to return the authorization result. Enable it
and retry from KMServer.
</p>
</noscript>
</main>
</body>
</html>
33 changes: 33 additions & 0 deletions public/oauth/bangumi/callback/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>Bangumi authorization · kmworks</title>
<link rel="stylesheet" href="/oauth/relay.css" />
<script type="module" src="/oauth/relay.js"></script>
</head>
<body>
<main class="relay-card" aria-labelledby="relay-title">
<p class="relay-kicker">kmworks · OAuth relay</p>
<h1 id="relay-title">Bangumi authorization</h1>
<p class="relay-description">
Returning the authorization result to your KMServer instance.
</p>
<div id="relay-status" class="relay-status" role="status">
Processing authorization…
</div>
<p id="relay-detail" class="relay-detail" aria-live="polite">
You will be redirected automatically.
</p>
<noscript>
<p class="relay-noscript">
JavaScript is required to return the authorization result. Enable it
and retry from KMServer.
</p>
</noscript>
</main>
</body>
</html>
33 changes: 33 additions & 0 deletions public/oauth/mal/callback/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>MyAnimeList authorization · kmworks</title>
<link rel="stylesheet" href="/oauth/relay.css" />
<script type="module" src="/oauth/relay.js"></script>
</head>
<body>
<main class="relay-card" aria-labelledby="relay-title">
<p class="relay-kicker">kmworks · OAuth relay</p>
<h1 id="relay-title">MyAnimeList authorization</h1>
<p class="relay-description">
Returning the authorization result to your KMServer instance.
</p>
<div id="relay-status" class="relay-status" role="status">
Processing authorization…
</div>
<p id="relay-detail" class="relay-detail" aria-live="polite">
You will be redirected automatically.
</p>
<noscript>
<p class="relay-noscript">
JavaScript is required to return the authorization result. Enable it
and retry from KMServer.
</p>
</noscript>
</main>
</body>
</html>
33 changes: 33 additions & 0 deletions public/oauth/mangabaka/callback/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex, nofollow, noarchive" />
<title>MangaBaka authorization · kmworks</title>
<link rel="stylesheet" href="/oauth/relay.css" />
<script type="module" src="/oauth/relay.js"></script>
</head>
<body>
<main class="relay-card" aria-labelledby="relay-title">
<p class="relay-kicker">kmworks · OAuth relay</p>
<h1 id="relay-title">MangaBaka authorization</h1>
<p class="relay-description">
Returning the authorization result to your KMServer instance.
</p>
<div id="relay-status" class="relay-status" role="status">
Processing authorization…
</div>
<p id="relay-detail" class="relay-detail" aria-live="polite">
You will be redirected automatically.
</p>
<noscript>
<p class="relay-noscript">
JavaScript is required to return the authorization result. Enable it
and retry from KMServer.
</p>
</noscript>
</main>
</body>
</html>
122 changes: 122 additions & 0 deletions public/oauth/relay.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
86 changes: 86 additions & 0 deletions public/oauth/relay.js
Original file line number Diff line number Diff line change
@@ -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();
}
Loading