mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(media-proxy): sign attachment URLs and gate origins (#2830)
This commit is contained in:
1 parent
025c01ab13
commit
522cf08e61
108 files changed
+10148
-441
No files matched your search
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {signAttachmentUrl} from '@app/api/attachment/AttachmentUrls';
|
||||
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
RefreshAttachmentUrlsRequest,
|
||||
RefreshAttachmentUrlsResponse,
|
||||
} from '@fluxer/schema/src/domains/message/AttachmentSchemas';
|
||||
|
||||
export function AttachmentController(app: HonoApp) {
|
||||
app.post(
|
||||
'/attachments/refresh-urls',
|
||||
RateLimitMiddleware(RateLimitConfigs.ATTACHMENT_URLS_REFRESH),
|
||||
LoginRequired,
|
||||
Validator('json', RefreshAttachmentUrlsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'refresh_attachment_urls',
|
||||
summary: 'Refresh attachment URLs',
|
||||
responseSchema: RefreshAttachmentUrlsResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'sessionToken'],
|
||||
tags: ['Messages'],
|
||||
description:
|
||||
'Reissues the expiring signature on attachment URLs. Returns one entry per requested URL, in the order they were requested, each pairing the URL exactly as it was sent with a freshly signed copy. A URL that is not an attachment URL of this instance is returned unchanged. No membership or existence check is performed.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const urls = ctx.req.valid('json').attachment_urls;
|
||||
return ctx.json({
|
||||
refreshed_urls: urls.map((original) => ({original, refreshed: signAttachmentUrl(original)})),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AttachmentID, ChannelID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {extractTimestampBigInt} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {
|
||||
attachmentStorageKeyFromUrl,
|
||||
type SignAttachmentUrlOptions,
|
||||
signDataPackageAttachmentUrl as signDataPackageWithSecret,
|
||||
signAttachmentUrl as signWithSecret,
|
||||
stripAttachmentSignature as stripSignature,
|
||||
} from '@pkgs/media_proxy_utils/src/AttachmentUrlSignature';
|
||||
|
||||
const SNOWFLAKE_SEGMENT_REGEX = /^[0-9]{1,20}$/u;
|
||||
const STORAGE_KEY_MIN_SEGMENTS = 4;
|
||||
|
||||
function signingSecret(): Buffer | null {
|
||||
const configured = Config.mediaProxy.attachmentUrls.secretsBase64[0];
|
||||
if (!configured) return null;
|
||||
const secret = Buffer.from(configured, 'base64');
|
||||
return secret.length === 0 ? null : secret;
|
||||
}
|
||||
|
||||
function anchorSecsFromStorageKey(storageKey: string): number | null {
|
||||
const segments = storageKey.split('/');
|
||||
if (segments.length < STORAGE_KEY_MIN_SEGMENTS || segments[0] !== 'attachments') return null;
|
||||
const attachmentId = segments[2] as string;
|
||||
if (!SNOWFLAKE_SEGMENT_REGEX.test(segments[1] as string) || !SNOWFLAKE_SEGMENT_REGEX.test(attachmentId)) return null;
|
||||
return Math.floor(extractTimestampBigInt(BigInt(attachmentId)) / 1000);
|
||||
}
|
||||
|
||||
function signingOptions(url: string, nowSecs?: number): SignAttachmentUrlOptions | null {
|
||||
const secret = signingSecret();
|
||||
if (secret === null) return null;
|
||||
const mediaEndpoint = Config.endpoints.media;
|
||||
const storageKey = attachmentStorageKeyFromUrl(url, mediaEndpoint);
|
||||
if (storageKey === null) return null;
|
||||
const anchorSecs = anchorSecsFromStorageKey(storageKey);
|
||||
if (anchorSecs === null) return null;
|
||||
return {mediaEndpoint, secret, nowSecs: nowSecs ?? Math.floor(Date.now() / 1000), anchorSecs};
|
||||
}
|
||||
|
||||
export function signAttachmentUrl(url: string, nowSecs?: number): string {
|
||||
const options = signingOptions(url, nowSecs);
|
||||
return options === null ? url : signWithSecret(url, options);
|
||||
}
|
||||
|
||||
export function signDataPackageAttachmentUrl(url: string, nowSecs?: number): string {
|
||||
const options = signingOptions(url, nowSecs);
|
||||
return options === null ? url : signDataPackageWithSecret(url, options);
|
||||
}
|
||||
|
||||
export function stripAttachmentSignature(url: string): string {
|
||||
return stripSignature(url);
|
||||
}
|
||||
|
||||
export function stripOwnAttachmentSignature(url: string): string {
|
||||
return attachmentStorageKeyFromUrl(url, Config.endpoints.media) === null ? url : stripSignature(url);
|
||||
}
|
||||
|
||||
export function makeSignedAttachmentCdnUrl(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID | bigint,
|
||||
filename: string,
|
||||
): string {
|
||||
return signAttachmentUrl(makeAttachmentCdnUrl(channelId, attachmentId, filename));
|
||||
}
|
||||
|
||||
export function makeDataPackageAttachmentCdnUrl(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID | bigint,
|
||||
filename: string,
|
||||
): string {
|
||||
return signDataPackageAttachmentUrl(makeAttachmentCdnUrl(channelId, attachmentId, filename));
|
||||
}
|
||||
Reference in new issue
Block a user