Initial implementation
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
test
|
||||
coverage
|
||||
.env
|
||||
README.md
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.DS_Store
|
||||
coverage/
|
||||
npm-debug.log*
|
||||
+22
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Generated
+405
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+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 };
|
||||
@@ -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: "<rss></rss>",
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 <Team>",
|
||||
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)),
|
||||
};
|
||||
}
|
||||
@@ -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, /<rss version="2.0"/);
|
||||
assert.match(xml, /Example & Manga — Chapter 12 & a half/);
|
||||
assert.match(xml, /A <Team>/);
|
||||
assert.match(xml, /suwayomi:chapter:42/);
|
||||
assert.match(xml, /x=1&y=2/);
|
||||
assert.match(xml, /atom:link/);
|
||||
});
|
||||
|
||||
test("FeedService caches responses and coalesces concurrent refreshes", async () => {
|
||||
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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user