diff --git a/ansible/roles/api-server/templates/start.sh b/ansible/roles/api-server/templates/start.sh index ae322c81d..250dc624e 100644 --- a/ansible/roles/api-server/templates/start.sh +++ b/ansible/roles/api-server/templates/start.sh @@ -4,4 +4,4 @@ export SPYGLASSMC_API_SERVER_DIR="/var/lib/api-server" export SPYGLASSMC_API_SERVER_WEBHOOK_SECRET="{{ api_server_webhook_secret }}" export SPYGLASSMC_API_SERVER_PORT="{{ api_server_port }}" -/usr/local/bin/spyglassmc-web-api-server +/usr/local/bin/spyglassmc-web-api-server --color diff --git a/packages/web-api-server/src/index.ts b/packages/web-api-server/src/index.ts index b4dfce80e..ef39b5af9 100644 --- a/packages/web-api-server/src/index.ts +++ b/packages/web-api-server/src/index.ts @@ -56,9 +56,21 @@ const app = express() 'ETag', 'RateLimit-Limit', 'RateLimit-Remaining', + 'RateLimit-Reset', 'Retry-After', ], })) + .use((_req, res, next) => { + // 'max-age=0' instead of 'no-cache' is used, as 'no-cache' disallows the use of stale + // response in cases where the origin server is unreachable. + res.setHeader('Cache-Control', 'max-age=0') + + res.contentType('application/json') + res.appendHeader('X-Content-Type-Options', 'nosniff') + res.appendHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload') + + next() + }) .use(logger) .use(userAgentEnforcer) .use(slowDown({ @@ -106,7 +118,7 @@ const app = express() ) .get('/favicon.ico', cheapRateLimiter, (_req, res) => { res.contentType('image/x-icon') - res.appendHeader('Cache-Control', 'public, max-age=604800') + res.setHeader('Cache-Control', 'max-age=604800, public') res.sendFile(fileURLToPath(new URL('../favicon.ico', import.meta.url))) }) .all('*catchall', cheapRateLimiter, (_req, res) => { diff --git a/packages/web-api-server/src/utils.ts b/packages/web-api-server/src/utils.ts index 35476c34e..5bda748b0 100644 --- a/packages/web-api-server/src/utils.ts +++ b/packages/web-api-server/src/utils.ts @@ -96,8 +96,9 @@ export async function sendGitFile( // since the same value is used for different representations of the same resource (e.g. gzip // compressed v.s. no compression). It can only guarantee semantic equivalence, not byte-for-byte // equivalence. - res.setHeader('ETag', `W/"${hash}"`) - if (req.headers['if-none-match'] === hash) { + const etag = `W/"${hash}"` + res.setHeader('ETag', etag) + if (req.headers['if-none-match'] === etag) { res.status(304).end() await rateLimiter.reward(req.ip!, CHEAP_REQUEST_POINTS) } else { @@ -122,8 +123,9 @@ export async function sendGitTarball( ) { const hash = (await git.log(['--format=%H', '-1', branch])).latest!.hash // See comments in sendGitFile() for why this is a weak validator. - res.setHeader('ETag', `W/"${hash}"`) - if (req.headers['if-none-match'] === hash) { + const etag = `W/"${hash}"` + res.setHeader('ETag', etag) + if (req.headers['if-none-match'] === etag) { res.status(304).end() await rateLimiter.reward(req.ip!, EXPENSIVE_REQUEST_POINTS) } else { @@ -171,7 +173,6 @@ export const loggerMiddleware = (req: Request, res: Response, next: NextFunction }...`, ), ) - res.contentType('application/json') res.on('finish', () => { const end = new Date() const message = @@ -214,6 +215,10 @@ const getRateLimiter = try { const result = await rateLimiter.consume(req.ip!, points) res.appendHeader('RateLimit-Remaining', `${result.remainingPoints}`) + res.appendHeader( + 'RateLimit-Reset', + `${new Date(Date.now() + result.msBeforeNext).toUTCString()}`, + ) next() } catch (e) { if (e instanceof RateLimiterRes) {