Initial implementation

This commit is contained in:
2026-07-19 21:28:54 +00:00
commit 287ba99e84
18 changed files with 1324 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
import Koa from "koa";
import { timingSafeEqual } from "node:crypto";
import { FeedService } from "./rss.js";
import { SuwayomiClient } from "./suwayomi.js";
function equal(left, right) {
const a = Buffer.from(left || "");
const b = Buffer.from(right || "");
return a.length === b.length && timingSafeEqual(a, b);
}
function authorized(ctx, config) {
if (!config.username) return true;
const [scheme, encoded] = (ctx.get("authorization") || "").split(" ");
if (scheme?.toLowerCase() !== "basic" || !encoded) return false;
let credentials;
try {
credentials = Buffer.from(encoded, "base64").toString("utf8");
} catch {
return false;
}
const separator = credentials.indexOf(":");
if (separator < 0) return false;
return (
equal(credentials.slice(0, separator), config.username) &&
equal(credentials.slice(separator + 1), config.password)
);
}
function etagMatches(header, etag) {
return header
.split(",")
.map((value) => value.trim().replace(/^W\//, ""))
.some((value) => value === "*" || value === etag);
}
export function createApp(config, options = {}) {
const app = new Koa();
const client =
options.client || new SuwayomiClient(config.suwayomi, options.fetch);
const feeds = options.feedService || new FeedService(client, config.feed);
app.use(async (ctx) => {
if (ctx.path === "/healthz") {
ctx.body = { status: "ok" };
return;
}
if (ctx.path === "/") {
ctx.body = { name: "suwayomi-rss", feed: "/rss.xml" };
return;
}
if (ctx.path !== "/rss.xml" && ctx.path !== "/feed.xml") {
ctx.status = 404;
ctx.body = { error: "Not found" };
return;
}
if (!authorized(ctx, config.feed)) {
ctx.status = 401;
ctx.set("WWW-Authenticate", 'Basic realm="suwayomi-rss", charset="UTF-8"');
ctx.body = "Authentication required";
return;
}
try {
const feed = await feeds.get();
ctx.set("ETag", feed.etag);
ctx.set("Last-Modified", feed.lastModified.toUTCString());
ctx.set(
"Cache-Control",
`private, max-age=${Math.max(0, config.feed.cacheSeconds)}`,
);
ctx.status = 200;
ctx.type = "application/rss+xml; charset=utf-8";
if (ctx.fresh || etagMatches(ctx.get("if-none-match"), feed.etag)) {
ctx.status = 304;
return;
}
ctx.body = feed.body;
} catch (error) {
ctx.app.emit("error", error, ctx);
ctx.status = 502;
ctx.body = { error: "Could not generate feed from Suwayomi" };
}
});
app.on("error", (error) => {
if (options.silent !== true) console.error(error);
});
return app;
}
+80
View File
@@ -0,0 +1,80 @@
const AUTH_MODES = new Set(["none", "basic", "ui_login", "bearer"]);
function integer(name, value, defaultValue, { min, max }) {
if (value === undefined || value === "") return defaultValue;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`${name} must be an integer between ${min} and ${max}`);
}
return parsed;
}
function normalizedUrl(name, value) {
try {
return new URL(value).toString().replace(/\/$/, "");
} catch {
throw new Error(`${name} must be an absolute URL`);
}
}
export function loadConfig(env = process.env) {
const suwayomiUrl = normalizedUrl(
"SUWAYOMI_URL",
env.SUWAYOMI_URL || "http://suwayomi:4567",
);
const authMode = (env.SUWAYOMI_AUTH_MODE || "none").toLowerCase();
if (!AUTH_MODES.has(authMode)) {
throw new Error(
`SUWAYOMI_AUTH_MODE must be one of: ${[...AUTH_MODES].join(", ")}`,
);
}
if (["basic", "ui_login"].includes(authMode)) {
if (!env.SUWAYOMI_USERNAME || !env.SUWAYOMI_PASSWORD) {
throw new Error(
`SUWAYOMI_USERNAME and SUWAYOMI_PASSWORD are required for ${authMode} auth`,
);
}
}
if (authMode === "bearer" && !env.SUWAYOMI_TOKEN) {
throw new Error("SUWAYOMI_TOKEN is required for bearer auth");
}
if (Boolean(env.RSS_USERNAME) !== Boolean(env.RSS_PASSWORD)) {
throw new Error("RSS_USERNAME and RSS_PASSWORD must be set together");
}
return {
host: env.HOST || "0.0.0.0",
port: integer("PORT", env.PORT, 3000, { min: 1, max: 65535 }),
suwayomi: {
url: suwayomiUrl,
graphqlUrl:
env.SUWAYOMI_GRAPHQL_URL || `${suwayomiUrl}/api/graphql`,
authMode,
username: env.SUWAYOMI_USERNAME,
password: env.SUWAYOMI_PASSWORD,
token: env.SUWAYOMI_TOKEN,
timeoutMs: integer("SUWAYOMI_TIMEOUT_MS", env.SUWAYOMI_TIMEOUT_MS, 10000, {
min: 100,
max: 120000,
}),
},
feed: {
title: env.FEED_TITLE || "Suwayomi releases",
description:
env.FEED_DESCRIPTION || "New chapters in my Suwayomi library",
link: env.FEED_LINK || suwayomiUrl,
publicUrl: env.FEED_PUBLIC_URL || "",
itemLimit: integer("FEED_ITEM_LIMIT", env.FEED_ITEM_LIMIT, 50, {
min: 1,
max: 200,
}),
cacheSeconds: integer("FEED_CACHE_SECONDS", env.FEED_CACHE_SECONDS, 300, {
min: 0,
max: 86400,
}),
username: env.RSS_USERNAME,
password: env.RSS_PASSWORD,
},
};
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig();
const app = createApp(config);
const server = app.listen(config.port, config.host, () => {
console.log(`suwayomi-rss listening on http://${config.host}:${config.port}`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
server.close((error) => {
if (error) {
console.error(error);
process.exitCode = 1;
}
});
});
}
+108
View File
@@ -0,0 +1,108 @@
import { createHash } from "node:crypto";
function escapeXml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function timestamp(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function itemXml(chapter, feedLink, generatedAt) {
const manga = chapter.manga;
const link = chapter.realUrl || manga.realUrl || feedLink;
const fetchedAt = timestamp(chapter.fetchedAt, generatedAt);
const uploadDate = timestamp(chapter.uploadDate, 0);
const details = [
`Chapter: ${chapter.name}`,
chapter.scanlator ? `Scanlator: ${chapter.scanlator}` : null,
uploadDate ? `Source upload date: ${new Date(uploadDate).toISOString()}` : null,
].filter(Boolean);
return [
" <item>",
` <title>${escapeXml(`${manga.title}${chapter.name}`)}</title>`,
` <link>${escapeXml(link)}</link>`,
` <guid isPermaLink="false">suwayomi:chapter:${chapter.id}</guid>`,
` <pubDate>${new Date(fetchedAt).toUTCString()}</pubDate>`,
` <description>${escapeXml(details.join("\n"))}</description>`,
" </item>",
].join("\n");
}
export function buildRss(chapters, config, generatedAt = Date.now()) {
const items = chapters.map((chapter) =>
itemXml(chapter, config.link, generatedAt),
);
const latest = chapters.reduce(
(value, chapter) => Math.max(value, timestamp(chapter.fetchedAt, 0)),
generatedAt,
);
const atom = config.publicUrl
? `\n <atom:link href="${escapeXml(config.publicUrl)}" rel="self" type="application/rss+xml" />`
: "";
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(config.title)}</title>
<link>${escapeXml(config.link)}</link>
<description>${escapeXml(config.description)}</description>
<lastBuildDate>${new Date(latest).toUTCString()}</lastBuildDate>${atom}
${items.join("\n")}
</channel>
</rss>
`;
}
export function etagFor(value) {
return `"${createHash("sha256").update(value).digest("base64url")}"`;
}
export class FeedService {
#cached;
#pending;
constructor(client, config, now = Date.now) {
this.client = client;
this.config = config;
this.now = now;
}
async get() {
const now = this.now();
if (this.#cached && now < this.#cached.expiresAt) return this.#cached;
if (this.#pending) return this.#pending;
this.#pending = this.#refresh(now).finally(() => {
this.#pending = undefined;
});
return this.#pending;
}
async #refresh(now) {
const chapters = await this.client.recentLibraryChapters(
this.config.itemLimit,
);
const body = buildRss(chapters, this.config, now);
const fetchedValues = chapters
.map((chapter) => Number(chapter.fetchedAt))
.filter((value) => Number.isFinite(value) && value > 0);
const lastModified = new Date(
fetchedValues.length ? Math.max(...fetchedValues) : now,
);
this.#cached = {
body,
etag: etagFor(body),
lastModified,
expiresAt: now + this.config.cacheSeconds * 1000,
};
return this.#cached;
}
}
+177
View File
@@ -0,0 +1,177 @@
const CHAPTERS_QUERY = `
query RecentLibraryChapters($first: Int!) {
chapters(
filter: { inLibrary: { equalTo: true } }
order: [{ by: FETCHED_AT, byType: DESC }]
first: $first
) {
nodes {
id
name
chapterNumber
scanlator
uploadDate
fetchedAt
realUrl
manga {
id
title
realUrl
}
}
}
}
`;
const LOGIN_MUTATION = `
mutation Login($input: LoginInput!) {
login(input: $input) { accessToken refreshToken }
}
`;
const REFRESH_MUTATION = `
mutation Refresh($input: RefreshTokenInput!) {
refreshToken(input: $input) { accessToken }
}
`;
function tokenExpiry(token) {
try {
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url"));
return Number(payload.exp) * 1000;
} catch {
return 0;
}
}
export class SuwayomiError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = "SuwayomiError";
}
}
export class SuwayomiClient {
#accessToken;
#accessTokenExpiresAt = 0;
#refreshToken;
#tokenPromise;
constructor(config, fetchImpl = globalThis.fetch) {
this.config = config;
this.fetch = fetchImpl;
}
async recentLibraryChapters(limit) {
const data = await this.#graphql(CHAPTERS_QUERY, { first: limit });
return data.chapters.nodes;
}
async #graphql(query, variables, { authorization = true } = {}) {
const headers = { "content-type": "application/json" };
if (authorization) {
const auth = await this.#authorization();
if (auth) headers.authorization = auth;
}
let response;
try {
response = await this.fetch(this.config.graphqlUrl, {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(this.config.timeoutMs),
});
} catch (error) {
throw new SuwayomiError(`Could not reach Suwayomi: ${error.message}`, {
cause: error,
});
}
if (!response.ok) {
throw new SuwayomiError(
`Suwayomi returned HTTP ${response.status} ${response.statusText}`,
);
}
let result;
try {
result = await response.json();
} catch (error) {
throw new SuwayomiError("Suwayomi returned invalid JSON", { cause: error });
}
if (result.errors?.length) {
const message = result.errors.map((error) => error.message).join("; ");
throw new SuwayomiError(`Suwayomi GraphQL error: ${message}`);
}
if (!result.data) {
throw new SuwayomiError("Suwayomi returned no GraphQL data");
}
return result.data;
}
async #authorization() {
switch (this.config.authMode) {
case "none":
return undefined;
case "basic":
return `Basic ${Buffer.from(
`${this.config.username}:${this.config.password}`,
).toString("base64")}`;
case "bearer":
return `Bearer ${this.config.token}`;
case "ui_login":
return `Bearer ${await this.#uiLoginToken()}`;
default:
throw new SuwayomiError(`Unsupported auth mode: ${this.config.authMode}`);
}
}
async #uiLoginToken() {
if (this.#accessToken && Date.now() < this.#accessTokenExpiresAt - 30000) {
return this.#accessToken;
}
if (!this.#tokenPromise) {
this.#tokenPromise = this.#obtainToken().finally(() => {
this.#tokenPromise = undefined;
});
}
return this.#tokenPromise;
}
async #obtainToken() {
if (this.#refreshToken) {
try {
const data = await this.#graphql(
REFRESH_MUTATION,
{ input: { refreshToken: this.#refreshToken } },
{ authorization: false },
);
return this.#saveAccessToken(data.refreshToken.accessToken);
} catch {
this.#refreshToken = undefined;
}
}
const data = await this.#graphql(
LOGIN_MUTATION,
{
input: {
username: this.config.username,
password: this.config.password,
},
},
{ authorization: false },
);
this.#refreshToken = data.login.refreshToken;
return this.#saveAccessToken(data.login.accessToken);
}
#saveAccessToken(token) {
this.#accessToken = token;
this.#accessTokenExpiresAt = tokenExpiry(token) || Date.now() + 240000;
return token;
}
}
export { CHAPTERS_QUERY };