diff --git a/fluxer_api/src/api/app/MiddlewarePipeline.ts b/fluxer_api/src/api/app/MiddlewarePipeline.ts index 96cd460ea..742657d21 100644 --- a/fluxer_api/src/api/app/MiddlewarePipeline.ts +++ b/fluxer_api/src/api/app/MiddlewarePipeline.ts @@ -36,6 +36,20 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName); routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'})); routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'})); + routes.use( + '/.well-known/fluxer', + cors({ + origins: '*', + methods: ['GET', 'HEAD', 'OPTIONS'], + allowedHeaders: [ + HttpHeaders.ACCEPT, + HttpHeaders.CONTENT_TYPE, + HttpHeaders.IF_MODIFIED_SINCE, + HttpHeaders.IF_NONE_MATCH, + ], + exposedHeaders: [HttpHeaders.ETAG, HttpHeaders.LAST_MODIFIED], + }), + ); applyMiddlewareStack(routes, { requestId: {}, cors: {origins: corsOrigins, exposedHeaders: [HttpHeaders.X_FLUXER_VERSION]}, diff --git a/fluxer_api/src/api/instance/DiscoveryValidators.ts b/fluxer_api/src/api/instance/DiscoveryValidators.ts new file mode 100644 index 000000000..0d545cbc8 --- /dev/null +++ b/fluxer_api/src/api/instance/DiscoveryValidators.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash} from 'node:crypto'; +import {entityTagMatches} from '../utils/EntityTag'; + +export interface DiscoveryValidators { + etag: string; + lastModified: Date; +} + +export function nextDiscoveryValidators(document: unknown, previous: DiscoveryValidators | null): DiscoveryValidators { + const etag = `"${createHash('sha256').update(JSON.stringify(document)).digest('hex')}"`; + if (previous !== null && previous.etag === etag) { + return previous; + } + return {etag, lastModified: new Date(Math.floor(Date.now() / 1000) * 1000)}; +} + +export function isDiscoveryNotModified( + validators: DiscoveryValidators, + ifNoneMatch: string | undefined, + ifModifiedSince: string | undefined, +): boolean { + if (ifNoneMatch !== undefined) { + return entityTagMatches(ifNoneMatch, validators.etag); + } + if (ifModifiedSince === undefined) { + return false; + } + const since = Date.parse(ifModifiedSince); + return !Number.isNaN(since) && validators.lastModified.getTime() <= since; +} diff --git a/fluxer_api/src/api/instance/InstanceController.ts b/fluxer_api/src/api/instance/InstanceController.ts index da8f6af4e..4d43fa7ea 100644 --- a/fluxer_api/src/api/instance/InstanceController.ts +++ b/fluxer_api/src/api/instance/InstanceController.ts @@ -13,8 +13,11 @@ import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware'; import {OpenAPI} from '../middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '../RateLimitConfig'; import type {HonoEnv} from '../types/HonoEnv'; +import {type DiscoveryValidators, isDiscoveryNotModified, nextDiscoveryValidators} from './DiscoveryValidators'; import type {InstanceCaptchaEffectiveConfig} from './InstanceConfigRepository'; +let discoveryValidators: DiscoveryValidators | null = null; + function buildDiscoveryStaticInput( gifService: GifService | undefined, appPublic: InstanceAppPublic, @@ -131,6 +134,18 @@ export function InstanceController(app: Hono) { limits, }, ); + discoveryValidators = nextDiscoveryValidators(response, discoveryValidators); + ctx.header('ETag', discoveryValidators.etag); + ctx.header('Last-Modified', discoveryValidators.lastModified.toUTCString()); + if ( + isDiscoveryNotModified( + discoveryValidators, + ctx.req.header('If-None-Match'), + ctx.req.header('If-Modified-Since'), + ) + ) { + return ctx.body(null, 304); + } return ctx.json(response); }, ); diff --git a/fluxer_api/src/api/utils/EntityTag.ts b/fluxer_api/src/api/utils/EntityTag.ts new file mode 100644 index 000000000..a4747b3e4 --- /dev/null +++ b/fluxer_api/src/api/utils/EntityTag.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +function stripWeakness(entityTag: string): string { + return entityTag.startsWith('W/') ? entityTag.slice(2) : entityTag; +} + +export function entityTagMatches(ifNoneMatch: string, etag: string): boolean { + const target = stripWeakness(etag.trim()); + return ifNoneMatch + .split(',') + .map((candidate) => candidate.trim()) + .some((candidate) => candidate === '*' || stripWeakness(candidate) === target); +}