Initial implementation
This commit is contained in:
+90
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,108 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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
@@ -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 };
|
||||
Reference in New Issue
Block a user