mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(media-proxy): sign attachment URLs and gate origins (#2830)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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')},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user