From 9c8aade851a7916305c684204b23681d69f0aedd Mon Sep 17 00:00:00 2001 From: Nicolas Kieffer Date: Tue, 1 Sep 2026 18:53:37 +0200 Subject: [PATCH 1/4] fix(rate-limit): reject every request when a user max is 0 A user configured with `rateLimit.max: 0` was never blocked: the requests kept returning 200. Two independent causes: - `user.rateLimit.max || 100` evaluated 0 to the default of 100, so the user silently kept a quota of 100 requests per window; - express-rate-limit v5 skips its own check when max is falsy (`if (max && current > max)`), so even a correctly resolved 0 would let every request through instead of answering 429. Resolve the max with Number.isFinite() so 0 is distinguished from "not configured", and answer the 429 from a guard placed before the limiter, since the limiter itself can never do it. The guard mirrors the headers the limiter sets and honours the per-user message. Also stop calling getUserById() unguarded: a valid temporary token whose user was removed from users.json produced a 500 instead of falling back to the default limits. Co-Authored-By: Claude Opus 5 --- README.md | 29 ++++++ src/utils/rateLimiter.js | 117 +++++++++++++++++++----- test/unit/rateLimiter.test.js | 165 ++++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 22 deletions(-) create mode 100644 test/unit/rateLimiter.test.js diff --git a/README.md b/README.md index aebafb6..7a182a4 100644 --- a/README.md +++ b/README.md @@ -1193,6 +1193,35 @@ npm run manage-users -- update-genshare snapshot-mails '{"authorizedVersions": [ npm run manage-users -- remove snapshot-mails ``` +#### Rate limit semantics + +Rate limiting is per user, configured in `conf/users.json` under `rateLimit`: + +| Setting | Meaning | +|---|---| +| `max: ` | At most `n` requests per window. Request `n + 1` gets **429**. | +| `max: 0` | **Every request is rejected with 429.** Use this to suspend an account without deleting it. | +| `max` absent | Falls back to the default of 100 requests per window. | +| `windowMs: 0` | **Rate limiting disabled** — unlimited requests. Takes precedence over `max: 0`. | +| `message` | Body returned with the 429. Optional; a default message is used otherwise. | + +```bash +# Suspend an account (every call answers 429) +npm run manage-users -- update-limit snapshot-mails '{"max": 0}' + +# Restore it +npm run manage-users -- update-limit snapshot-mails '{"max": 100}' +``` + +Two implementation details worth knowing before editing `src/utils/rateLimiter.js`: + +- `max: 0` is handled by a guard that runs **before** `express-rate-limit`. The installed version (v5) + skips its own check when `max` is falsy (`if (max && current > max)`), so without that guard a max + of 0 would let every request through instead of blocking it. +- `windowMs` is only read per user to detect the `0` (disabled) case. The window length itself is + fixed at 15 minutes for everyone — a user configured with, say, `windowMs: 60000` still gets a + 15-minute window. + ### GenShare Version Management ```bash diff --git a/src/utils/rateLimiter.js b/src/utils/rateLimiter.js index ddf715d..6107bfa 100644 --- a/src/utils/rateLimiter.js +++ b/src/utils/rateLimiter.js @@ -2,33 +2,106 @@ const rateLimit = require('express-rate-limit'); const { getUserById } = require('./userManager'); -const customRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // Default window of 15 minutes - max: (req) => { - if (req.user) { - const user = getUserById(req.user.id); - return user.rateLimit.max || 100; // Default to 100 if not specified - } - return 100; // Default limit for unauthenticated requests - }, - message: 'Too many requests, please try again later.', +const DEFAULT_WINDOW_MS = 15 * 60 * 1000; // Default window of 15 minutes +const DEFAULT_MAX = 100; // Default limit when the user has no `max` configured +const DEFAULT_MESSAGE = 'Too many requests, please try again later.'; +const TOO_MANY_REQUESTS = 429; + +/** + * Read the rateLimit configuration of the authenticated user. + * Never throws: an unauthenticated request, or a token whose user has been + * removed from users.json, falls back to the default limits instead of a 500. + * @param {Object} req - Express request + * @returns {Object} The user's rateLimit object, or {} when unavailable + */ +const getUserRateLimit = (req) => { + if (!req.user || !req.user.id) return {}; + + try { + return getUserById(req.user.id).rateLimit || {}; + } catch (error) { + return {}; + } +}; + +/** + * Resolve the max number of requests allowed for this request. + * `max: 0` is a valid value meaning "no request allowed", so it must not be + * confused with "not configured" — hence Number.isFinite() rather than `||`. + * @param {Object} req - Express request + * @returns {number} The max number of requests allowed in the window + */ +const resolveMax = (req) => { + const { max } = getUserRateLimit(req); + return Number.isFinite(max) ? max : DEFAULT_MAX; +}; + +/** + * Rate limiting is disabled for the user (unlimited requests). + * @param {Object} req - Express request + * @returns {boolean} True when the user opted out of rate limiting + */ +const isRateLimitDisabled = (req) => getUserRateLimit(req).windowMs === 0; + +/** + * Resolve the message returned to the user when the limit is reached. + * @param {Object} req - Express request + * @param {string} fallback - Message to use when the user defines none + * @returns {string} The message to send + */ +const resolveMessage = (req, fallback) => getUserRateLimit(req).message || fallback; + +/** + * Reject every request of a user configured with `max: 0`. + * + * express-rate-limit v5 skips its own limit check when max is falsy + * (`if (max && current > max)`), so a max of 0 would let every request through. + * This guard runs before the limiter and answers 429 itself, mirroring the + * headers the limiter sets so both paths look identical to the client. + * @param {Object} req - Express request + * @param {Object} res - Express response + * @param {Function} next - Express next middleware + * @returns {*} Express response or next() + */ +const blockZeroMax = (req, res, next) => { + if (isRateLimitDisabled(req)) return next(); + if (resolveMax(req) !== 0) return next(); + + if (!res.headersSent) { + const resetTime = new Date(Date.now() + DEFAULT_WINDOW_MS); + res.setHeader('X-RateLimit-Limit', 0); + res.setHeader('X-RateLimit-Remaining', 0); + res.setHeader('Date', new Date().toUTCString()); + res.setHeader('X-RateLimit-Reset', Math.ceil(resetTime.getTime() / 1000)); + res.setHeader('Retry-After', Math.ceil(DEFAULT_WINDOW_MS / 1000)); + } + + return res.status(TOO_MANY_REQUESTS).send(resolveMessage(req, DEFAULT_MESSAGE)); +}; + +const limiter = rateLimit({ + windowMs: DEFAULT_WINDOW_MS, + max: resolveMax, + message: DEFAULT_MESSAGE, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.user ? req.user.id : req.ip, handler: (req, res, next, options) => { - if (req.user) { - const user = getUserById(req.user.id); - options.message = user.rateLimit.message || options.message; - } - res.status(options.statusCode).send(options.message); + res.status(options.statusCode).send(resolveMessage(req, options.message)); }, - skip: (req) => { - if (req.user) { - const user = getUserById(req.user.id); - return user.rateLimit.windowMs === 0; // Skip rate limiting if windowMs is 0 - } - return false; - } + skip: isRateLimitDisabled }); +/** + * Per-user rate limiter. + * `windowMs: 0` disables rate limiting, `max: 0` blocks every request, + * anything else is limited to `max` requests per window. + * @param {Object} req - Express request + * @param {Object} res - Express response + * @param {Function} next - Express next middleware + * @returns {*} Express response or next() + */ +const customRateLimiter = (req, res, next) => + blockZeroMax(req, res, (error) => error ? next(error) : limiter(req, res, next)); + module.exports = customRateLimiter; diff --git a/test/unit/rateLimiter.test.js b/test/unit/rateLimiter.test.js new file mode 100644 index 0000000..c08db78 --- /dev/null +++ b/test/unit/rateLimiter.test.js @@ -0,0 +1,165 @@ +/** + * Unit tests for the per-user rate limiter. + * + * Behaviour under test: + * - `max: 0` -> every request is rejected with 429 + * - `windowMs: 0` -> rate limiting is disabled + * - anything else -> `max` requests per window, then 429 + */ + +// Mock userManager: it loads conf/users.json at require-time +jest.mock('../../src/utils/userManager', () => ({ + getUserById: jest.fn() +})); + +const express = require('express'); +const request = require('supertest'); +const userManager = require('../../src/utils/userManager'); + +const USERS = {}; + +/** + * Build an express app with a fresh limiter (and therefore a fresh store). + * @param {Object} [options] - Test options + * @param {boolean} [options.authenticated] - Attach req.user when true + * @returns {Object} An express app exposing GET / + */ +const buildApp = ({ authenticated = true } = {}) => { + jest.resetModules(); + jest.doMock('../../src/utils/userManager', () => ({ + getUserById: (userId) => { + if (!USERS[userId]) throw new Error(`User ${userId} not found`); + return { id: userId, ...USERS[userId] }; + } + })); + + // eslint-disable-next-line global-require + const rateLimiter = require('../../src/utils/rateLimiter'); + const app = express(); + + if (authenticated) app.use((req, res, next) => { + req.user = { id: req.get('x-test-user') }; + next(); + }); + + app.use(rateLimiter); + app.get('/', (req, res) => res.status(200).send('OK')); + + return app; +}; + +/** + * Issue `count` requests as `userId` and collect the status codes. + * @param {Object} app - Express app + * @param {string} userId - User making the requests + * @param {number} count - Number of requests to issue + * @returns {Promise>} The status codes, in order + */ +const call = async (app, userId, count) => { + const statuses = []; + + for (let i = 0; i < count; i++) { + const res = await request(app).get('/').set('x-test-user', userId); + statuses.push(res.status); + } + + return statuses; +}; + +beforeEach(() => { + Object.keys(USERS).forEach((key) => delete USERS[key]); + userManager.getUserById.mockReset(); +}); + +describe('rateLimiter - max: 0', () => { + it('rejects every request with 429', async () => { + USERS.blocked = { rateLimit: { max: 0, windowMs: 900000 } }; + + expect(await call(buildApp(), 'blocked', 5)).toEqual([429, 429, 429, 429, 429]); + }); + + it('never lets a single request through, even the first one', async () => { + USERS.blocked = { rateLimit: { max: 0, windowMs: 900000 } }; + const res = await request(buildApp()).get('/').set('x-test-user', 'blocked'); + + expect(res.status).toBe(429); + expect(res.text).not.toBe('OK'); + }); + + it('returns the user message and the rate limit headers', async () => { + USERS.blocked = { rateLimit: { max: 0, windowMs: 900000, message: 'Access suspended.' } }; + const res = await request(buildApp()).get('/').set('x-test-user', 'blocked'); + + expect(res.text).toBe('Access suspended.'); + expect(res.headers['x-ratelimit-limit']).toBe('0'); + expect(res.headers['x-ratelimit-remaining']).toBe('0'); + expect(res.headers['retry-after']).toBe('900'); + }); + + it('falls back to the default message when the user defines none', async () => { + USERS.blocked = { rateLimit: { max: 0, windowMs: 900000 } }; + const res = await request(buildApp()).get('/').set('x-test-user', 'blocked'); + + expect(res.text).toBe('Too many requests, please try again later.'); + }); + + it('is overridden by windowMs: 0 (disabled wins over blocked)', async () => { + USERS.unlimited = { rateLimit: { max: 0, windowMs: 0 } }; + + expect(await call(buildApp(), 'unlimited', 4)).toEqual([200, 200, 200, 200]); + }); +}); + +describe('rateLimiter - regular limits', () => { + it('allows max requests then answers 429', async () => { + USERS.limited = { rateLimit: { max: 2, windowMs: 900000 } }; + + expect(await call(buildApp(), 'limited', 4)).toEqual([200, 200, 429, 429]); + }); + + it('uses the per-user message once the limit is reached', async () => { + USERS.limited = { rateLimit: { max: 1, windowMs: 900000, message: 'Slow down.' } }; + const app = buildApp(); + + await request(app).get('/').set('x-test-user', 'limited'); + const res = await request(app).get('/').set('x-test-user', 'limited'); + + expect(res.status).toBe(429); + expect(res.text).toBe('Slow down.'); + }); + + it('counts each user separately', async () => { + USERS.a = { rateLimit: { max: 1, windowMs: 900000 } }; + USERS.b = { rateLimit: { max: 1, windowMs: 900000 } }; + const app = buildApp(); + + expect(await call(app, 'a', 2)).toEqual([200, 429]); + expect(await call(app, 'b', 1)).toEqual([200]); + }); + + it('defaults to 100 requests when max is not configured', async () => { + USERS.nomax = { rateLimit: { windowMs: 900000 } }; + + expect(await call(buildApp(), 'nomax', 3)).toEqual([200, 200, 200]); + }); + + it('does not rate limit a user with windowMs: 0', async () => { + USERS.unlimited = { rateLimit: { max: 1, windowMs: 0 } }; + + expect(await call(buildApp(), 'unlimited', 5)).toEqual([200, 200, 200, 200, 200]); + }); +}); + +describe('rateLimiter - unknown user', () => { + it('applies the default limit instead of failing with a 500', async () => { + const res = await request(buildApp()).get('/').set('x-test-user', 'deleted-user'); + + expect(res.status).toBe(200); + }); + + it('applies the default limit to unauthenticated requests', async () => { + const res = await request(buildApp({ authenticated: false })).get('/'); + + expect(res.status).toBe(200); + }); +}); From 7816df81e6c156a5d9779cbb7e314562df5c5ad6 Mon Sep 17 00:00:00 2001 From: Nicolas Kieffer Date: Tue, 1 Sep 2026 18:53:46 +0200 Subject: [PATCH 2/4] feat(reports): generate missing report URLs on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While snapshot-reports was unavailable, createReport() failed and the error was deliberately swallowed — a report failure must never fail the request itself. Those requests ended up with no report URL at all. Add a maintenance script to create the missing URLs after the fact, for a single request, for every request of one user, or for all of them. It only ever adds a missing report URL: an existing one is never replaced and the analysis is never re-run. Dry run is the default. This is a recovery patch, not part of the normal flow — whether a given request deserves a report (cached? demo?) stays the operator's call. Co-Authored-By: Claude Opus 5 --- README.md | 40 +++ package.json | 2 + .../maintenance/generate_missing_reports.js | 182 ++++++++++ src/utils/dbManager.js | 54 +++ src/utils/reportBackfiller.js | 314 ++++++++++++++++++ test/unit/reportBackfiller.test.js | 296 +++++++++++++++++ 6 files changed, 888 insertions(+) create mode 100644 scripts/maintenance/generate_missing_reports.js create mode 100644 src/utils/reportBackfiller.js create mode 100644 test/unit/reportBackfiller.test.js diff --git a/README.md b/README.md index 7a182a4..732581e 100644 --- a/README.md +++ b/README.md @@ -1222,6 +1222,46 @@ Two implementation details worth knowing before editing `src/utils/rateLimiter.j fixed at 15 minutes for everyone — a user configured with, say, `windowMs: 60000` still gets a 15-minute window. +### Missing Report URLs (recovery patch) + +When snapshot-reports is unavailable, `createReport` fails and the error is deliberately swallowed — +a report failure must never fail the request itself. Those requests end up with no report URL +(`requests.report_data` is NULL, no `report_link` in the archived response). This script generates +the missing URLs after the fact. + +It is a **patch tool, not part of the normal flow**. It only ever adds a missing report URL — it +never replaces an existing one and never re-runs the analysis. Whether a given request deserves a +report (cached? demo? stale?) is the operator's judgement: start with a dry run and read the list. + +```bash +# Dry run (default — nothing is written): what is missing a report URL? +npm run reports:missing -- --all +npm run reports:missing -- --user acme +npm run reports:missing -- --request 16d2bb78c7901c527b8e1b43b8f38b5c + +# Apply +npm run reports:generate -- --request 16d2bb78c7901c527b8e1b43b8f38b5c +npm run reports:generate -- --user acme --limit 50 +npm run reports:generate -- --all + +# Override the report kind for users with no reports.defaultVersion +npm run reports:generate -- --user acme --report-kind v3 + +# Machine-readable output +npm run reports:missing -- --all --json +``` + +For each patched request three artifacts are updated, mirroring the normal processing path: +`requests.report_data` (SQLite), `//report/report.json` (S3), and `report_link` +injected into the archived `//response.json` (S3). + +Requests are skipped — never failed — when they already have a report URL, when the owning user has +no `reports.defaultVersion` (pass `--report-kind` to override), when the user is absent from the +users configuration, or when `genshare/response.json` is missing from S3 (there is nothing to build a +report from). A skip of the last kind prints an S3 `NoSuchKey` line from the shared S3 helper before +the skip is reported — that is expected noise, not a failure. The script exits non-zero only when at +least one request genuinely failed. + ### GenShare Version Management ```bash diff --git a/package.json b/package.json index a600fa5..901ecce 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "db:check": "node scripts/maintenance/initDB.js check", "db:check-notification": "node scripts/maintenance/initDB.js check-scholarone-notifications", "db:list-notifications": "node scripts/maintenance/initDB.js list-scholarone-notifications", + "reports:missing": "node scripts/maintenance/generate_missing_reports.js", + "reports:generate": "node scripts/maintenance/generate_missing_reports.js --apply", "s3:audit": "node scripts/maintenance/refresh_s3_data.js", "s3:refresh": "node scripts/maintenance/refresh_s3_data.js --apply", "s3:refresh:rehash": "node scripts/maintenance/refresh_s3_data.js --apply --rehash", diff --git a/scripts/maintenance/generate_missing_reports.js b/scripts/maintenance/generate_missing_reports.js new file mode 100644 index 0000000..8d0d030 --- /dev/null +++ b/scripts/maintenance/generate_missing_reports.js @@ -0,0 +1,182 @@ +// File: scripts/maintenance/generate_missing_reports.js +// +// Generate report URLs for requests that never got one — typically after a +// snapshot-reports outage, during which createReport failed and the error was +// swallowed so the request itself would not fail. +// +// This is a PATCH tool, not part of the normal flow. It only ever adds a +// missing report URL; it never replaces an existing one and never re-runs the +// analysis. Deciding whether a given request should be patched at all (cached? +// demo? stale?) is the operator's job — start with --dry-run and read the list. +// +// Scope (exactly one required): +// --request <32-hex> a single request +// --user every request of one user +// --all every request, all users +// +// Options: +// --apply actually write (default is DRY-RUN, read-only) +// --limit stop after n candidate requests +// --report-kind override the owning user's reports.defaultVersion +// --json print the raw result object instead of a table +// --help +// +// Examples: +// node scripts/maintenance/generate_missing_reports.js --all +// node scripts/maintenance/generate_missing_reports.js --user acme --apply +// node scripts/maintenance/generate_missing_reports.js --request 16d2… --apply + +const { backfillReportUrls, Outcome } = require('../../src/utils/reportBackfiller'); + +const USAGE = ` +Usage: node scripts/maintenance/generate_missing_reports.js [options] + +Scope (exactly one): + --request <32-hex> Patch a single request + --user Patch every request owned by that user + --all Patch every request, all users + +Options: + --apply Write the changes (default: dry run, nothing is written) + --limit Process at most n candidate requests + --report-kind Use this report kind instead of the user's default + --json Print the raw JSON result + --help Show this message +`; + +const parseArgs = (argv) => { + const args = { + requestId: null, + userId: null, + all: false, + dryRun: true, + limit: null, + reportKind: null, + json: false, + help: false + }; + + for (let i = 2; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '--request': + args.requestId = argv[++i]; + break; + case '--user': + args.userId = argv[++i]; + break; + case '--all': + args.all = true; + break; + case '--apply': + args.dryRun = false; + break; + case '--limit': + args.limit = parseInt(argv[++i], 10); + break; + case '--report-kind': + args.reportKind = argv[++i]; + break; + case '--json': + args.json = true; + break; + case '--help': + case '-h': + args.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +}; + +const OUTCOME_LABEL = { + [Outcome.CREATED]: 'CREATED', + [Outcome.WOULD_CREATE]: 'WOULD CREATE', + [Outcome.SKIPPED_HAS_REPORT]: 'skip (has report)', + [Outcome.SKIPPED_NO_REPORT_KIND]: 'skip (no report kind)', + [Outcome.SKIPPED_UNKNOWN_USER]: 'skip (unknown user)', + [Outcome.SKIPPED_NO_GENSHARE_RESPONSE]: 'skip (no genshare response)', + [Outcome.FAILED]: 'FAILED' +}; + +const printReport = (summary) => { + const mode = summary.dry_run ? 'DRY RUN — nothing was written' : 'APPLIED'; + console.log(`\nScope: ${summary.scope} Mode: ${mode}`); + console.log(`Candidates scanned: ${summary.scanned}\n`); + + if (summary.results.length === 0) { + console.log('No request is missing a report URL for this scope.\n'); + return; + } + + for (const result of summary.results) { + const label = OUTCOME_LABEL[result.outcome] || result.outcome; + console.log(`${result.request_id} ${result.user_id.padEnd(20)} ${label}`); + if (result.report_url) console.log(` url: ${result.report_url}`); + if (result.reason) console.log(` reason: ${result.reason}`); + } + + console.log( + `\nCreated: ${summary.created} Would create: ${summary.would_create} ` + + `Skipped: ${summary.skipped} Failed: ${summary.failed}` + ); + + if (summary.dry_run && summary.would_create > 0) { + console.log('\nRe-run with --apply to create these report URLs.'); + } + console.log(''); +}; + +const main = async () => { + let args; + try { + args = parseArgs(process.argv); + } catch (error) { + console.error(`Error: ${error.message}`); + console.error(USAGE); + process.exitCode = 1; + return; + } + + if (args.help) { + console.log(USAGE); + return; + } + + try { + const summary = await backfillReportUrls({ + requestId: args.requestId, + userId: args.userId, + all: args.all, + dryRun: args.dryRun, + limit: args.limit, + reportKind: args.reportKind + }); + + if (args.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + printReport(summary); + } + + // Non-zero when at least one request could not be patched, so a scripted + // run after an outage fails loudly instead of looking clean. + if (summary.failed > 0) process.exitCode = 1; + } catch (error) { + console.error(`Error: ${error.message}`); + console.error(USAGE); + process.exitCode = 1; + } +}; + +// Several managers pulled in above call watchConfig(), which installs fs.watch +// handles that keep the event loop alive forever. That is what the server +// wants; a one-shot script must exit instead. Force it once main() has settled, +// preserving whatever exit code main() decided on. +main().finally(() => { + // eslint-disable-next-line n/no-process-exit + process.exit(process.exitCode || 0); +}); diff --git a/src/utils/dbManager.js b/src/utils/dbManager.js index ad00a82..addb7bf 100644 --- a/src/utils/dbManager.js +++ b/src/utils/dbManager.js @@ -2683,6 +2683,59 @@ const findDemoRequestByPdfHash = async (pdfHash) => { } }; +/** + * List requests that carry no report URL, for the on-demand report backfill. + * + * `report_data` is a JSON blob whose `report_link` key holds the URL, so a row + * is a candidate when the blob is absent, empty, or has no `report_link` at + * all. The LIKE guard is a coarse pre-filter — reportBackfiller re-checks each + * blob by parsing it, which also catches malformed JSON. + * + * Exactly one of `requestId` / `userId` narrows the scope; passing neither + * scans every user. + * + * @param {Object} options + * @param {string|null} [options.requestId] - restrict to a single request + * @param {string|null} [options.userId] - restrict to one user's requests + * @param {number|null} [options.limit] - cap the number of rows returned + * @returns {Promise>} candidate rows, oldest first + */ +const listRequestsWithoutReportUrl = async ({ requestId = null, userId = null, limit = null } = {}) => { + const db = await getDBConnection(); + try { + const conditions = [ + `(report_data IS NULL OR report_data = '' OR report_data NOT LIKE '%"report_link"%')` + ]; + const params = []; + + if (requestId) { + conditions.push('request_id = ?'); + params.push(requestId); + } + if (userId) { + conditions.push('user_name = ?'); + params.push(userId); + } + + let sql = + `SELECT id, user_name, article_id, request_id, report_data, created_at, updated_at + FROM requests + WHERE ${conditions.join(' AND ')} + ORDER BY created_at ASC`; + + if (Number.isInteger(limit) && limit > 0) { + sql += ' LIMIT ?'; + params.push(limit); + } + + return await new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows || []))); + }); + } finally { + await new Promise((resolve) => db.close(() => resolve())); + } +}; + /** * List every request currently flagged as a demo (for the s3-manager UI). */ @@ -2817,5 +2870,6 @@ module.exports = { setRequestBypassSource, findDemoRequestByPdfHash, listDemoRequests, + listRequestsWithoutReportUrl, getRequestByRequestIdAnyUser }; diff --git a/src/utils/reportBackfiller.js b/src/utils/reportBackfiller.js new file mode 100644 index 0000000..99ac9a8 --- /dev/null +++ b/src/utils/reportBackfiller.js @@ -0,0 +1,314 @@ +// File: src/utils/reportBackfiller.js +// +// On-demand generation of report URLs for requests that never got one. +// +// When snapshot-reports is unavailable, `snapshotReportsManager.createReport` +// throws and genshareManager swallows the error on purpose — a report failure +// must never fail the request itself. The consequence is a request with no +// report URL: `requests.report_data` stays NULL and the archived response +// carries no `report_link`. +// +// This module patches those requests after the fact. It is deliberately narrow: +// +// - it only ever ADDS a missing report URL, never replaces an existing one; +// - it re-runs nothing else — no genshare call, no re-analysis, no repair of +// the request's content. Whether a given request *deserves* a report (is it +// cached? a demo? stale?) is the operator's call, not this module's. +// +// For each patched request three artifacts are updated, mirroring what the +// normal processing path writes: +// 1. `requests.report_data` (SQLite) +// 2. `//report/report.json` (S3) +// 3. `report_link` injected into `//response.json` (S3), +// when that archived API response exists and lacks it. + +const dbManager = require('./dbManager'); +const requestsManager = require('./requestsManager'); +const snapshotReportsManager = require('./snapshotReportsManager'); +const userManager = require('./userManager'); +const { + getGenshareResponseFile, + getApiResponseFile, + uploadBatchToS3, + s3Config +} = require('./s3Storage'); +const { logger } = require('./logger'); + +/** + * Outcome codes reported per request. Anything other than `created` (or + * `would_create` in a dry run) left the request untouched. + */ +const Outcome = { + CREATED: 'created', + WOULD_CREATE: 'would_create', + SKIPPED_HAS_REPORT: 'skipped_has_report', + SKIPPED_NO_REPORT_KIND: 'skipped_no_report_kind', + SKIPPED_UNKNOWN_USER: 'skipped_unknown_user', + SKIPPED_NO_GENSHARE_RESPONSE: 'skipped_no_genshare_response', + FAILED: 'failed' +}; + +/** + * Extract the report URL already stored on a requests row, if any. + * `report_data` is a JSON blob whose `report_link` key holds the URL. + * + * @param {string|null} reportData - raw `requests.report_data` column value + * @returns {string|null} the stored URL, or null when there is none + */ +const readStoredReportUrl = (reportData) => { + if (!reportData) return null; + try { + const parsed = JSON.parse(reportData); + const url = parsed && parsed.report_link; + return typeof url === 'string' && url ? url : null; + } catch { + // A malformed blob is treated as "no report URL" — the row gets rebuilt. + return null; + } +}; + +/** + * Minimal stand-in for a ProcessingSession, good enough for the collaborators + * used here (`snapshotReportsManager.createReport` only calls `addLog`). + * Lines are collected so the caller can surface them per request. + * + * @param {string} requestId + * @returns {{requestId: string, logs: Array, addLog: Function}} + */ +const createLogCollector = (requestId) => { + const logs = []; + return { + requestId, + logs, + addLog(message, level = 'INFO') { + logs.push(`[${level}] ${message}`); + if (level === 'ERROR') { + logger.error(`[backfill][${requestId}] ${message}`); + } else { + logger.info(`[backfill][${requestId}] ${message}`); + } + } + }; +}; + +/** + * Resolve which report kind to use for a request. + * Mirrors the "client sent nothing" branch of genshareManager: the report kind + * is the owning user's configured default. An explicit override wins, which is + * how an operator patches requests whose owner has no default configured. + * + * @param {string} userId - owning user + * @param {string|null} reportKindOverride - operator-supplied kind, or null + * @returns {{reportKind: string|null, unknownUser: boolean}} + */ +const resolveReportKind = (userId, reportKindOverride) => { + if (reportKindOverride) return { reportKind: reportKindOverride, unknownUser: false }; + + let user; + try { + user = userManager.getUserById(userId); + } catch { + return { reportKind: null, unknownUser: true }; + } + + return { reportKind: user.reports?.defaultVersion || null, unknownUser: false }; +}; + +/** + * Add `report_link` to the archived API response so the stored snapshot + * response matches what the client would have received. No-op when the file is + * absent or already carries the field. + * + * @param {string} userId + * @param {string} requestId + * @param {string} reportUrl + * @returns {Promise} true when the archived response was rewritten + */ +const injectReportLinkIntoApiResponse = async (userId, requestId, reportUrl) => { + const apiResponse = await getApiResponseFile(userId, requestId); + if (!apiResponse || !Array.isArray(apiResponse.data)) return false; + + if (apiResponse.data.some((item) => item && item.name === 'report_link')) return false; + + apiResponse.data.push({ + name: 'report_link', + description: 'Report link', + value: reportUrl + }); + + await uploadBatchToS3([ + { + key: `${s3Config.s3Folder}/${userId}/${requestId}/response.json`, + data: JSON.stringify(apiResponse, null, 2), + contentType: 'application/json' + } + ]); + + return true; +}; + +/** + * Generate and persist the missing report URL for a single request row. + * + * @param {Object} row - a `requests` row (needs user_name, request_id, report_data) + * @param {Object} options + * @param {boolean} options.dryRun - when true, resolve eligibility but write nothing + * @param {string|null} options.reportKindOverride + * @returns {Promise} result descriptor for this request + */ +const backfillOne = async (row, { dryRun = true, reportKindOverride = null } = {}) => { + const requestId = row.request_id; + const userId = row.user_name; + const base = { request_id: requestId, user_id: userId, article_id: row.article_id || '' }; + + const existingUrl = readStoredReportUrl(row.report_data); + if (existingUrl) { + return { ...base, outcome: Outcome.SKIPPED_HAS_REPORT, report_url: existingUrl }; + } + + const { reportKind, unknownUser } = resolveReportKind(userId, reportKindOverride); + if (unknownUser) { + return { + ...base, + outcome: Outcome.SKIPPED_UNKNOWN_USER, + reason: `User "${userId}" is not present in the users configuration` + }; + } + if (!reportKind) { + return { + ...base, + outcome: Outcome.SKIPPED_NO_REPORT_KIND, + reason: `User "${userId}" has no reports.defaultVersion; pass an explicit report kind to override` + }; + } + + // The report JSON is built from the same genshare response the normal path + // uses, so a backfilled report is identical to one created inline. + const genshareResponse = await getGenshareResponseFile(userId, requestId); + const responseArray = genshareResponse && genshareResponse.response; + if (!Array.isArray(responseArray)) { + return { + ...base, + outcome: Outcome.SKIPPED_NO_GENSHARE_RESPONSE, + report_kind: reportKind, + reason: 'genshare/response.json is missing or has no response[] array' + }; + } + + if (dryRun) { + return { ...base, outcome: Outcome.WOULD_CREATE, report_kind: reportKind }; + } + + const collector = createLogCollector(requestId); + + try { + const report = await snapshotReportsManager.createReport(reportKind, requestId, collector); + const reportUrl = report.url; + + // buildJSON signals an unknown report version by RETURNING an Error rather + // than throwing — guard explicitly so a bad kind can't be stored as data. + const reportData = requestsManager.buildJSON(reportKind, responseArray, reportUrl); + if (reportData instanceof Error) throw reportData; + + await dbManager.updateRequestReportData(requestId, reportData); + + await uploadBatchToS3([ + { + key: `${s3Config.s3Folder}/${userId}/${requestId}/report/report.json`, + data: JSON.stringify(reportData, null, 2), + contentType: 'application/json' + } + ]); + + const apiResponsePatched = await injectReportLinkIntoApiResponse(userId, requestId, reportUrl); + + return { + ...base, + outcome: Outcome.CREATED, + report_kind: reportKind, + report_url: reportUrl, + was_new: report.is_new === true, + api_response_patched: apiResponsePatched, + logs: collector.logs + }; + } catch (error) { + logger.error(`[backfill][${requestId}] Failed: ${error.message}`); + return { + ...base, + outcome: Outcome.FAILED, + report_kind: reportKind, + reason: error.message, + logs: collector.logs + }; + } +}; + +/** + * Generate the missing report URLs over one of three scopes. + * + * Exactly one scope must be given: + * - `requestId` — a single request + * - `userId` — every request owned by that user + * - `all: true` — every request, all users + * + * Requests are processed sequentially: this is an after-the-fact patch run, and + * snapshot-reports has just come back from an outage — there is no reason to + * hammer it. + * + * @param {Object} options + * @param {string} [options.requestId] + * @param {string} [options.userId] + * @param {boolean} [options.all] + * @param {boolean} [options.dryRun=true] - default is read-only + * @param {number|null} [options.limit=null] - cap on requests processed + * @param {string|null} [options.reportKind=null] - override the per-user default + * @returns {Promise} summary + per-request results + */ +const backfillReportUrls = async ({ + requestId = null, + userId = null, + all = false, + dryRun = true, + limit = null, + reportKind = null +} = {}) => { + const scopes = [requestId && 'request', userId && 'user', all && 'all'].filter(Boolean); + if (scopes.length !== 1) { + throw new Error('Exactly one scope is required: requestId, userId, or all'); + } + if (requestId && !/^[0-9a-f]{32}$/.test(requestId)) { + throw new Error('Invalid request ID format. Must be 32 hexadecimal characters.'); + } + + const candidates = await dbManager.listRequestsWithoutReportUrl({ requestId, userId, limit }); + + const results = []; + for (const row of candidates) { + // eslint-disable-next-line no-await-in-loop + results.push(await backfillOne(row, { dryRun, reportKindOverride: reportKind })); + } + + const countBy = (outcome) => results.filter((r) => r.outcome === outcome).length; + + return { + scope: scopes[0], + dry_run: dryRun, + scanned: candidates.length, + created: countBy(Outcome.CREATED), + would_create: countBy(Outcome.WOULD_CREATE), + skipped: + countBy(Outcome.SKIPPED_HAS_REPORT) + + countBy(Outcome.SKIPPED_NO_REPORT_KIND) + + countBy(Outcome.SKIPPED_UNKNOWN_USER) + + countBy(Outcome.SKIPPED_NO_GENSHARE_RESPONSE), + failed: countBy(Outcome.FAILED), + results + }; +}; + +module.exports = { + Outcome, + readStoredReportUrl, + backfillOne, + backfillReportUrls +}; diff --git a/test/unit/reportBackfiller.test.js b/test/unit/reportBackfiller.test.js new file mode 100644 index 0000000..21b2bdd --- /dev/null +++ b/test/unit/reportBackfiller.test.js @@ -0,0 +1,296 @@ +/** + * Unit tests for the on-demand report URL backfill (reportBackfiller). + */ + +// Mock modules that load conf/*.json at require-time +jest.mock('../../src/utils/dbManager', () => ({ + listRequestsWithoutReportUrl: jest.fn(), + updateRequestReportData: jest.fn().mockResolvedValue(true) +})); + +jest.mock('../../src/utils/requestsManager', () => ({ + buildJSON: jest.fn() +})); + +jest.mock('../../src/utils/snapshotReportsManager', () => ({ + createReport: jest.fn() +})); + +jest.mock('../../src/utils/userManager', () => ({ + getUserById: jest.fn() +})); + +jest.mock('../../src/utils/s3Storage', () => ({ + getGenshareResponseFile: jest.fn(), + getApiResponseFile: jest.fn(), + uploadBatchToS3: jest.fn().mockResolvedValue(undefined), + s3Config: { s3Folder: 'snapshot' } +})); + +jest.mock('../../src/utils/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() } +})); + +const dbManager = require('../../src/utils/dbManager'); +const requestsManager = require('../../src/utils/requestsManager'); +const snapshotReportsManager = require('../../src/utils/snapshotReportsManager'); +const userManager = require('../../src/utils/userManager'); +const s3Storage = require('../../src/utils/s3Storage'); + +const { + Outcome, + readStoredReportUrl, + backfillOne, + backfillReportUrls +} = require('../../src/utils/reportBackfiller'); + +const REQUEST_ID = '16d2bb78c7901c527b8e1b43b8f38b5c'; +const RESPONSE_ARRAY = [ + { name: 'article_id', description: 'Article ID', value: 'KWG1234' }, + { name: 'cumulated_score', description: 'Cumulated score from snapshot', value: 21 } +]; + +const makeRow = (overrides = {}) => ({ + user_name: 'acme', + article_id: 'KWG1234', + request_id: REQUEST_ID, + report_data: null, + ...overrides +}); + +beforeEach(() => { + jest.clearAllMocks(); + + userManager.getUserById.mockReturnValue({ + id: 'acme', + reports: { defaultVersion: 'v3' } + }); + s3Storage.getGenshareResponseFile.mockResolvedValue({ response: RESPONSE_ARRAY }); + s3Storage.getApiResponseFile.mockResolvedValue(null); + snapshotReportsManager.createReport.mockResolvedValue({ + url: 'https://snapshot-reports.dataseer.ai/r/abc', + is_new: true + }); + requestsManager.buildJSON.mockReturnValue({ + article_id: 'KWG1234', + report_link: 'https://snapshot-reports.dataseer.ai/r/abc' + }); +}); + +describe('readStoredReportUrl', () => { + it('returns null when there is no report_data', () => { + expect(readStoredReportUrl(null)).toBeNull(); + expect(readStoredReportUrl('')).toBeNull(); + }); + + it('returns null when the blob carries no report_link', () => { + expect(readStoredReportUrl(JSON.stringify({ article_id: 'KWG1234' }))).toBeNull(); + }); + + it('treats a malformed blob as "no report URL"', () => { + expect(readStoredReportUrl('{not json')).toBeNull(); + }); + + it('returns the stored URL when present', () => { + const blob = JSON.stringify({ report_link: 'https://reports/r/x' }); + expect(readStoredReportUrl(blob)).toBe('https://reports/r/x'); + }); +}); + +describe('backfillOne — dry run', () => { + it('reports would_create and writes nothing', async () => { + const result = await backfillOne(makeRow(), { dryRun: true }); + + expect(result.outcome).toBe(Outcome.WOULD_CREATE); + expect(result.report_kind).toBe('v3'); + expect(snapshotReportsManager.createReport).not.toHaveBeenCalled(); + expect(dbManager.updateRequestReportData).not.toHaveBeenCalled(); + expect(s3Storage.uploadBatchToS3).not.toHaveBeenCalled(); + }); +}); + +describe('backfillOne — skips', () => { + it('never replaces an existing report URL', async () => { + const row = makeRow({ + report_data: JSON.stringify({ report_link: 'https://reports/r/existing' }) + }); + + const result = await backfillOne(row, { dryRun: false }); + + expect(result.outcome).toBe(Outcome.SKIPPED_HAS_REPORT); + expect(result.report_url).toBe('https://reports/r/existing'); + expect(snapshotReportsManager.createReport).not.toHaveBeenCalled(); + expect(dbManager.updateRequestReportData).not.toHaveBeenCalled(); + }); + + it('skips a user with no configured report kind', async () => { + userManager.getUserById.mockReturnValue({ id: 'acme', reports: {} }); + + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.outcome).toBe(Outcome.SKIPPED_NO_REPORT_KIND); + expect(snapshotReportsManager.createReport).not.toHaveBeenCalled(); + }); + + it('skips a user missing from the users configuration', async () => { + userManager.getUserById.mockImplementation(() => { + throw new Error('User acme not found'); + }); + + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.outcome).toBe(Outcome.SKIPPED_UNKNOWN_USER); + }); + + it('skips when the genshare response is missing from S3', async () => { + s3Storage.getGenshareResponseFile.mockResolvedValue(null); + + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.outcome).toBe(Outcome.SKIPPED_NO_GENSHARE_RESPONSE); + expect(snapshotReportsManager.createReport).not.toHaveBeenCalled(); + }); + + it('uses the override kind for a user with no default', async () => { + userManager.getUserById.mockReturnValue({ id: 'acme', reports: {} }); + + const result = await backfillOne(makeRow(), { + dryRun: false, + reportKindOverride: 'v3' + }); + + expect(result.outcome).toBe(Outcome.CREATED); + expect(snapshotReportsManager.createReport).toHaveBeenCalledWith( + 'v3', + REQUEST_ID, + expect.anything() + ); + }); +}); + +describe('backfillOne — apply', () => { + it('creates the report and persists it to the DB and S3', async () => { + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.outcome).toBe(Outcome.CREATED); + expect(result.report_url).toBe('https://snapshot-reports.dataseer.ai/r/abc'); + + expect(requestsManager.buildJSON).toHaveBeenCalledWith( + 'v3', + RESPONSE_ARRAY, + 'https://snapshot-reports.dataseer.ai/r/abc' + ); + expect(dbManager.updateRequestReportData).toHaveBeenCalledWith(REQUEST_ID, { + article_id: 'KWG1234', + report_link: 'https://snapshot-reports.dataseer.ai/r/abc' + }); + + const uploadedKeys = s3Storage.uploadBatchToS3.mock.calls + .flatMap(([files]) => files) + .map((f) => f.key); + expect(uploadedKeys).toContain(`snapshot/acme/${REQUEST_ID}/report/report.json`); + }); + + it('injects report_link into the archived API response when absent', async () => { + s3Storage.getApiResponseFile.mockResolvedValue({ + status: 200, + data: [{ name: 'article_id', description: 'Article ID', value: 'KWG1234' }] + }); + + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.api_response_patched).toBe(true); + + const responseUpload = s3Storage.uploadBatchToS3.mock.calls + .flatMap(([files]) => files) + .find((f) => f.key === `snapshot/acme/${REQUEST_ID}/response.json`); + expect(responseUpload).toBeDefined(); + + const written = JSON.parse(responseUpload.data); + expect(written.data).toContainEqual({ + name: 'report_link', + description: 'Report link', + value: 'https://snapshot-reports.dataseer.ai/r/abc' + }); + }); + + it('leaves an archived response that already has report_link untouched', async () => { + s3Storage.getApiResponseFile.mockResolvedValue({ + status: 200, + data: [{ name: 'report_link', description: 'Report link', value: 'https://reports/r/old' }] + }); + + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.api_response_patched).toBe(false); + const keys = s3Storage.uploadBatchToS3.mock.calls + .flatMap(([files]) => files) + .map((f) => f.key); + expect(keys).not.toContain(`snapshot/acme/${REQUEST_ID}/response.json`); + }); + + it('reports a failure when snapshot-reports is still down', async () => { + snapshotReportsManager.createReport.mockRejectedValue(new Error('connect ECONNREFUSED')); + + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.outcome).toBe(Outcome.FAILED); + expect(result.reason).toBe('connect ECONNREFUSED'); + expect(dbManager.updateRequestReportData).not.toHaveBeenCalled(); + }); + + it('fails instead of storing an Error when buildJSON rejects the report kind', async () => { + requestsManager.buildJSON.mockReturnValue(new Error("Report version 'v3' not found")); + + const result = await backfillOne(makeRow(), { dryRun: false }); + + expect(result.outcome).toBe(Outcome.FAILED); + expect(result.reason).toContain('not found'); + expect(dbManager.updateRequestReportData).not.toHaveBeenCalled(); + }); +}); + +describe('backfillReportUrls — scopes', () => { + it('requires exactly one scope', async () => { + await expect(backfillReportUrls({})).rejects.toThrow('Exactly one scope'); + await expect(backfillReportUrls({ userId: 'acme', all: true })).rejects.toThrow( + 'Exactly one scope' + ); + }); + + it('rejects a malformed request id', async () => { + await expect(backfillReportUrls({ requestId: 'nope' })).rejects.toThrow( + 'Invalid request ID format' + ); + }); + + it('summarises a mixed run', async () => { + dbManager.listRequestsWithoutReportUrl.mockResolvedValue([ + makeRow(), + makeRow({ request_id: 'a'.repeat(32), user_name: 'other' }) + ]); + userManager.getUserById + .mockReturnValueOnce({ id: 'acme', reports: { defaultVersion: 'v3' } }) + .mockReturnValueOnce({ id: 'other', reports: {} }); + + const summary = await backfillReportUrls({ all: true, dryRun: false }); + + expect(summary.scope).toBe('all'); + expect(summary.scanned).toBe(2); + expect(summary.created).toBe(1); + expect(summary.skipped).toBe(1); + expect(summary.failed).toBe(0); + }); + + it('passes the scope through to the DB query', async () => { + dbManager.listRequestsWithoutReportUrl.mockResolvedValue([]); + + await backfillReportUrls({ userId: 'acme', limit: 10 }); + + expect(dbManager.listRequestsWithoutReportUrl).toHaveBeenCalledWith({ + requestId: null, + userId: 'acme', + limit: 10 + }); + }); +}); From cc5e02bf2746d780bfa20ba8a9413eba43439e85 Mon Sep 17 00:00:00 2001 From: Nicolas Kieffer Date: Tue, 1 Sep 2026 18:53:54 +0200 Subject: [PATCH 3/4] docs(deploy): document the systemd deployment and restart policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the EC2 instances the container is managed by systemd, not by the docker run snippets in this file, and the units carry no Restart= directive — a container that loses the boot race against dockerd stays down until someone notices. Document how the service is actually run, the Restart=always drop-in, and why systemctl must be used instead of docker stop. Co-Authored-By: Claude Opus 5 --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/README.md b/README.md index 732581e..34f2aeb 100644 --- a/README.md +++ b/README.md @@ -1529,6 +1529,76 @@ docker run -d \ snapshot-api:latest ``` +### Deployment on EC2 (systemd) + +On the `snapshot-dev` and `snapshot-prod` instances the container is **managed by systemd**, not by +the `docker run` snippet above and not by any compose file. The unit is +`/etc/systemd/system/snapshot-api.service`; it runs the ECR image in the foreground +(`docker run --rm --name snapshot-api-instance --init --network host …`) with `Type=simple`, so +systemd supervises the `docker run` client rather than the container itself. + +Manage it through systemd, never through Docker directly: + +```bash +sudo systemctl status snapshot-api +sudo systemctl restart snapshot-api +sudo systemctl stop snapshot-api # NOT `docker stop snapshot-api-instance` +journalctl -u snapshot-api -b --no-pager # container stdout/stderr for this boot +``` + +`docker stop snapshot-api-instance` is undone by systemd within seconds (see the restart policy +below), and because the unit uses `--rm` there is no exited container left to inspect — the journal +is the only post-mortem source. + +#### Restart policy + +The unit files ship with no `Restart=` directive, which means systemd defaults to `Restart=no`: a +container that exits for any reason — crash, forced instance stop, or losing the race against +`dockerd` at boot — stays down permanently. In August 2026 this took snapshot-reports offline long +enough that report URLs stopped being created. + +The policy is supplied by a drop-in rather than by editing the unit, so the unit file is untouched +and rollback is deleting one file: + +```ini +# /etc/systemd/system/snapshot-api.service.d/10-restart.conf +[Unit] +StartLimitIntervalSec=0 + +[Service] +Restart=always +RestartSec=5 +``` + +```bash +sudo mkdir -p /etc/systemd/system/snapshot-api.service.d +sudo tee /etc/systemd/system/snapshot-api.service.d/10-restart.conf >/dev/null <<'EOF' +[Unit] +StartLimitIntervalSec=0 + +[Service] +Restart=always +RestartSec=5 +EOF +sudo systemctl daemon-reload + +# Verify +systemctl show snapshot-api -p Restart -p RestartUSec -p StartLimitIntervalUSec +# expect: Restart=always RestartUSec=5s StartLimitIntervalUSec=0 + +# Roll back +sudo rm -rf /etc/systemd/system/snapshot-api.service.d && sudo systemctl daemon-reload +``` + +`daemon-reload` only re-reads configuration — it does not stop, start or restart the running +container, so this is safe to apply at any time. `StartLimitIntervalSec=0` is required, not +cosmetic: without it systemd's default limit of 5 starts in 10 s puts a fast-failing unit into +`failed` permanently, which is the exact failure the policy is meant to prevent. + +Known limitation: if an unclean shutdown leaves a container holding the `--name`, restarts loop on a +name conflict. Adding `ExecStartPre=-/usr/bin/docker rm -f snapshot-api-instance` to the drop-in +covers it. + ### Environment Variables for Production For production deployments, make sure to set the following environment variables: From 3d1f898a2fa945ba635bc73e9a360cf0f0765a6f Mon Sep 17 00:00:00 2001 From: Nicolas Kieffer Date: Tue, 1 Sep 2026 19:08:16 +0200 Subject: [PATCH 4/4] chore(release): 3.18.0 --- CHANGELOG.md | 18 ++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe97051..7c1625a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [3.18.0](https://github.com/DataSeer/snapshot-api/compare/v3.17.1...v3.18.0) (2026-09-01) + + +### Features + +* **reports:** generate missing report URLs on demand ([7816df8](https://github.com/DataSeer/snapshot-api/commits/7816df81e6c156a5d9779cbb7e314562df5c5ad6)) + + +### Bug Fixes + +* **rate-limit:** reject every request when a user max is 0 ([9c8aade](https://github.com/DataSeer/snapshot-api/commits/9c8aade851a7916305c684204b23681d69f0aedd)) + + +### Documentation + +* **deploy:** document the systemd deployment and restart policy ([cc5e02b](https://github.com/DataSeer/snapshot-api/commits/cc5e02bf2746d780bfa20ba8a9413eba43439e85)) +* refresh API documentation and guard internal-only files ([5e11e95](https://github.com/DataSeer/snapshot-api/commits/5e11e95413933664014656ea5c86eceab5bf8a8d)) + ### [3.17.1](https://github.com/DataSeer/snapshot-api/compare/v3.17.0...v3.17.1) (2026-07-14) diff --git a/package-lock.json b/package-lock.json index 47bdc62..13a33b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodejs-rest-api", - "version": "3.17.1", + "version": "3.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodejs-rest-api", - "version": "3.17.1", + "version": "3.18.0", "dependencies": { "@aws-sdk/client-s3": "^3.804.0", "archiver": "^7.0.1", diff --git a/package.json b/package.json index 901ecce..322dde0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodejs-rest-api", - "version": "3.17.1", + "version": "3.18.0", "description": "nodejs REST API to interact with GenShare", "main": "src/server.js", "engines": {