feat(media-proxy): sign attachment URLs and gate origins (#2830)

This commit is contained in:
Hampus
2026-09-18 15:57:32 +02:00
committed by GitHub
parent 025c01ab13
commit 522cf08e61
108 changed files with 10148 additions and 441 deletions
@@ -24,6 +24,10 @@
"import": "./src/MediaProxySigner.ts",
"types": "./src/MediaProxySigner.ts"
},
"./src/AttachmentUrlSignature": {
"import": "./src/AttachmentUrlSignature.ts",
"types": "./src/AttachmentUrlSignature.ts"
},
"./*": "./*"
},
"main": "./src/MediaProxyUtils.ts",
@@ -0,0 +1,211 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
export const ATTACHMENT_URL_TTL_SECS = 86_400;
export const ATTACHMENT_URL_BUCKET_SECS = 43_200;
export const ORDINARY_USAGE = '';
export const DATA_PACKAGE_USAGE = 'dp';
export type AttachmentUrlUsage = typeof ORDINARY_USAGE | typeof DATA_PACKAGE_USAGE;
export type SignatureParameterName = 'ex' | 'is' | 'hm' | 'uc';
const SIGNATURE_DOMAIN = 'fluxer-attachment-url-v1';
const ATTACHMENT_PATH_PREFIX = '/attachments/';
const SIGNATURE_PARAMETER_NAMES: ReadonlyArray<SignatureParameterName> = ['ex', 'is', 'hm', 'uc'];
const DATA_PACKAGE_EXPIRES = '0';
const WINDOW_HEX_LENGTH = 8;
const MAX_WINDOW_SECS = 0xff_ff_ff_ff;
const LEADING_SLASHES_REGEX = /^\/+/u;
const TRAILING_SLASHES_REGEX = /\/+$/u;
const textEncoder = new TextEncoder();
const strictTextDecoder = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true});
export interface AttachmentUrlWindow {
issued: number;
expires: number;
}
export interface SignAttachmentUrlOptions {
mediaEndpoint: string;
secret: Uint8Array;
nowSecs: number;
anchorSecs: number;
}
function hexNibble(byte: number): number {
if (byte >= 0x30 && byte <= 0x39) return byte - 0x30;
if (byte >= 0x41 && byte <= 0x46) return byte - 0x41 + 10;
if (byte >= 0x61 && byte <= 0x66) return byte - 0x61 + 10;
return -1;
}
function percentDecodeBytes(value: string, plusAsSpace: boolean): Uint8Array {
const bytes = textEncoder.encode(value);
const decoded = new Uint8Array(bytes.length);
let length = 0;
let index = 0;
while (index < bytes.length) {
const byte = bytes[index] as number;
if (byte === 0x25 && index + 2 < bytes.length) {
const high = hexNibble(bytes[index + 1] as number);
const low = hexNibble(bytes[index + 2] as number);
if (high >= 0 && low >= 0) {
decoded[length] = (high << 4) | low;
length += 1;
index += 3;
continue;
}
}
decoded[length] = plusAsSpace && byte === 0x2b ? 0x20 : byte;
length += 1;
index += 1;
}
return decoded.subarray(0, length);
}
export function percentDecodeStorageKey(path: string): string | null {
try {
return strictTextDecoder.decode(percentDecodeBytes(path.replace(LEADING_SLASHES_REGEX, ''), false));
} catch {
return null;
}
}
export function signatureParameterName(name: string): SignatureParameterName | null {
const decoded = percentDecodeBytes(name, true);
if (decoded.length !== 2) return null;
const candidate = String.fromCharCode(decoded[0] as number, decoded[1] as number);
return SIGNATURE_PARAMETER_NAMES.find((entry) => entry === candidate) ?? null;
}
export function isSignatureParameterName(name: string): boolean {
return signatureParameterName(name) !== null;
}
function isSafeStorageKey(key: string): boolean {
if (key.length === 0 || key.startsWith('/')) return false;
return key
.split('/')
.every((component) => component.length > 0 && component !== '.' && component !== '..' && !component.includes('\0'));
}
function firstIndexOf(value: string, characters: ReadonlyArray<string>): number {
let found = -1;
for (const character of characters) {
const index = value.indexOf(character);
if (index >= 0 && (found < 0 || index < found)) {
found = index;
}
}
return found;
}
function rawPathFromUrl(url: string): string | null {
const schemeIndex = url.indexOf('://');
if (schemeIndex < 0) return null;
const afterAuthority = url.slice(schemeIndex + 3);
const boundary = firstIndexOf(afterAuthority, ['/', '?', '#']);
if (boundary < 0 || afterAuthority[boundary] !== '/') return '';
const path = afterAuthority.slice(boundary);
const queryIndex = firstIndexOf(path, ['?', '#']);
return queryIndex < 0 ? path : path.slice(0, queryIndex);
}
function parseWebUrl(value: string): URL | null {
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed : null;
} catch {
return null;
}
}
export function attachmentStorageKeyFromUrl(url: string, mediaEndpoint: string): string | null {
const target = parseWebUrl(url);
const endpoint = parseWebUrl(mediaEndpoint);
if (!target || !endpoint || target.origin !== endpoint.origin) return null;
const path = rawPathFromUrl(url);
if (path === null) return null;
const endpointPath = (rawPathFromUrl(mediaEndpoint) ?? '').replace(TRAILING_SLASHES_REGEX, '');
if (!path.startsWith(`${endpointPath}${ATTACHMENT_PATH_PREFIX}`)) return null;
const storageKey = percentDecodeStorageKey(path.slice(endpointPath.length));
if (storageKey === null || !isSafeStorageKey(storageKey)) return null;
return storageKey;
}
interface SplitUrl {
base: string;
query: string;
fragment: string;
}
function splitUrl(url: string): SplitUrl {
const fragmentIndex = url.indexOf('#');
const head = fragmentIndex < 0 ? url : url.slice(0, fragmentIndex);
const fragment = fragmentIndex < 0 ? '' : url.slice(fragmentIndex);
const queryIndex = head.indexOf('?');
if (queryIndex < 0) return {base: head, query: '', fragment};
return {base: head.slice(0, queryIndex), query: head.slice(queryIndex + 1), fragment};
}
function preservedFields(query: string): Array<string> {
if (query.length === 0) return [];
return query.split('&').filter((field) => {
if (field.length === 0 || field === '=') return false;
const separator = field.indexOf('=');
return !isSignatureParameterName(separator < 0 ? field : field.slice(0, separator));
});
}
export function stripAttachmentSignature(url: string): string {
const {base, query, fragment} = splitUrl(url);
const preserved = preservedFields(query);
if (preserved.length === 0) return `${base}${fragment}`;
return `${base}?${preserved.join('&')}${fragment}`;
}
export function issueWindow(anchorSecs: number, nowSecs: number): AttachmentUrlWindow {
const elapsed = Math.max(0, nowSecs - anchorSecs);
const issued = anchorSecs + Math.floor(elapsed / ATTACHMENT_URL_BUCKET_SECS) * ATTACHMENT_URL_BUCKET_SECS;
return {issued, expires: issued + ATTACHMENT_URL_TTL_SECS};
}
export function canonicalInput(storageKey: string, exHex: string, isHex: string, usage: AttachmentUrlUsage): string {
return `${SIGNATURE_DOMAIN}\n${exHex}\n${isHex}\n${usage}\n${storageKey}`;
}
function windowHex(value: number): string {
return value.toString(16).padStart(WINDOW_HEX_LENGTH, '0');
}
function signUsage(url: string, options: SignAttachmentUrlOptions, usage: AttachmentUrlUsage): string {
const storageKey = attachmentStorageKeyFromUrl(url, options.mediaEndpoint);
if (storageKey === null) return url;
const {issued, expires} = issueWindow(options.anchorSecs, options.nowSecs);
if (!Number.isSafeInteger(issued) || issued < 0 || expires > MAX_WINDOW_SECS) return url;
const isDataPackage = usage === DATA_PACKAGE_USAGE;
const exHex = windowHex(isDataPackage ? 0 : expires);
const isHex = windowHex(issued);
const signature = crypto
.createHmac('sha256', options.secret)
.update(canonicalInput(storageKey, exHex, isHex, usage))
.digest('hex');
const signatureFields = isDataPackage
? `ex=${DATA_PACKAGE_EXPIRES}&is=${isHex}&hm=${signature}&uc=${DATA_PACKAGE_USAGE}`
: `ex=${exHex}&is=${isHex}&hm=${signature}`;
const {base, query, fragment} = splitUrl(url);
const preserved = preservedFields(query);
const fields = preserved.length === 0 ? signatureFields : `${signatureFields}&${preserved.join('&')}`;
return `${base}?${fields}${fragment}`;
}
export function signAttachmentUrl(url: string, options: SignAttachmentUrlOptions): string {
return signUsage(url, options, ORDINARY_USAGE);
}
export function signDataPackageAttachmentUrl(url: string, options: SignAttachmentUrlOptions): string {
return signUsage(url, options, DATA_PACKAGE_USAGE);
}
+3
View File
@@ -263,6 +263,9 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
tokenTtlSecs: uploadRelayConfig.token_ttl_secs,
keepDirectCountries: uploadRelayConfig.keep_direct_countries,
},
attachmentUrls: {
secretsBase64: master.services.media_proxy.attachment_urls.secrets_base64,
},
},
geoip: geoipSourceConfig,
proxy: {
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerAdminControllers} from '@app/api/admin/controllers/index';
import {AttachmentController} from '@app/api/attachment/AttachmentController';
import {AuthController} from '@app/api/auth/AuthController';
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
import {Config} from '@app/api/Config';
@@ -45,6 +46,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
GeolocationController(routes);
registerAdminControllers(routes);
AuthController(routes);
AttachmentController(routes);
ChannelController(routes);
ConnectionController(routes);
BlueskyOAuthController(routes);
@@ -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));
}
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import {createAttachmentID, type UserID} from '@app/api/BrandedTypes';
import {createAttachmentID, createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {AttachmentToProcess} from '@app/api/channel/AttachmentDTOs';
import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
@@ -11,6 +11,7 @@ import {
makeAttachmentCdnKey,
validateAttachmentIds,
} from '@app/api/channel/services/message/MessageHelpers';
import {scheduleUploadSegmentSignal} from '@app/api/channel/services/message/UploadSegmentSignal';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import {contentModerationService, type ModerationContext} from '@app/api/infrastructure/ContentModerationService';
import type {
@@ -65,6 +66,7 @@ interface ProcessedAttachment {
hasVirusDetected: boolean;
applyFinalObjectMetadata: boolean;
sourceLocalPath: string | null;
sniffedContentType: string | null;
}
export class AttachmentProcessingService {
@@ -172,6 +174,24 @@ export class AttachmentProcessingService {
}
return result.attachment;
});
scheduleUploadSegmentSignal({
userId: params.uploadUserId,
guildId: params.guild ? createGuildID(BigInt(params.guild.id)) : null,
guildOwnerId: params.guild ? createUserID(BigInt(params.guild.owner_id)) : null,
channelId: params.message.channelId,
messageId: params.message.id,
attachments: processedAttachments.map((attachment, index) => ({
attachmentId: attachment.attachment_id,
uploadKey: results[index].copyOperation.sourceKey,
filename: attachment.filename,
contentType: attachment.content_type,
size: attachment.size,
duration: attachment.duration ?? null,
waveform: attachment.waveform ?? null,
sniffedContentType: results[index].sniffedContentType,
requestIp: bindingResults[index].bound?.request_ip ?? null,
})),
});
return {attachments: processedAttachments, hasVirusDetected: false};
}
@@ -213,7 +233,31 @@ export class AttachmentProcessingService {
let applyFinalObjectMetadata = false;
const clientDuration: number | null = attachment.duration ?? null;
const waveform: string | null = attachment.waveform ?? null;
const isMedia = isMediaFile(contentType);
const sniffedContentType = isMediaFile(contentType)
? null
: await this.sniffAttachmentMediaType({
index,
uploadFilename: attachment.upload_filename,
filename: attachment.filename,
});
if (sniffedContentType !== null) {
Logger.warn(
{
surface: 'message_attachment',
userId: params.uploadUserId.toString(),
guildId: params.guild?.id ?? null,
channelId: message.channelId.toString(),
messageId: message.id.toString(),
attachmentId: attachmentId.toString(),
uploadKey: attachment.upload_filename,
filename: attachment.filename,
filenameContentType: contentType,
sniffedContentType,
},
'content_moderation.attachment_type_mismatch',
);
}
const isMedia = isMediaFile(contentType) || sniffedContentType !== null;
let metadata: MediaProxyMetadataResponse | null = null;
if (isMedia) {
metadata = await this.getAttachmentMediaMetadata({
@@ -303,6 +347,7 @@ export class AttachmentProcessingService {
hasVirusDetected,
applyFinalObjectMetadata,
sourceLocalPath: null,
sniffedContentType,
};
}
const isAudio = contentType.startsWith('audio/');
@@ -341,6 +386,7 @@ export class AttachmentProcessingService {
hasVirusDetected,
applyFinalObjectMetadata,
sourceLocalPath: retainedLocalPath,
sniffedContentType,
};
} catch (error) {
if (sourceLocalPath) {
@@ -350,6 +396,27 @@ export class AttachmentProcessingService {
}
}
private async sniffAttachmentMediaType(params: {
index: number;
uploadFilename: string;
filename: string;
}): Promise<string | null> {
const sniff = await this.mediaService.sniffUpload(params.uploadFilename);
if (sniff) {
return sniff.content_type;
}
Logger.warn(
{
context: METADATA_PROBE_DEGRADED_CONTEXT,
attachmentIndex: params.index,
uploadFilename: params.uploadFilename,
filename: params.filename,
},
'Attachment content sniff unavailable, storing attachment with its filename type',
);
return null;
}
private async getAttachmentMediaMetadata(params: {
index: number;
uploadFilename: string;
@@ -107,6 +107,7 @@ export class MessageResponseDataService {
can_read_message_history: params.access.canReadMessageHistory,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
include_reactions: true,
});
if (typeof response === 'object' && 'FoundApiMany' in response) {
@@ -147,6 +148,7 @@ export class MessageResponseDataService {
can_read_message_history: params.access.canReadMessageHistory,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
include_reactions: true,
nonce: params.nonce,
tts: params.tts,
@@ -177,6 +179,7 @@ export class MessageResponseDataService {
can_read_message_history: params.access.canReadMessageHistory,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
include_reactions: params.includeReactions ?? true,
nonce: params.nonce,
tts: params.tts,
@@ -244,6 +247,7 @@ export class MessageResponseDataService {
can_read_message_history: params.access.canReadMessageHistory,
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
include_reactions: params.includeReactions ?? true,
});
if (typeof response !== 'object' || !('FoundApiMany' in response)) {
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentID, ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import {Logger} from '@app/api/Logger';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
const WINDOW_MS = 600000;
const USER_THRESHOLD = 60;
const GUILD_THRESHOLD = 120;
const FRESH_GUILD_THRESHOLD = 20;
const FRESH_GUILD_MAX_AGE_MS = 604800000;
const SEGMENT_MAX_BYTES = 16 * 1024 * 1024;
const SEGMENT_MAX_DURATION_SECONDS = 30;
const KEY_PREFIX = 'abuse:upload_segment:';
const WINDOW_TTL_SECONDS = WINDOW_MS / 1000;
const MAX_IN_FLIGHT_SIGNALS = 32;
const SURFACE = 'message_attachment';
const SIGNAL_MESSAGE = 'content_moderation.upload_segment_pattern';
const FAILURE_MESSAGE = 'content_moderation.upload_segment_signal_failed';
const DROPPED_MESSAGE = 'content_moderation.upload_segment_signal_dropped';
const PLAYLIST_CONTENT_TYPES = new Set(['application/vnd.apple.mpegurl', 'application/x-mpegurl']);
export type UploadSegmentScope = 'user' | 'guild' | 'fresh_guild';
export interface UploadSegmentAttachment {
attachmentId: AttachmentID;
uploadKey: string;
filename: string;
contentType: string;
size: bigint;
duration: number | null;
waveform: string | null;
sniffedContentType: string | null;
requestIp: string | null;
}
export interface UploadSegmentSignalInput {
userId: UserID;
guildId: GuildID | null;
guildOwnerId: UserID | null;
channelId: ChannelID;
messageId: MessageID;
attachments: ReadonlyArray<UploadSegmentAttachment>;
}
interface CountedScope {
scope: UploadSegmentScope;
key: string;
threshold: number;
}
function baseContentType(contentType: string): string {
const [base] = contentType.split(';');
return (base ?? '').trim().toLowerCase();
}
export function isSegmentShapedAttachment(attachment: UploadSegmentAttachment): boolean {
if (attachment.waveform !== null) {
return false;
}
if (attachment.sniffedContentType !== null) {
return true;
}
const contentType = baseContentType(attachment.contentType);
const isSegmentType =
contentType.startsWith('video/') || contentType.startsWith('audio/') || PLAYLIST_CONTENT_TYPES.has(contentType);
if (!isSegmentType) {
return false;
}
if (attachment.size > BigInt(SEGMENT_MAX_BYTES)) {
return false;
}
return attachment.duration === null || attachment.duration <= SEGMENT_MAX_DURATION_SECONDS;
}
export function isRungValue(count: number, threshold: number): boolean {
if (count < threshold || count % threshold !== 0) {
return false;
}
const multiple = count / threshold;
return (multiple & (multiple - 1)) === 0;
}
function uploaderOwnsGuild(input: UploadSegmentSignalInput): boolean {
return input.guildOwnerId !== null && input.guildOwnerId === input.userId;
}
function resolveScopes(input: UploadSegmentSignalInput, windowIndex: number, nowMs: number): Array<CountedScope> {
const scopes: Array<CountedScope> = [
{
scope: 'user',
key: `${KEY_PREFIX}user:${input.userId.toString()}:${windowIndex}`,
threshold: USER_THRESHOLD,
},
];
if (input.guildId === null) {
return scopes;
}
const guildAgeMs = nowMs - snowflakeToDate(input.guildId).getTime();
const isFreshGuild = uploaderOwnsGuild(input) && guildAgeMs < FRESH_GUILD_MAX_AGE_MS;
scopes.push({
scope: isFreshGuild ? 'fresh_guild' : 'guild',
key: `${KEY_PREFIX}guild:${input.guildId.toString()}:${windowIndex}`,
threshold: isFreshGuild ? FRESH_GUILD_THRESHOLD : GUILD_THRESHOLD,
});
return scopes;
}
function buildSignalFields(params: {
input: UploadSegmentSignalInput;
segments: ReadonlyArray<UploadSegmentAttachment>;
windowIndex: number;
scope: CountedScope;
count: number;
}): Record<string, unknown> {
const {input, segments, windowIndex, scope, count} = params;
const requestIps = new Set<string>();
for (const segment of segments) {
if (segment.requestIp !== null) {
requestIps.add(segment.requestIp);
}
}
return {
surface: SURFACE,
scope: scope.scope,
count,
threshold: scope.threshold,
windowMs: WINDOW_MS,
windowStartedAt: new Date(windowIndex * WINDOW_MS).toISOString(),
userId: input.userId.toString(),
userCreatedAt: snowflakeToDate(input.userId).toISOString(),
guildId: input.guildId === null ? null : input.guildId.toString(),
guildCreatedAt: input.guildId === null ? null : snowflakeToDate(input.guildId).toISOString(),
guildOwnerId: input.guildOwnerId === null ? null : input.guildOwnerId.toString(),
uploaderOwnsGuild: uploaderOwnsGuild(input),
channelId: input.channelId.toString(),
messageId: input.messageId.toString(),
attachmentIds: segments.map((segment) => segment.attachmentId.toString()),
uploadKeys: segments.map((segment) => segment.uploadKey),
requestIps: Array.from(requestIps),
filenames: segments.map((segment) => segment.filename),
contentTypes: segments.map((segment) => segment.contentType),
disguisedCount: segments.filter((segment) => segment.sniffedContentType !== null).length,
};
}
function scopeFields(input: UploadSegmentSignalInput): Record<string, unknown> {
return {
surface: SURFACE,
userId: input.userId.toString(),
guildId: input.guildId === null ? null : input.guildId.toString(),
channelId: input.channelId.toString(),
messageId: input.messageId.toString(),
};
}
const inFlightSignals = new Set<Promise<void>>();
export function scheduleUploadSegmentSignal(input: UploadSegmentSignalInput): void {
if (!input.attachments.some(isSegmentShapedAttachment)) {
return;
}
if (inFlightSignals.size >= MAX_IN_FLIGHT_SIGNALS) {
Logger.warn(scopeFields(input), DROPPED_MESSAGE);
return;
}
const pending = recordUploadSegmentSignal(input).finally(() => {
inFlightSignals.delete(pending);
});
inFlightSignals.add(pending);
}
export async function flushUploadSegmentSignals(): Promise<void> {
while (inFlightSignals.size > 0) {
await Promise.all([...inFlightSignals]);
}
}
export async function recordUploadSegmentSignal(input: UploadSegmentSignalInput): Promise<void> {
try {
const segments = input.attachments.filter(isSegmentShapedAttachment);
if (segments.length === 0) {
return;
}
const nowMs = Date.now();
const windowIndex = Math.floor(nowMs / WINDOW_MS);
const scopes = resolveScopes(input, windowIndex, nowMs);
const kv = getKVClient();
const counts: Array<number> = [];
for (const scope of scopes) {
const count = await kv.incr(scope.key);
counts.push(count);
if (count === 1) {
await kv.expire(scope.key, WINDOW_TTL_SECONDS);
}
}
for (const [index, scope] of scopes.entries()) {
const count = counts[index];
if (!isRungValue(count, scope.threshold)) {
continue;
}
Logger.warn(buildSignalFields({input, segments, windowIndex, scope, count}), SIGNAL_MESSAGE);
}
} catch (error) {
Logger.warn({error, ...scopeFields(input)}, FAILURE_MESSAGE);
}
}
+3
View File
@@ -118,6 +118,9 @@ export interface APIConfig {
tokenTtlSecs: number;
keepDirectCountries: Array<string>;
};
attachmentUrls: {
secretsBase64: Array<string>;
};
};
geoip: APIGeoipConfig;
proxy: {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {signAttachmentUrl} from '@app/api/attachment/AttachmentUrls';
import {tryExtractGifProviderSlug} from '@app/api/gif/GifProviderUtils';
import type {GifService} from '@app/api/gif/GifService';
import type {IGifProvider} from '@app/api/gif/IGifProvider';
@@ -108,10 +109,11 @@ function favoriteGifEntryFromExternalMedia({
mediaService: IMediaService;
metadata: MediaProxyMetadataResponse | null;
}): ResolvedGifEntrySchema {
const proxyUrl = mediaService.getExternalMediaProxyURL(url);
const media = directMediaFormatFromMetadata({url, proxyUrl, metadata});
const signedUrl = signAttachmentUrl(url);
const proxyUrl = signAttachmentUrl(mediaService.getExternalMediaProxyURL(url));
const media = directMediaFormatFromMetadata({url: signedUrl, proxyUrl, metadata});
return {
url,
url: signedUrl,
proxy_url: proxyUrl,
width: metadata?.width ?? 0,
height: metadata?.height ?? 0,
@@ -153,16 +155,16 @@ function favoriteGifEntryFromEmbedMedia({
mediaService: IMediaService;
media: EmbedMediaResponse;
}): ResolvedGifEntrySchema {
const proxyUrl = media.proxy_url ?? mediaService.getExternalMediaProxyURL(media.url);
const proxyUrl = signAttachmentUrl(media.proxy_url ?? mediaService.getExternalMediaProxyURL(media.url));
const width = media.width ?? 0;
const height = media.height ?? 0;
const contentType = media.content_type ?? '';
return {
url,
url: signAttachmentUrl(url),
proxy_url: proxyUrl,
width,
height,
media: directMediaFormatFromDetails({url: media.url, proxyUrl, contentType, width, height}),
media: directMediaFormatFromDetails({url: signAttachmentUrl(media.url), proxyUrl, contentType, width, height}),
content_type: contentType,
placeholder: media.placeholder ?? null,
};
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {makeSignedAttachmentCdnUrl} from '@app/api/attachment/AttachmentUrls';
import {userIdToChannelId} from '@app/api/BrandedTypes';
import {makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import type {FavoriteMeme} from '@app/api/models/FavoriteMeme';
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
import type {FavoriteMemeResponse} from '@fluxer/schema/src/domains/meme/MemeSchemas';
export function mapFavoriteMemeToResponse(meme: FavoriteMeme): FavoriteMemeResponse {
const url = makeAttachmentCdnUrl(userIdToChannelId(meme.userId), meme.attachmentId, meme.filename);
const url = makeSignedAttachmentCdnUrl(userIdToChannelId(meme.userId), meme.attachmentId, meme.filename);
return {
id: meme.id.toString(),
user_id: meme.userId.toString(),
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import {makeSignedAttachmentCdnUrl} from '@app/api/attachment/AttachmentUrls';
import type {ChannelID, MemeID, MessageID, UserID} from '@app/api/BrandedTypes';
import {createAttachmentID, createMemeID, userIdToChannelId} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers';
import {mapFavoriteMemeToResponse} from '@app/api/favorite_meme/FavoriteMemeModel';
import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository';
import {
@@ -35,6 +36,7 @@ import {MediaMetadataError} from '@fluxer/errors/src/domains/core/MediaMetadataE
import {UnknownFavoriteMemeError} from '@fluxer/errors/src/domains/core/UnknownFavoriteMemeError';
import type {GifMediaFormat} from '@fluxer/schema/src/domains/gif/GifSchemas';
import {normalizeFilename} from '@fluxer/schema/src/primitives/FileValidators';
import {attachmentStorageKeyFromUrl} from '@pkgs/media_proxy_utils/src/AttachmentUrlSignature';
import mime from 'mime';
type MessageAttachmentCandidate = Message['attachments'][number];
@@ -78,6 +80,11 @@ function isAnimatedEmbedMedia(contentType: string | null | undefined, flags: num
return ((flags ?? 0) & EmbedMediaFlags.IS_ANIMATED) !== 0;
}
function isAttachmentKeyInChannel(storageKey: string, channelId: ChannelID): boolean {
const segments = storageKey.split('/');
return segments.length === 4 && segments[0] === 'attachments' && segments[1] === channelId.toString();
}
function resolveFavoriteMemeAnimationFlag(
media: Pick<FavoriteMemeMedia, 'isGifv'>,
metadata: Pick<MediaProxyMetadataResponse, 'animated'> | null | undefined,
@@ -315,7 +322,7 @@ export class FavoriteMemeService {
this.ensureFavoriteMemeTagLimit(user, urlTags);
const metadata = await this.apiContext.services.media.getMetadata({
type: 'external',
url,
url: url,
with_base64: true,
nsfw: 'allow',
});
@@ -544,7 +551,7 @@ export class FavoriteMemeService {
embedCount: embeds.length,
});
}
return this.mediaFromEmbed(embeds[preferredEmbedIndex], `embed_${preferredEmbedIndex}`);
return this.mediaFromEmbed(embeds[preferredEmbedIndex], `embed_${preferredEmbedIndex}`, message.channelId);
}
if (attachments.length > 0) {
let attachment: MessageAttachmentCandidate | undefined;
@@ -566,7 +573,7 @@ export class FavoriteMemeService {
}
}
for (const embed of embeds) {
const media = await this.mediaFromEmbed(embed, 'media');
const media = await this.mediaFromEmbed(embed, 'media', message.channelId);
if (media) return media;
}
return null;
@@ -587,7 +594,7 @@ export class FavoriteMemeService {
const isGifv = isAnimatedAttachment(attachment.contentType, attachment.flags);
return {
isExternal: false,
url: makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename),
url: makeSignedAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename),
sourceKey: makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename),
filename: attachment.filename,
contentType: attachment.contentType,
@@ -607,6 +614,7 @@ export class FavoriteMemeService {
private async mediaFromEmbed(
embed: MessageEmbedCandidate,
fallbackFilename: string,
channelId: ChannelID,
): Promise<FavoriteMemeMedia | null> {
const media = embed.image || embed.video || embed.thumbnail;
if (!media?.url) {
@@ -617,13 +625,14 @@ export class FavoriteMemeService {
if (!this.isValidMediaType(contentType)) {
return null;
}
const isExternal = !this.isInternalCDNUrl(media.url);
const candidateKey = attachmentStorageKeyFromUrl(media.url, Config.endpoints.media);
const sourceKey = candidateKey !== null && isAttachmentKeyInChannel(candidateKey, channelId) ? candidateKey : null;
const isGifv = embed.type === 'gifv' || isAnimatedEmbedMedia(media.contentType, media.flags);
const detectedGif = embed.type === 'gifv' ? await this.detectGifFromUrl(media.url) : null;
return {
isExternal,
isExternal: sourceKey === null,
url: media.url,
sourceKey: isExternal ? '' : this.extractStorageKeyFromUrl(media.url) || '',
sourceKey: sourceKey ?? '',
filename,
contentType,
size: BigInt(0),
@@ -639,10 +648,6 @@ export class FavoriteMemeService {
};
}
private isInternalCDNUrl(url: string): boolean {
return url.startsWith(`${Config.endpoints.media}/`);
}
private isValidMediaType(contentType: string): boolean {
return contentType.startsWith('image/') || contentType.startsWith('video/') || contentType.startsWith('audio/');
}
@@ -661,15 +666,6 @@ export class FavoriteMemeService {
}
}
private extractStorageKeyFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
return urlObj.pathname.substring(1);
} catch {
return null;
}
}
private ensureFavoriteMemeTagLimit(user: User, tags?: Array<string>): void {
const limit = this.resolveUserLimit(user, 'max_favorite_meme_tags', MAX_FAVORITE_MEME_TAGS);
if ((tags?.length ?? 0) > limit) {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {stripOwnAttachmentSignature} from '@app/api/attachment/AttachmentUrls';
import type {ChannelID, MessageID} from '@app/api/BrandedTypes';
import type {RichEmbedMediaWithMetadata} from '@app/api/channel/EmbedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
@@ -53,6 +54,15 @@ interface InitialUrlEmbedResult {
hasUncachedUrls: boolean;
}
type RichEmbedRequestWithMedia = RichEmbedRequest & {
image?: RichEmbedMediaWithMetadata | null;
thumbnail?: RichEmbedMediaWithMetadata | null;
};
function canonicalUrl(url: string | null | undefined): string | null {
return url == null ? null : stripOwnAttachmentSignature(url);
}
export class EmbedService {
private readonly MAX_EMBED_CHARACTERS = 6000;
private readonly MAX_EMBED_CHARACTERS_BUG_HUNTER = 12000;
@@ -228,20 +238,20 @@ export class EmbedService {
type: embed.type ?? null,
title: embed.title ?? null,
description: embed.description ?? null,
url: embed.url ?? null,
url: canonicalUrl(embed.url),
timestamp: embed.timestamp ? new Date(embed.timestamp) : null,
color: embed.color ?? null,
author: embed.author
? {
name: embed.author.name ?? null,
url: embed.author.url ?? null,
icon_url: embed.author.icon_url ?? null,
url: canonicalUrl(embed.author.url),
icon_url: canonicalUrl(embed.author.icon_url),
}
: null,
provider: embed.provider
? {
name: embed.provider.name ?? null,
url: embed.provider.url ?? null,
url: canonicalUrl(embed.provider.url),
}
: null,
thumbnail: this.mapResponseMedia(embed.thumbnail),
@@ -251,7 +261,7 @@ export class EmbedService {
footer: embed.footer
? {
text: embed.footer.text ?? null,
icon_url: embed.footer.icon_url ?? null,
icon_url: canonicalUrl(embed.footer.icon_url),
}
: null,
fields:
@@ -272,7 +282,7 @@ export class EmbedService {
private mapResponseMedia(media?: MessageEmbedResponse['image']): MessageEmbed['image'] {
if (!media) return null;
return {
url: media.url,
url: stripOwnAttachmentSignature(media.url),
content_type: media.content_type ?? null,
content_hash: media.content_hash ?? null,
width: media.width ?? null,
@@ -303,13 +313,7 @@ export class EmbedService {
}
}
private async createEmbed(
embed: RichEmbedRequest & {
image?: RichEmbedMediaWithMetadata | null;
thumbnail?: RichEmbedMediaWithMetadata | null;
},
nsfwMode: MediaProxyNsfwMode,
): Promise<Embed> {
private async createEmbed(embed: RichEmbedRequestWithMedia, nsfwMode: MediaProxyNsfwMode): Promise<Embed> {
const [author, footer, imageResult, thumbnailResult] = await Promise.all([
this.processAuthor(embed.author ?? undefined, nsfwMode),
this.processFooter(embed.footer ?? undefined, nsfwMode),
@@ -326,7 +330,7 @@ export class EmbedService {
type: 'rich',
title: embed.title ?? null,
description: embed.description ?? null,
url: embed.url ?? null,
url: canonicalUrl(embed.url),
timestamp: embed.timestamp ?? null,
color: embed.color ?? 0,
footer: footer?.toMessageEmbedFooter() ?? null,
@@ -363,7 +367,7 @@ export class EmbedService {
if (attachmentMetadata) {
return {
media: new EmbedMedia({
url: request.url,
url: stripOwnAttachmentSignature(request.url),
width: attachmentMetadata.width,
height: attachmentMetadata.height,
description: request.description ?? null,
@@ -376,7 +380,7 @@ export class EmbedService {
nsfw: attachmentMetadata.nsfw ?? false,
};
}
const {url, metadata} = await this.resolveExternalMedia(request.url, nsfwMode);
const {url, metadata} = await this.resolveExternalMedia(stripOwnAttachmentSignature(request.url), nsfwMode);
if (!metadata) {
return {
media: new EmbedMedia({
@@ -418,13 +422,14 @@ export class EmbedService {
url: string;
metadata: MediaProxyMetadataResponse | null;
}> {
const stored = stripOwnAttachmentSignature(url);
const directMetadata = await this.mediaService.getMetadata({
type: 'external',
url,
...mediaProxyMetadataPolicy(nsfwMode),
});
if (this.isRenderableMediaType(directMetadata?.content_type)) {
return {url, metadata: directMetadata};
return {url: stored, metadata: directMetadata};
}
const unfurled = await this.unfurlerService.unfurl(url, nsfwMode);
for (const embed of unfurled) {
@@ -437,11 +442,11 @@ export class EmbedService {
...mediaProxyMetadataPolicy(nsfwMode),
});
if (this.isRenderableMediaType(candidateMetadata?.content_type)) {
return {url: candidate, metadata: candidateMetadata};
return {url: stripOwnAttachmentSignature(candidate), metadata: candidateMetadata};
}
}
}
return {url, metadata: directMetadata};
return {url: stored, metadata: directMetadata};
}
private isRenderableMediaType(contentType: string | null | undefined): boolean {
@@ -461,11 +466,11 @@ export class EmbedService {
url: author.icon_url,
...mediaProxyMetadataPolicy(nsfwMode),
});
if (metadata) iconUrl = author.icon_url;
if (metadata) iconUrl = stripOwnAttachmentSignature(author.icon_url);
}
return new EmbedAuthor({
name: author.name,
url: author.url ?? null,
url: canonicalUrl(author.url),
icon_url: iconUrl,
});
}
@@ -482,7 +487,7 @@ export class EmbedService {
url: footer.icon_url,
...mediaProxyMetadataPolicy(nsfwMode),
});
if (metadata) iconUrl = footer.icon_url;
if (metadata) iconUrl = stripOwnAttachmentSignature(footer.icon_url);
}
return new EmbedFooter({
text: footer.text,
@@ -57,6 +57,10 @@ export interface MediaProxyMetadataResponse {
nsfw_probability?: number;
}
export interface MediaProxySniffResponse {
content_type: string | null;
}
export type MediaProxyFrameRequest =
| {
type: 'upload';
@@ -85,5 +89,7 @@ export abstract class IMediaService {
abstract getThumbnail(uploadFilename: string): Promise<Buffer | null>;
abstract sniffUpload(uploadFilename: string): Promise<MediaProxySniffResponse | null>;
abstract extractFrames(request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse>;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {signAttachmentUrl} from '@app/api/attachment/AttachmentUrls';
import {Config} from '@app/api/Config';
import {
IMediaService,
@@ -9,11 +10,13 @@ import {
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
type MediaProxyNsfwMode,
type MediaProxySniffResponse,
} from '@app/api/infrastructure/IMediaService';
import {Logger} from '@app/api/Logger';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {isJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
import {ExplicitContentCannotBeSentError} from '@fluxer/errors/src/domains/moderation/ExplicitContentCannotBeSentError';
import {attachmentStorageKeyFromUrl} from '@pkgs/media_proxy_utils/src/AttachmentUrlSignature';
import * as MediaProxyUtils from '@pkgs/media_proxy_utils/src/MediaProxyUtils';
import {ms} from 'itty-time';
@@ -30,7 +33,13 @@ const MEDIA_PROXY_METADATA_WITH_BASE64_MAX_BYTES = 64 * 1024 * 1024;
const MEDIA_PROXY_ERROR_MAX_BYTES = 16 * 1024;
const MEDIA_PROXY_THUMBNAIL_MAX_BYTES = 8 * 1024 * 1024;
const MEDIA_PROXY_FRAMES_MAX_BYTES = 512 * 1024;
const MEDIA_PROXY_SNIFF_MAX_BYTES = 1024;
const MEDIA_PROXY_REQUEST_TIMEOUT_MS = ms('30 seconds');
const MEDIA_PROXY_SNIFF_TIMEOUT_MS = ms('2 seconds');
function isMediaProxySniffResponse(value: unknown): value is MediaProxySniffResponse {
return isJsonRecord(value) && (value.content_type === null || typeof value.content_type === 'string');
}
function isMediaProxyMetadataResponse(value: unknown): value is MediaProxyMetadataResponse {
if (!isJsonRecord(value)) return false;
@@ -135,6 +144,9 @@ export class MediaService extends IMediaService {
}
getExternalMediaProxyURL(url: string): string {
if (attachmentStorageKeyFromUrl(url, Config.endpoints.media) !== null) {
return signAttachmentUrl(url);
}
let urlObj: URL;
try {
urlObj = new URL(url);
@@ -166,6 +178,34 @@ export class MediaService extends IMediaService {
}
}
async sniffUpload(uploadFilename: string): Promise<MediaProxySniffResponse | null> {
const response = await this.makeRequest(
'/_sniff',
{
type: 'upload',
upload_filename: uploadFilename,
},
MEDIA_PROXY_SNIFF_TIMEOUT_MS,
);
if (!response) return null;
try {
const responseText = await FetchUtils.streamToStringWithLimit(response.body, {
maxBytes: MEDIA_PROXY_SNIFF_MAX_BYTES,
headers: response.headers,
description: 'Media proxy sniff response',
});
const sniff = parseJsonWithGuard(responseText, isMediaProxySniffResponse);
if (!sniff) {
Logger.error({uploadFilename}, 'Media proxy returned invalid sniff response');
return null;
}
return {content_type: sniff.content_type};
} catch (error) {
Logger.error({error, uploadFilename}, 'Failed to read media proxy sniff response');
return null;
}
}
async extractFrames(request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
const response = await this.makeRequest('/_frames', request);
if (!response) {
@@ -183,7 +223,11 @@ export class MediaService extends IMediaService {
return data;
}
private async makeRequest(endpoint: string, body: MediaProxyRequestBody): Promise<Response | null> {
private async makeRequest(
endpoint: string,
body: MediaProxyRequestBody,
timeoutMs: number = MEDIA_PROXY_REQUEST_TIMEOUT_MS,
): Promise<Response | null> {
try {
const url = `http://${Config.mediaProxy.host}:${Config.mediaProxy.port}${endpoint}`;
const response = await fetch(url, {
@@ -193,7 +237,7 @@ export class MediaService extends IMediaService {
'Content-Type': 'application/json',
Authorization: `Bearer ${Config.mediaProxy.secretKey}`,
},
signal: AbortSignal.timeout(MEDIA_PROXY_REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const errorText = await FetchUtils.streamToStringWithLimit(response.body, {
+95 -1
View File
@@ -107,6 +107,63 @@
"security": [{"botToken": []}]
}
},
"/attachments/refresh-urls": {
"post": {
"operationId": "refresh_attachment_urls",
"summary": "Refresh attachment URLs",
"tags": ["Messages"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshAttachmentUrlsResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"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.",
"security": [{"botToken": []}, {"sessionToken": []}],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefreshAttachmentUrlsRequest"}}}
}
}
},
"/auth/authorize-ip": {
"post": {
"operationId": "authorize_ip_address",
@@ -28845,6 +28902,31 @@
"properties": {"token": {"description": "The IP authorization token from email", "type": "string"}},
"required": ["token"]
},
"RefreshAttachmentUrlsRequest": {
"type": "object",
"properties": {
"attachment_urls": {
"minItems": 1,
"maxItems": 50,
"type": "array",
"items": {"type": "string", "maxLength": 2048},
"description": "Attachment URLs to refresh (1-50 entries, each at most 2048 characters)"
}
},
"required": ["attachment_urls"]
},
"RefreshAttachmentUrlsResponse": {
"type": "object",
"properties": {
"refreshed_urls": {
"type": "array",
"items": {"$ref": "#/components/schemas/RefreshedAttachmentUrl"},
"description": "One entry per requested URL, in the order they were requested"
}
},
"required": ["refreshed_urls"],
"additionalProperties": false
},
"ApplicationsMeResponse": {
"type": "object",
"properties": {
@@ -29351,6 +29433,18 @@
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
]
},
"RefreshedAttachmentUrl": {
"type": "object",
"properties": {
"original": {"type": "string", "description": "The requested URL, echoed back unchanged"},
"refreshed": {
"type": "string",
"description": "The same URL carrying a fresh signature, or the original when it is not an attachment URL of ours"
}
},
"required": ["original", "refreshed"],
"additionalProperties": false
},
"PasswordType": {"type": "string"},
"EmailType": {"type": "string"},
"AuthSessionLocation": {
@@ -34217,8 +34311,8 @@
{"name": "Billing", "description": "Subscription and payment management via Stripe"},
{"name": "Premium", "description": "Premium subscription features and benefits"},
{"name": "Gifts", "description": "Gift codes and redemption"},
{"name": "Connections"},
{"name": "Messages"},
{"name": "Connections"},
{"name": "Donations"},
{"name": "Experiments"},
{"name": "Geolocation"},
@@ -68,6 +68,10 @@ export const ChannelRateLimitConfigs = {
bucket: 'attachment:delete',
config: {limit: 40, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
ATTACHMENT_URLS_REFRESH: {
bucket: 'attachment:refresh_urls',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
CHANNEL_TYPING: {
bucket: 'channel:typing::channel_id',
config: {limit: 20, windowMs: ms('10 seconds')},
+1
View File
@@ -62,6 +62,7 @@ function setDefaultTestEnv(): void {
FLUXER_S3_SECRET_ACCESS_KEY: 'test',
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: 'false',
FLUXER_MEDIA_PROXY_SECRET_KEY: 'test-media-secret',
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=',
FLUXER_ADMIN_SECRET_KEY_BASE: 'test-admin-secret',
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: 'test-admin-oauth-secret',
FLUXER_APP_PROXY_PORT: '8773',
@@ -8,6 +8,7 @@ import {
type MediaProxyFrameResponse,
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
type MediaProxySniffResponse,
} from '@app/api/infrastructure/IMediaService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
@@ -87,6 +88,10 @@ export class TestMediaService extends IMediaService {
return Buffer.alloc(1024);
}
async sniffUpload(_uploadFilename: string): Promise<MediaProxySniffResponse | null> {
return {content_type: null};
}
async extractFrames(_request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
return {
frames: [
@@ -2,9 +2,9 @@
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
import {AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
import {makeSignedAttachmentCdnUrl, signAttachmentUrl} from '@app/api/attachment/AttachmentUrls';
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
type MessageResponseAccessContext,
MessageResponseDataService,
@@ -58,6 +58,14 @@ class NoopNatsConnectionManager implements INatsConnectionManager {
}
}
function signOwnUrl(url: string | null | undefined): string | null {
return url == null ? null : signAttachmentUrl(url);
}
function mediaProxyUrl(url: string | null | undefined): string | null {
return url?.startsWith('http') ? signAttachmentUrl(url) : null;
}
export class RepositoryBackedMessageResponseDataService extends MessageResponseDataService {
private readonly attachmentDecayRepository = new AttachmentDecayRepository();
private readonly attachmentDecayService = new AttachmentDecayService(this.attachmentDecayRepository);
@@ -343,8 +351,7 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
}
private mapAttachmentUrl(message: Message, attachment: Attachment): string {
const filename = encodeURIComponent(attachment.filename);
return `${Config.endpoints.media}/attachments/${message.channelId.toString()}/${message.id.toString()}/${attachment.id.toString()}/${filename}`;
return makeSignedAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
}
private async mapAttachments(
@@ -399,7 +406,7 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
private mapEmbed(embed: Embed, message: Message): MessageEmbedResponse {
return {
type: embed.type ?? 'rich',
url: embed.url,
url: signOwnUrl(embed.url),
title: embed.title,
color: embed.color,
timestamp: embed.timestamp?.toISOString() ?? null,
@@ -425,9 +432,9 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
const iconUrl = 'iconUrl' in author ? author.iconUrl : null;
return {
name: author.name,
url: author.url,
icon_url: iconUrl,
proxy_icon_url: iconUrl,
url: signOwnUrl(author.url),
icon_url: signOwnUrl(iconUrl),
proxy_icon_url: mediaProxyUrl(iconUrl),
};
}
@@ -435,8 +442,8 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
if (!footer?.text) return null;
return {
text: footer.text,
icon_url: footer.iconUrl,
proxy_icon_url: footer.iconUrl,
icon_url: signOwnUrl(footer.iconUrl),
proxy_icon_url: mediaProxyUrl(footer.iconUrl),
};
}
@@ -450,10 +457,10 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
private mapEmbedMedia(media: EmbedMedia | null, message: Message) {
if (!media?.url) return null;
const url = this.resolveAttachmentUrl(media.url, message);
const resolved = this.resolveAttachmentUrl(media.url, message);
return {
url,
proxy_url: url.startsWith('http') ? url : null,
url: signAttachmentUrl(resolved),
proxy_url: mediaProxyUrl(resolved),
content_type: media.contentType,
content_hash: media.contentHash,
width: media.width,
+5 -3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {stripOwnAttachmentSignature} from '@app/api/attachment/AttachmentUrls';
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import * as InviteUtils from '@app/api/utils/InviteUtils';
@@ -103,9 +104,10 @@ export function extractURLs(inputText: string) {
if (isFluxerAppExcludedURL(url)) continue;
const encoded = idnaEncodeURL(url);
if (!encoded) continue;
if (!seen.has(encoded)) {
seen.add(encoded);
result.push(encoded);
const canonical = stripOwnAttachmentSignature(encoded);
if (!seen.has(canonical)) {
seen.add(canonical);
result.push(canonical);
if (result.length >= 5) break;
}
}
+4 -2
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs/promises';
import {stripOwnAttachmentSignature} from '@app/api/attachment/AttachmentUrls';
import type {ChannelID, GuildID, MessageID, UserID, WebhookID, WebhookToken} from '@app/api/BrandedTypes';
import {createChannelID, createGuildID, createWebhookID, createWebhookToken} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
@@ -571,12 +572,13 @@ export class WebhookService {
private async getWebhookAvatar({
webhookId,
avatarUrl,
avatarUrl: requestedAvatarUrl,
}: {
webhookId: WebhookID;
avatarUrl: string | null;
}): Promise<string | null> {
if (!avatarUrl) return null;
if (!requestedAvatarUrl) return null;
const avatarUrl = stripOwnAttachmentSignature(requestedAvatarUrl);
try {
const cacheKey = `webhook:${webhookId}:avatar:${avatarUrl}`;
const avatarCache = await this.cacheService.get<string>(cacheKey);
@@ -11,9 +11,10 @@ import {
createArchiveTask,
throwIfArchiveTerminallyFailed,
} from '@app/api/archive/ArchiveTask';
import {makeDataPackageAttachmentCdnUrl} from '@app/api/attachment/AttachmentUrls';
import {type AttachmentID, type ChannelID, createGuildID, type MessageID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {Logger} from '@app/api/Logger';
import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils';
@@ -54,6 +55,32 @@ interface PendingAttachmentDownload {
filename: string;
}
export interface GuildHarvestAttachment {
id: AttachmentID;
filename: string;
size: bigint;
contentType: string;
width: number | null;
height: number | null;
}
export function buildGuildHarvestAttachment(
channelId: ChannelID,
attachment: GuildHarvestAttachment,
includeAttachments: boolean,
): Record<string, unknown> {
return {
attachment_id: attachment.id.toString(),
filename: attachment.filename,
size: attachment.size.toString(),
content_type: attachment.contentType,
archive_path: includeAttachments ? `attachments/${channelId}/${attachment.id}/${attachment.filename}` : null,
cdn_url: makeDataPackageAttachmentCdnUrl(channelId, attachment.id, attachment.filename),
width: attachment.width,
height: attachment.height,
};
}
function cdnBucket(): string {
return Config.s3.buckets.cdn;
}
@@ -182,16 +209,7 @@ const harvestGuildData: ArchiveTaskHandler = async (payload, helpers, attempt) =
if (msg.authorId == null) continue;
const attachments: Array<object> = [];
for (const att of msg.attachments) {
attachments.push({
attachment_id: att.id.toString(),
filename: att.filename,
size: att.size.toString(),
content_type: att.contentType,
archive_path: validated.includeAttachments ? `attachments/${channel.id}/${att.id}/${att.filename}` : null,
cdn_url: makeAttachmentCdnUrl(channel.id, att.id, att.filename),
width: att.width,
height: att.height,
});
attachments.push(buildGuildHarvestAttachment(channel.id, att, validated.includeAttachments));
if (validated.includeAttachments) {
channelDownloads.push({
channelId: channel.id,
@@ -11,6 +11,7 @@ import {
createArchiveTask,
throwIfArchiveTerminallyFailed,
} from '@app/api/archive/ArchiveTask';
import {makeDataPackageAttachmentCdnUrl} from '@app/api/attachment/AttachmentUrls';
import {
type ChannelID,
createAttachmentID,
@@ -21,7 +22,6 @@ import {
type UserID,
} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {
isChannelEligible,
isTimestampInWindow,
@@ -340,7 +340,7 @@ export async function harvestMessages(
content_type: attachment.contentType,
content_hash: null,
archive_path: null,
cdn_url: makeAttachmentCdnUrl(channelId, attachment.id, attachment.filename),
cdn_url: makeDataPackageAttachmentCdnUrl(channelId, attachment.id, attachment.filename),
width: attachment.width,
height: attachment.height,
})),