From 287ba99e8490c470f22e5716472dd49bc0b00a6a Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 19 Jul 2026 21:28:54 +0000 Subject: [PATCH] Initial implementation --- .dockerignore | 7 + .env.example | 16 ++ .gitignore | 5 + Dockerfile | 22 +++ LICENSE | 21 +++ README.md | 90 ++++++++++ package-lock.json | 405 ++++++++++++++++++++++++++++++++++++++++++ package.json | 18 ++ src/app.js | 90 ++++++++++ src/config.js | 80 +++++++++ src/index.js | 20 +++ src/rss.js | 108 +++++++++++ src/suwayomi.js | 177 ++++++++++++++++++ test/app.test.js | 72 ++++++++ test/config.test.js | 26 +++ test/helpers.js | 41 +++++ test/rss.test.js | 37 ++++ test/suwayomi.test.js | 89 ++++++++++ 18 files changed, 1324 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/config.js create mode 100644 src/index.js create mode 100644 src/rss.js create mode 100644 src/suwayomi.js create mode 100644 test/app.test.js create mode 100644 test/config.test.js create mode 100644 test/helpers.js create mode 100644 test/rss.test.js create mode 100644 test/suwayomi.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d7eb372 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +node_modules +test +coverage +.env +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7ce2110 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +SUWAYOMI_URL=http://suwayomi:4567 +SUWAYOMI_AUTH_MODE=none +# SUWAYOMI_USERNAME= +# SUWAYOMI_PASSWORD= +# SUWAYOMI_TOKEN= + +FEED_TITLE=Suwayomi releases +FEED_DESCRIPTION=New chapters in my Suwayomi library +FEED_ITEM_LIMIT=50 +FEED_CACHE_SECONDS=300 +# FEED_PUBLIC_URL=https://example.com/manga/rss.xml +# RSS_USERNAME= +# RSS_PASSWORD= + +HOST=0.0.0.0 +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..360b6a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +.DS_Store +coverage/ +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..211d261 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:24-alpine AS dependencies + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +FROM node:24-alpine + +ENV NODE_ENV=production +WORKDIR /app + +COPY --from=dependencies /app/node_modules ./node_modules +COPY package.json ./ +COPY src ./src + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +CMD ["node", "src/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..336e355 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 codinget + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..05ea514 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# suwayomi-rss + +A small Node.js 24 and Koa service that turns recent chapter updates from a +[Suwayomi](https://github.com/Suwayomi/Suwayomi-Server) library into an RSS 2.0 +feed. + +It queries Suwayomi's GraphQL API for library chapters ordered by the time they +were fetched. Each RSS item uses the Suwayomi chapter ID as a stable GUID, so a +feed reader will notify only when a chapter first appears in the feed. + +## Run with Docker + +```sh +docker build -t suwayomi-rss . +docker run --rm -p 3000:3000 \ + -e SUWAYOMI_URL=http://suwayomi:4567 \ + suwayomi-rss +``` + +In Compose, place this service on the same network as Suwayomi: + +```yaml +services: + suwayomi-rss: + build: . + environment: + SUWAYOMI_URL: http://suwayomi:4567 + SUWAYOMI_AUTH_MODE: ui_login + SUWAYOMI_USERNAME: ${SUWAYOMI_USERNAME} + SUWAYOMI_PASSWORD: ${SUWAYOMI_PASSWORD} + FEED_PUBLIC_URL: https://rss.example.com/rss.xml + RSS_USERNAME: ${RSS_USERNAME} + RSS_PASSWORD: ${RSS_PASSWORD} + ports: + - "127.0.0.1:3000:3000" + restart: unless-stopped +``` + +The feed is available at `/rss.xml` and `/feed.xml`. A liveness endpoint is +available at `/healthz`; it deliberately does not contact Suwayomi. + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `SUWAYOMI_URL` | `http://suwayomi:4567` | Suwayomi server root URL | +| `SUWAYOMI_GRAPHQL_URL` | `$SUWAYOMI_URL/api/graphql` | Override the GraphQL endpoint | +| `SUWAYOMI_AUTH_MODE` | `none` | `none`, `basic`, `ui_login`, or `bearer` | +| `SUWAYOMI_USERNAME` | | Required for `basic` and `ui_login` | +| `SUWAYOMI_PASSWORD` | | Required for `basic` and `ui_login` | +| `SUWAYOMI_TOKEN` | | Required for `bearer` | +| `SUWAYOMI_TIMEOUT_MS` | `10000` | Upstream request timeout | +| `FEED_TITLE` | `Suwayomi releases` | RSS channel title | +| `FEED_DESCRIPTION` | `New chapters in my Suwayomi library` | RSS channel description | +| `FEED_LINK` | `SUWAYOMI_URL` | RSS channel link and fallback item link | +| `FEED_PUBLIC_URL` | | Public feed URL, emitted as the Atom self link | +| `FEED_ITEM_LIMIT` | `50` | Number of recent chapters, from 1 to 200 | +| `FEED_CACHE_SECONDS` | `300` | In-memory upstream response cache duration | +| `RSS_USERNAME` / `RSS_PASSWORD` | | Optional HTTP Basic protection for the feed | +| `HOST` | `0.0.0.0` | Listening address | +| `PORT` | `3000` | Listening port | + +`simple_login` is not supported for the upstream connection because it is an +interactive, cookie-based mode. Prefer Suwayomi's `ui_login`, Basic Auth over a +trusted private network, or a bearer token. + +The application never persists credentials or chapter data. Its cache is held +in memory and is discarded on restart. + +## Local development + +```sh +npm install +npm test +SUWAYOMI_URL=http://localhost:4567 npm start +``` + +This project uses Node's built-in `node:test` runner and has no test framework +dependency. + +## Security + +This feed reveals the contents of your Suwayomi library and chapter source +URLs. Keep it on a private network, protect it with `RSS_USERNAME` and +`RSS_PASSWORD`, or put it behind your existing authenticated reverse proxy. +Do not send Suwayomi or RSS credentials over unencrypted public HTTP. + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f9c297d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,405 @@ +{ + "name": "suwayomi-rss", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "suwayomi-rss", + "version": "0.1.0", + "dependencies": { + "koa": "^3.2.1" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..95f03b8 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "suwayomi-rss", + "version": "0.1.0", + "description": "Expose recent Suwayomi library chapters as an RSS feed", + "type": "module", + "private": true, + "engines": { + "node": ">=24" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test test/*.test.js", + "test:coverage": "node --test --experimental-test-coverage test/*.test.js" + }, + "dependencies": { + "koa": "^3.2.1" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..8106538 --- /dev/null +++ b/src/app.js @@ -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; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..6064f0b --- /dev/null +++ b/src/config.js @@ -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, + }, + }; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..bfeeffc --- /dev/null +++ b/src/index.js @@ -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; + } + }); + }); +} diff --git a/src/rss.js b/src/rss.js new file mode 100644 index 0000000..d3c8ca6 --- /dev/null +++ b/src/rss.js @@ -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 [ + " ", + ` ${escapeXml(`${manga.title} — ${chapter.name}`)}`, + ` ${escapeXml(link)}`, + ` suwayomi:chapter:${chapter.id}`, + ` ${new Date(fetchedAt).toUTCString()}`, + ` ${escapeXml(details.join("\n"))}`, + " ", + ].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 ` + : ""; + + return ` + + + ${escapeXml(config.title)} + ${escapeXml(config.link)} + ${escapeXml(config.description)} + ${new Date(latest).toUTCString()}${atom} +${items.join("\n")} + + +`; +} + +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; + } +} diff --git a/src/suwayomi.js b/src/suwayomi.js new file mode 100644 index 0000000..1b7cc85 --- /dev/null +++ b/src/suwayomi.js @@ -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 }; diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 0000000..eb39f19 --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp } from "../src/app.js"; +import { baseConfig, listen } from "./helpers.js"; + +const feed = { + body: "", + etag: '"feed-tag"', + lastModified: new Date("2025-01-01T00:00:00Z"), +}; + +test("serves health and RSS endpoints with conditional GET", async (t) => { + const app = createApp(baseConfig, { + feedService: { async get() { return feed; } }, + silent: true, + }); + const server = await listen(app); + t.after(server.close); + + const health = await fetch(`${server.url}/healthz`); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { status: "ok" }); + + const response = await fetch(`${server.url}/rss.xml`); + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type"), /application\/rss\+xml/); + assert.equal(response.headers.get("etag"), feed.etag); + assert.equal(await response.text(), feed.body); + + const fresh = await fetch(`${server.url}/rss.xml`, { + headers: { "if-none-match": feed.etag }, + }); + assert.equal(fresh.status, 304); +}); + +test("optionally protects the feed with HTTP Basic auth", async (t) => { + const config = structuredClone(baseConfig); + config.feed.username = "reader"; + config.feed.password = "secret:with-colon"; + const app = createApp(config, { + feedService: { async get() { return feed; } }, + silent: true, + }); + const server = await listen(app); + t.after(server.close); + + const denied = await fetch(`${server.url}/rss.xml`); + assert.equal(denied.status, 401); + assert.match(denied.headers.get("www-authenticate"), /Basic/); + + const accepted = await fetch(`${server.url}/rss.xml`, { + headers: { + authorization: `Basic ${Buffer.from("reader:secret:with-colon").toString("base64")}`, + }, + }); + assert.equal(accepted.status, 200); +}); + +test("returns 502 without leaking upstream error details", async (t) => { + const app = createApp(baseConfig, { + feedService: { async get() { throw new Error("secret upstream detail"); } }, + silent: true, + }); + const server = await listen(app); + t.after(server.close); + + const response = await fetch(`${server.url}/rss.xml`); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { + error: "Could not generate feed from Suwayomi", + }); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..912a045 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadConfig } from "../src/config.js"; + +test("loads defaults", () => { + const config = loadConfig({}); + assert.equal(config.port, 3000); + assert.equal(config.suwayomi.graphqlUrl, "http://suwayomi:4567/api/graphql"); + assert.equal(config.feed.itemLimit, 50); +}); + +test("requires credentials for authenticated modes", () => { + assert.throws( + () => loadConfig({ SUWAYOMI_AUTH_MODE: "ui_login" }), + /USERNAME and SUWAYOMI_PASSWORD/, + ); + assert.throws( + () => loadConfig({ RSS_USERNAME: "reader" }), + /must be set together/, + ); +}); + +test("validates bounded integer settings", () => { + assert.throws(() => loadConfig({ FEED_ITEM_LIMIT: "201" }), /between 1 and 200/); + assert.throws(() => loadConfig({ PORT: "wat" }), /PORT must be an integer/); +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..3e2b476 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,41 @@ +export const baseConfig = { + suwayomi: { + url: "http://suwayomi:4567", + graphqlUrl: "http://suwayomi:4567/api/graphql", + authMode: "none", + timeoutMs: 1000, + }, + feed: { + title: "Manga releases", + description: "Recently fetched chapters", + link: "https://manga.example.test", + publicUrl: "https://rss.example.test/rss.xml", + itemLimit: 25, + cacheSeconds: 300, + }, +}; + +export const chapter = { + id: 42, + name: "Chapter 12 & a half", + chapterNumber: 12.5, + scanlator: "A ", + uploadDate: 1700000000000, + fetchedAt: 1700000100000, + realUrl: "https://source.example.test/chapter?x=1&y=2", + manga: { + id: 7, + title: "Example & Manga", + realUrl: "https://source.example.test/manga", + }, +}; + +export async function listen(app) { + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const { port } = server.address(); + return { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} diff --git a/test/rss.test.js b/test/rss.test.js new file mode 100644 index 0000000..873b63b --- /dev/null +++ b/test/rss.test.js @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildRss, FeedService } from "../src/rss.js"; +import { baseConfig, chapter } from "./helpers.js"; + +test("buildRss emits valid escaped RSS fields and a stable GUID", () => { + const xml = buildRss([chapter], baseConfig.feed, 1700000200000); + + assert.match(xml, / { + let calls = 0; + let now = 1000; + const client = { + async recentLibraryChapters(limit) { + calls += 1; + assert.equal(limit, 25); + await Promise.resolve(); + return [chapter]; + }, + }; + const service = new FeedService(client, baseConfig.feed, () => now); + + const [first, second] = await Promise.all([service.get(), service.get()]); + assert.strictEqual(first, second); + assert.equal(calls, 1); + + now += 301000; + await service.get(); + assert.equal(calls, 2); +}); diff --git a/test/suwayomi.test.js b/test/suwayomi.test.js new file mode 100644 index 0000000..468eaaf --- /dev/null +++ b/test/suwayomi.test.js @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SuwayomiClient } from "../src/suwayomi.js"; + +const config = { + graphqlUrl: "https://suwayomi.example.test/api/graphql", + authMode: "none", + timeoutMs: 1000, +}; + +function response(data, init = {}) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +test("queries recent chapters from library in fetched order", async () => { + const calls = []; + const client = new SuwayomiClient(config, async (url, init) => { + calls.push({ url, init }); + return response({ data: { chapters: { nodes: [{ id: 1 }] } } }); + }); + + assert.deepEqual(await client.recentLibraryChapters(12), [{ id: 1 }]); + const body = JSON.parse(calls[0].init.body); + assert.equal(calls[0].url, config.graphqlUrl); + assert.equal(body.variables.first, 12); + assert.match(body.query, /inLibrary/); + assert.match(body.query, /FETCHED_AT/); +}); + +test("sends Basic credentials", async () => { + const headers = []; + const client = new SuwayomiClient( + { ...config, authMode: "basic", username: "me", password: "pass" }, + async (_url, init) => { + headers.push(init.headers.authorization); + return response({ data: { chapters: { nodes: [] } } }); + }, + ); + + await client.recentLibraryChapters(5); + assert.equal(headers[0], `Basic ${Buffer.from("me:pass").toString("base64")}`); +}); + +test("logs in once for UI login and sends the JWT", async () => { + const token = [ + "header", + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 300 })).toString("base64url"), + "signature", + ].join("."); + const operations = []; + const client = new SuwayomiClient( + { + ...config, + authMode: "ui_login", + username: "reader", + password: "secret", + }, + async (_url, init) => { + const request = JSON.parse(init.body); + operations.push({ request, authorization: init.headers.authorization }); + if (request.query.includes("mutation Login")) { + return response({ + data: { login: { accessToken: token, refreshToken: "refresh" } }, + }); + } + return response({ data: { chapters: { nodes: [] } } }); + }, + ); + + await client.recentLibraryChapters(5); + await client.recentLibraryChapters(5); + assert.equal(operations.filter(({ request }) => request.query.includes("mutation Login")).length, 1); + assert.equal(operations[1].authorization, `Bearer ${token}`); + assert.equal(operations[2].authorization, `Bearer ${token}`); +}); + +test("turns GraphQL errors into useful upstream errors", async () => { + const client = new SuwayomiClient(config, async () => + response({ errors: [{ message: "Unauthorized" }] }), + ); + await assert.rejects( + client.recentLibraryChapters(5), + /Suwayomi GraphQL error: Unauthorized/, + ); +});