fix(media): stream large files and honour size settings (#3387)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 13:28:24 +02:00
1 parent 6acd7f3d22
commit 45b78a13c2
97 files changed
+2547 -409

No files matched your search

@@ -2,9 +2,11 @@
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {startAttachmentDecayReconcile} from '@app/api/attachment/AttachmentDecayReconcileRun';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
type InstanceAttachmentDecayEffectiveConfig,
type InstancePolicyConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
REGISTRATION_REJECTED_TRAIT,
@@ -13,6 +15,7 @@ import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '@app/api/inst
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getKVClient, getWorkerService} from '@app/api/middleware/ServiceRegistry';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
@@ -47,6 +50,22 @@ import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
const INSTANCE_BRANDING_ENTITY_ID = 0n;
const ATTACHMENT_EXPIRY_RULE_FIELDS = [
'enabled',
'min_size_mb',
'max_size_mb',
'max_eligible_size_mb',
'min_lifetime_days',
'max_lifetime_days',
'curve',
] as const satisfies ReadonlyArray<keyof InstanceAttachmentDecayEffectiveConfig>;
function attachmentExpiryRulesChanged(
previous: InstanceAttachmentDecayEffectiveConfig,
next: InstanceAttachmentDecayEffectiveConfig,
): boolean {
return ATTACHMENT_EXPIRY_RULE_FIELDS.some((field) => previous[field] !== next[field]);
}
function readOptionalField<T extends object, K extends keyof T>(value: T, key: K): T[K] | undefined {
return Object.hasOwn(value, key) ? value[key] : undefined;
@@ -556,6 +575,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
});
}
if (data.media) {
const previousAttachmentDecay = await instanceConfigRepository.getEffectiveAttachmentDecayConfig();
await instanceConfigRepository.setInstanceMediaConfig({
attachment_decay: data.media.attachment_decay
? omitUndefinedFields({
@@ -571,6 +591,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
})
: undefined,
});
const attachmentDecay = await instanceConfigRepository.getEffectiveAttachmentDecayConfig();
if (attachmentDecay.enabled && attachmentExpiryRulesChanged(previousAttachmentDecay, attachmentDecay)) {
await startAttachmentDecayReconcile(getKVClient(), getWorkerService());
}
}
if (data.policy) {
await applyInstancePolicyUpdate(
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import {Config} from '@app/api/Config';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {seconds} from 'itty-time';
const RUN_KEY = 'attachment_decay:reconcile_run';
const RUN_TTL_SECONDS = seconds('1 day');
export function attachmentDecayReconcileJobKey(runId: string, chunk: number): string {
return `${runId}:${chunk}`;
}
export async function startAttachmentDecayReconcile(
kvClient: IKVProvider,
workerService: IWorkerService<WorkerTaskName>,
): Promise<void> {
if (!Config.instance.selfHosted) return;
const runId = randomUUID();
await kvClient.setex(RUN_KEY, RUN_TTL_SECONDS, runId);
await workerService.addJob(
'reconcileAttachmentDecay',
{runId, chunk: 0},
{jobKey: attachmentDecayReconcileJobKey(runId, 0)},
);
}
export async function holdAttachmentDecayReconcileRun(kvClient: IKVProvider, runId: string): Promise<boolean> {
return (
(await kvClient.extendLock(RUN_KEY, runId, RUN_TTL_SECONDS)) ||
(await kvClient.acquireLock(RUN_KEY, runId, RUN_TTL_SECONDS))
);
}
export async function finishAttachmentDecayReconcileRun(kvClient: IKVProvider, runId: string): Promise<void> {
await kvClient.releaseLock(RUN_KEY, runId);
}
export async function isAttachmentDecayReconcileRunning(kvClient: IKVProvider): Promise<boolean> {
if (!Config.instance.selfHosted) return false;
return (await kvClient.exists(RUN_KEY)) > 0;
}
@@ -7,9 +7,11 @@ import {
fetchMany,
fetchManyInChunks,
fetchOne,
fetchPage,
} from '@app/api/database/CassandraQueryExecution';
import {AttachmentDecayByExpiry, AttachmentDecayById} from '@app/api/Tables';
import {AttachmentDecayByExpiry, AttachmentDecayById, ChannelMessageBuckets} from '@app/api/Tables';
import type {AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
import {BACKGROUND_READ_TIMEOUT_MS} from '@pkgs/cassandra/src/Client';
interface AttachmentDecayExpiryRow {
expiry_bucket: number;
@@ -26,6 +28,7 @@ const FETCH_BY_ID_CQL = AttachmentDecayById.selectCql({
const FETCH_BY_IDS_CQL = AttachmentDecayById.selectCql({
where: AttachmentDecayById.where.in('attachment_id', 'attachment_ids'),
});
const SCAN_CHANNELS_WITH_MESSAGES_CQL = ChannelMessageBuckets.selectCql({columns: ['channel_id']});
const createFetchExpiredByBucketQuery = (limit: number) =>
AttachmentDecayByExpiry.select({
where: [
@@ -74,6 +77,18 @@ export class AttachmentDecayRepository {
return map;
}
async scanChannelsWithMessagesPage(
limit: number,
pageState: string | null,
): Promise<{channelIds: Array<ChannelID>; pageState: string | null}> {
const page = await fetchPage<{channel_id: ChannelID}>(
SCAN_CHANNELS_WITH_MESSAGES_CQL,
{},
{pageSize: limit, pageState, readTimeout: BACKGROUND_READ_TIMEOUT_MS},
);
return {channelIds: [...new Set(page.rows.map((row) => row.channel_id))], pageState: page.pageState};
}
async fetchExpiredByBucket(bucket: number, currentTime: Date, limit = 200): Promise<Array<AttachmentDecayExpiryRow>> {
const query = createFetchExpiredByBucketQuery(limit);
return fetchMany(query.bind({expiry_bucket: bucket, current_time: currentTime}));
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
import {type AttachmentDecayPayload, AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
import type {AttachmentID} from '@app/api/BrandedTypes';
import {createAttachmentID, createChannelID, createMessageID} from '@app/api/BrandedTypes';
import type {InstanceAttachmentDecayEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import type {AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
import {ms} from 'itty-time';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const NOW = new Date('2026-10-10T00:00:00.000Z');
const ATTACHMENT_ID = createAttachmentID(7001n);
const CONFIG: InstanceAttachmentDecayEffectiveConfig = {
enabled: true,
min_size_mb: 5,
max_size_mb: 500,
max_eligible_size_mb: 500,
min_lifetime_days: 14,
max_lifetime_days: 365,
curve: 0.5,
renew_threshold_days: 30,
renew_window_days: 30,
};
function daysFromNow(days: number): Date {
return new Date(NOW.getTime() + days * ms('1 day'));
}
function payload(uploadedAt: Date, sizeBytes = 1024n): AttachmentDecayPayload {
return {
attachmentId: ATTACHMENT_ID,
channelId: createChannelID(7002n),
messageId: createMessageID(7003n),
filename: 'file.png',
sizeBytes,
uploadedAt,
};
}
function createService(config: InstanceAttachmentDecayEffectiveConfig = CONFIG) {
const rows = new Map<AttachmentID, AttachmentDecayRow>();
const repo = {
async fetchByIds(ids: Array<AttachmentID>) {
return new Map(ids.flatMap((id) => (rows.has(id) ? [[id, rows.get(id)!] as const] : [])));
},
async upsert(record: AttachmentDecayRow) {
rows.set(record.attachment_id, record);
},
async deleteRecords({attachment_id}: {attachment_id: AttachmentID}) {
rows.delete(attachment_id);
},
} as unknown as AttachmentDecayRepository;
return {rows, service: new AttachmentDecayService(repo, async () => config)};
}
async function fileExists(): Promise<boolean> {
return true;
}
async function fileMissing(): Promise<boolean> {
return false;
}
function seed(rows: Map<AttachmentID, AttachmentDecayRow>, uploadedAt: Date, expiresAt: Date): void {
rows.set(ATTACHMENT_ID, {
attachment_id: ATTACHMENT_ID,
channel_id: createChannelID(7002n),
message_id: createMessageID(7003n),
filename: 'file.png',
size_bytes: 1024n,
uploaded_at: uploadedAt,
expires_at: expiresAt,
last_accessed_at: uploadedAt,
cost: 1,
lifetime_days: 1,
status: null,
});
}
describe('AttachmentDecayService.reconcile', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('writes nothing while expiry is off', async () => {
const {rows, service} = createService({...CONFIG, enabled: false});
expect(await service.reconcile([payload(daysFromNow(-10))], fileExists)).toBe(0);
expect(rows.size).toBe(0);
});
it('gives an untracked attachment the expiry its upload date implies', async () => {
const {rows, service} = createService();
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(1);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(265));
});
it('gives an untracked attachment that is already overdue the renew window', async () => {
const {rows, service} = createService();
await service.reconcile([payload(daysFromNow(-1000))], fileExists);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(30));
});
it('gives an attachment whose expiry passed unnoticed the renew window', async () => {
const {rows, service} = createService();
seed(rows, daysFromNow(-1000), daysFromNow(-200));
await service.reconcile([payload(daysFromNow(-1000))], fileExists);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(30));
});
it('leaves an untracked attachment alone when its file is gone', async () => {
const {rows, service} = createService();
expect(await service.reconcile([payload(daysFromNow(-100))], fileMissing)).toBe(0);
expect(rows.size).toBe(0);
});
it('keeps a passed expiry when the file is gone', async () => {
const {rows, service} = createService();
seed(rows, daysFromNow(-1000), daysFromNow(-200));
expect(await service.reconcile([payload(daysFromNow(-1000))], fileMissing)).toBe(0);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(-200));
});
it('does not look for the file of an attachment that is still pending', async () => {
const {rows, service} = createService({...CONFIG, max_lifetime_days: 730});
seed(rows, daysFromNow(-100), daysFromNow(265));
await service.reconcile([payload(daysFromNow(-100))], fileMissing);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(630));
});
it('extends a tracked attachment when the lifetime grows', async () => {
const {rows, service} = createService({...CONFIG, max_lifetime_days: 730});
seed(rows, daysFromNow(-100), daysFromNow(265));
await service.reconcile([payload(daysFromNow(-100))], fileExists);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(630));
});
it('shortens a tracked attachment no further than the renew window', async () => {
const {rows, service} = createService({...CONFIG, max_lifetime_days: 60});
seed(rows, daysFromNow(-100), daysFromNow(265));
await service.reconcile([payload(daysFromNow(-100))], fileExists);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(30));
});
it('keeps an expiry that already falls inside the renew window', async () => {
const {rows, service} = createService({...CONFIG, max_lifetime_days: 60});
seed(rows, daysFromNow(-100), daysFromNow(12));
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(0);
expect(rows.get(ATTACHMENT_ID)?.expires_at).toEqual(daysFromNow(12));
});
it('drops the record of an attachment that is no longer eligible', async () => {
const {rows, service} = createService({...CONFIG, max_eligible_size_mb: 1});
seed(rows, daysFromNow(-100), daysFromNow(265));
expect(await service.reconcile([payload(daysFromNow(-100), 2n * 1024n * 1024n)], fileExists)).toBe(1);
expect(rows.size).toBe(0);
});
it('settles after one pass', async () => {
const {service} = createService({...CONFIG, max_lifetime_days: 60});
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(1);
expect(await service.reconcile([payload(daysFromNow(-100))], fileExists)).toBe(0);
});
});
@@ -15,7 +15,7 @@ import {
} from '@app/api/utils/AttachmentDecay';
import {ms} from 'itty-time';
interface AttachmentDecayPayload {
export interface AttachmentDecayPayload {
attachmentId: AttachmentID;
channelId: ChannelID;
messageId: MessageID;
@@ -27,7 +27,7 @@ interface AttachmentDecayPayload {
type AttachmentDecayConfigResolver = () => Promise<InstanceAttachmentDecayEffectiveConfig>;
async function resolveDefaultAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
export async function resolveDefaultAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
const {getInstanceConfigRepository} = await import('@app/api/middleware/ServiceSingletons');
return getInstanceConfigRepository().getEffectiveAttachmentDecayConfig();
}
@@ -108,26 +108,85 @@ export class AttachmentDecayService {
expiresAt = renewed;
}
}
const lifetimeDays = Math.round((expiresAt.getTime() - uploadedAt.getTime()) / ms('1 day'));
const cost = computeCost({
sizeBytes: attachment.sizeBytes,
lifetimeDays,
pricePerTBPerMonth: DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
});
await this.repo.upsert({
attachment_id: attachment.attachmentId,
channel_id: attachment.channelId,
message_id: attachment.messageId,
filename: attachment.filename,
size_bytes: attachment.sizeBytes,
uploaded_at: uploadedAt,
expires_at: expiresAt,
last_accessed_at: now,
cost,
lifetime_days: lifetimeDays,
await this.writeRecord(attachment, {
uploadedAt,
expiresAt,
lastAccessedAt: now,
status: existing?.status ?? null,
expiry_bucket: getExpiryBucket(expiresAt),
});
}
}
async reconcile(
attachments: Array<AttachmentDecayPayload>,
fileExists: (attachment: AttachmentDecayPayload) => Promise<boolean>,
): Promise<number> {
if (attachments.length === 0) return 0;
const config = await this.resolveConfig();
if (!config.enabled) return 0;
const rules = buildDecayRules(config);
const existingRecords = await this.repo.fetchByIds(attachments.map((a) => a.attachmentId));
const now = new Date();
const windowEnd = new Date(now.getTime() + config.renew_window_days * ms('1 day'));
const seen = new Set<AttachmentID>();
let changed = 0;
for (const attachment of attachments) {
if (seen.has(attachment.attachmentId)) continue;
seen.add(attachment.attachmentId);
const existing = existingRecords.get(attachment.attachmentId);
const uploadedAt = existing?.uploaded_at ?? attachment.uploadedAt;
const decay = computeDecay({sizeBytes: attachment.sizeBytes, uploadedAt, rules});
if (!decay) {
if (existing) {
await this.repo.deleteRecords({
expiry_bucket: getExpiryBucket(existing.expires_at),
expires_at: existing.expires_at,
attachment_id: existing.attachment_id,
});
changed++;
}
continue;
}
const stillPending = existing != null && existing.expires_at.getTime() > now.getTime();
if (!stillPending && !(await fileExists(attachment))) continue;
const floor =
stillPending && existing.expires_at.getTime() < windowEnd.getTime() ? existing.expires_at : windowEnd;
const expiresAt = extendExpiry(floor, decay.expiresAt);
if (existing && existing.expires_at.getTime() === expiresAt.getTime()) continue;
await this.writeRecord(attachment, {
uploadedAt,
expiresAt,
lastAccessedAt: existing?.last_accessed_at ?? uploadedAt,
status: existing?.status ?? null,
});
changed++;
}
return changed;
}
private async writeRecord(
attachment: AttachmentDecayPayload,
state: {uploadedAt: Date; expiresAt: Date; lastAccessedAt: Date; status: string | null},
): Promise<void> {
const lifetimeDays = Math.round((state.expiresAt.getTime() - state.uploadedAt.getTime()) / ms('1 day'));
const cost = computeCost({
sizeBytes: attachment.sizeBytes,
lifetimeDays,
pricePerTBPerMonth: DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
});
await this.repo.upsert({
attachment_id: attachment.attachmentId,
channel_id: attachment.channelId,
message_id: attachment.messageId,
filename: attachment.filename,
size_bytes: attachment.sizeBytes,
uploaded_at: state.uploadedAt,
expires_at: state.expiresAt,
last_accessed_at: state.lastAccessedAt,
cost,
lifetime_days: lifetimeDays,
status: state.status,
expiry_bucket: getExpiryBucket(state.expiresAt),
});
}
}
@@ -249,6 +249,7 @@ export class AttachmentProcessingService {
uploadFilename: attachment.upload_filename,
filename: attachment.filename,
nsfwMode,
sizeBytes: uploadedFile.contentLength,
});
if (metadata) {
applyFinalObjectMetadata = true;
@@ -406,14 +407,18 @@ export class AttachmentProcessingService {
uploadFilename: string;
filename: string;
nsfwMode: MediaProxyNsfwMode;
sizeBytes: number;
}): Promise<MediaProxyMetadataResponse | null> {
try {
const metadata = await this.mediaService.getMetadata({
type: 'upload',
upload_filename: params.uploadFilename,
filename: params.filename,
nsfw: params.nsfwMode,
});
const metadata = await this.mediaService.getMetadata(
{
type: 'upload',
upload_filename: params.uploadFilename,
filename: params.filename,
nsfw: params.nsfwMode,
},
{sizeBytes: params.sizeBytes},
);
if (metadata) {
return metadata;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentDecayPayload} from '@app/api/attachment/AttachmentDecayService';
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import {createAttachmentID, userIdToChannelId} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
@@ -472,6 +473,22 @@ export function collectMessageAttachments(message: Message): Array<Attachment> {
return [...message.attachments, ...message.messageSnapshots.flatMap((snapshot) => snapshot.attachments)];
}
export function attachmentDecayPayloadsForMessage(message: Message): Array<AttachmentDecayPayload> {
const attachments = collectMessageAttachments(message);
if (attachments.length === 0) return [];
const ownerMessageId = isCrosspostCopy(message) ? (message.reference?.messageId ?? message.id) : message.id;
const uploadedAt = snowflakeToDate(ownerMessageId);
const storageChannelId = attachmentStorageChannelId(message);
return attachments.map((attachment) => ({
attachmentId: attachment.id,
channelId: storageChannelId,
messageId: ownerMessageId,
filename: attachment.filename,
sizeBytes: attachment.size,
uploadedAt,
}));
}
export function countedThreadMessages(channel: Channel, messageIds: Array<MessageID>): number {
if (!channel.isThread()) return 0;
const starterId = channel.id.toString();
@@ -5,11 +5,15 @@ import {
MESSAGE_BUILD_BATCH_MAX_BYTES,
MessageResponseDataService,
} from '@app/api/channel/services/message/MessageResponseDataService';
import {
type InstanceAttachmentDecayEffectiveConfig,
InstanceConfigRepository,
} from '@app/api/instance/InstanceConfigRepository';
import {Message} from '@app/api/models/Message';
import {MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {NatsConnection} from '@nats-io/transport-node';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
import {describe, expect, it} from 'vitest';
import {afterEach, describe, expect, it, vi} from 'vitest';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -129,6 +133,10 @@ function batchSizes(connectionManager: FakeConnectionManager): Array<number> {
}
describe('MessageResponseDataService', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('omits reactions from broadcast message response requests', async () => {
const connectionManager = new FakeConnectionManager();
const service = new MessageResponseDataService(connectionManager);
@@ -161,6 +169,19 @@ describe('MessageResponseDataService', () => {
});
});
it('tells the shard whether attachment expiry is on', async () => {
const connectionManager = new FakeConnectionManager();
const service = new MessageResponseDataService(connectionManager);
const decayConfig = vi.spyOn(InstanceConfigRepository.prototype, 'getEffectiveAttachmentDecayConfig');
for (const enabled of [true, false]) {
decayConfig.mockResolvedValue({enabled} as InstanceAttachmentDecayEffectiveConfig);
await service.buildMessageForChannel({channel: {guildId: null}, message: makeMessage()});
}
expect(connectionManager.payloads.map((payload) => payload.attachment_decay)).toEqual([true, false]);
});
it('waits longer than the router shard timeout so the inner hop expires first', async () => {
const connectionManager = new FakeConnectionManager();
const service = new MessageResponseDataService(connectionManager);
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveDefaultAttachmentDecayConfig} from '@app/api/attachment/AttachmentDecayService';
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
@@ -49,6 +50,10 @@ type MessageServiceResponse =
}
| 'NotFound';
async function attachmentDecayEnabled(): Promise<boolean> {
return (await resolveDefaultAttachmentDecayConfig()).enabled;
}
function isMessageResponse(value: unknown): value is MessageResponse {
return isJsonRecord(value) && typeof value.id === 'string';
}
@@ -113,6 +118,7 @@ export class MessageResponseDataService {
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
attachment_decay: await attachmentDecayEnabled(),
include_reactions: true,
});
if (typeof response === 'object' && 'FoundApiMany' in response) {
@@ -157,6 +163,7 @@ export class MessageResponseDataService {
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
attachment_decay: await attachmentDecayEnabled(),
include_reactions: true,
nonce: params.nonce,
tts: params.tts,
@@ -189,6 +196,7 @@ export class MessageResponseDataService {
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
attachment_decay: await attachmentDecayEnabled(),
include_reactions: params.includeReactions ?? true,
nonce: params.nonce,
tts: params.tts,
@@ -258,6 +266,7 @@ export class MessageResponseDataService {
media_endpoint: Config.endpoints.media,
media_proxy_secret_key: Config.mediaProxy.secretKey,
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
attachment_decay: await attachmentDecayEnabled(),
include_reactions: params.includeReactions ?? true,
});
if (typeof response !== 'object' || !('FoundApiMany' in response)) {
@@ -1,17 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
import {getDmChannelIdsForScope} from '@app/api/channel/services/message/DmScopeUtils';
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
import {
attachmentStorageChannelId,
collectMessageAttachments,
isCrosspostCopy,
} from '@app/api/channel/services/message/MessageHelpers';
import {attachmentDecayPayloadsForMessage} from '@app/api/channel/services/message/MessageHelpers';
import type {MessageProcessingService} from '@app/api/channel/services/message/MessageProcessingService';
import {
createMessageResponseDataService,
@@ -283,36 +279,11 @@ export class MessageRetrievalService {
}
private async extendAttachments(messages: Array<Message>): Promise<void> {
const payloads = messages.flatMap((message) => {
return this.buildAttachmentDecayEntriesForMessage(message);
});
const payloads = messages.flatMap(attachmentDecayPayloadsForMessage);
if (payloads.length === 0) return;
await this.attachmentDecayService.extendForAttachments(payloads);
}
private buildAttachmentDecayEntriesForMessage(message: Message): Array<{
attachmentId: AttachmentID;
channelId: ChannelID;
messageId: MessageID;
filename: string;
sizeBytes: bigint;
uploadedAt: Date;
}> {
const attachments = collectMessageAttachments(message);
if (attachments.length === 0) return [];
const ownerMessageId = isCrosspostCopy(message) ? (message.reference?.messageId ?? message.id) : message.id;
const uploadedAt = snowflakeToDate(ownerMessageId);
const storageChannelId = attachmentStorageChannelId(message);
return attachments.map((attachment) => ({
attachmentId: attachment.id,
channelId: storageChannelId,
messageId: ownerMessageId,
filename: attachment.filename,
sizeBytes: attachment.size,
uploadedAt,
}));
}
private async applyDmScopeToSearchParams({
userId,
channel,
@@ -1,17 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createAttachmentID, createChannelID, createMessageID} from '@app/api/BrandedTypes';
import {Config, getConfig} from '@app/api/Config';
import {
createChannel,
createGuild,
loadFixture,
sendMessageWithAttachments,
} from '@app/api/channel/tests/AttachmentTestUtils';
import {
getAssetDeletionQueue,
getChannelRepository,
getInstanceConfigRepository,
} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {getExpiryBucket} from '@app/api/utils/AttachmentDecay';
import {processExpiredAttachments} from '@app/api/worker/tasks/ExpireAttachments';
import reconcileAttachmentDecay, {
type ReconcileAttachmentDecayPayload,
reconcileAttachmentDecayChunk,
} from '@app/api/worker/tasks/ReconcileAttachmentDecay';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
interface AttachmentDecayRow {
attachment_id: string;
@@ -24,14 +42,325 @@ interface AttachmentDecayRow {
status: string | null;
}
interface ListedMessage {
id: string;
attachments?: Array<{id: string; url?: string | null; expires_at?: string | null}>;
}
describe('Attachment Decay', () => {
let harness: ApiTestHarness;
const originalSelfHosted = getConfig().instance.selfHosted;
beforeEach(async () => {
getConfig().instance.selfHosted = true;
harness = await createApiTestHarness();
});
afterEach(async () => {
getConfig().instance.selfHosted = originalSelfHosted;
vi.restoreAllMocks();
clearWorkerDependencies();
await harness?.shutdown();
});
async function createInstanceAdmin(): Promise<TestAccount> {
return setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
}
async function setAttachmentExpiry(admin: TestAccount, attachmentDecay: Record<string, unknown>): Promise<void> {
await createBuilder(harness, admin.token)
.patch('/admin/instance/config')
.body({media: {attachment_decay: attachmentDecay}})
.expect(HTTP_STATUS.OK)
.execute();
}
async function listedAttachment(token: string, channelId: string, messageId: string) {
const messages = await createBuilder<Array<ListedMessage>>(harness, token)
.get(`/channels/${channelId}/messages?limit=10`)
.execute();
return messages.find((message) => message.id === messageId)?.attachments?.[0];
}
async function fetchDecayRow(attachmentId: string): Promise<AttachmentDecayRow | null> {
const {row} = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
.get(`/test/attachment-decay/${attachmentId}`)
.execute();
return row;
}
function installWorkerDependencies(): void {
setWorkerDependenciesForTest({
assetDeletionQueue: getAssetDeletionQueue(),
channelRepository: getChannelRepository(),
instanceConfigRepository: getInstanceConfigRepository(),
kvClient: harness.kvProvider,
storageService: harness.storageService,
});
}
async function enableExpiryAndTakeReconcile(admin: TestAccount): Promise<ReconcileAttachmentDecayPayload> {
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await setAttachmentExpiry(admin, {enabled: true});
expect(addJob).toHaveBeenCalledTimes(1);
const [task, payload, options] = addJob.mock.calls[0] as unknown as [
string,
ReconcileAttachmentDecayPayload,
{jobKey: string},
];
expect(task).toBe('reconcileAttachmentDecay');
expect(payload).toEqual({runId: expect.any(String), chunk: 0});
expect(options).toEqual({jobKey: `${payload.runId}:0`});
addJob.mockRestore();
return payload;
}
async function runReconcile(
first: ReconcileAttachmentDecayPayload,
limits?: {messagePageSize: number; messagePages: number},
): Promise<number> {
let chunks = 0;
let next: ReconcileAttachmentDecayPayload | null = first;
while (next !== null) {
next = await reconcileAttachmentDecayChunk(next, limits);
chunks++;
}
return chunks;
}
async function uploadWhileExpiryIsOff(token: string, channelId: string, filename: string) {
const {json} = await sendMessageWithAttachments(
harness,
token,
channelId,
{content: filename, attachments: [{id: 0, filename}]},
[{index: 0, filename, data: loadFixture('yeah.png')}],
);
return {messageId: json.id, attachmentId: json.attachments![0].id};
}
test('should stop reporting an expiry once attachment expiry is turned off', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Expiry Off Guild');
const channelId = guild.system_channel_id!;
await setAttachmentExpiry(admin, {enabled: true});
const {json} = await sendMessageWithAttachments(
harness,
admin.token,
channelId,
{content: 'Uploaded while expiry is on', attachments: [{id: 0, filename: 'on.png'}]},
[{index: 0, filename: 'on.png', data: loadFixture('yeah.png')}],
);
expect((await listedAttachment(admin.token, channelId, json.id))?.expires_at).toBeTruthy();
await setAttachmentExpiry(admin, {enabled: false});
const listed = await listedAttachment(admin.token, channelId, json.id);
expect(listed?.expires_at ?? null).toBeNull();
expect(listed?.url).toBeTruthy();
expect(await fetchDecayRow(json.attachments![0].id)).not.toBeNull();
});
test('should apply an expiry to earlier attachments once attachment expiry is turned on', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Expiry On Guild');
const channelId = guild.system_channel_id!;
await setAttachmentExpiry(admin, {enabled: false});
const {json} = await sendMessageWithAttachments(
harness,
admin.token,
channelId,
{content: 'Uploaded while expiry is off', attachments: [{id: 0, filename: 'off.png'}]},
[{index: 0, filename: 'off.png', data: loadFixture('yeah.png')}],
);
const attachmentId = json.attachments![0].id;
expect(await fetchDecayRow(attachmentId)).toBeNull();
const reconcile = await enableExpiryAndTakeReconcile(admin);
installWorkerDependencies();
expect(await runReconcile(reconcile)).toBe(1);
const row = await fetchDecayRow(attachmentId);
expect(row?.channel_id).toBe(channelId);
expect(row?.message_id).toBe(json.id);
expect(new Date(row!.expires_at).getTime()).toBeGreaterThan(Date.now());
});
test('should only queue a reconcile when the expiry rules change', async () => {
const admin = await createInstanceAdmin();
await setAttachmentExpiry(admin, {enabled: true, max_lifetime_days: 400});
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await setAttachmentExpiry(admin, {enabled: true, max_lifetime_days: 400, renew_window_days: 10});
expect(addJob).not.toHaveBeenCalled();
await setAttachmentExpiry(admin, {max_lifetime_days: 200});
expect(addJob).toHaveBeenCalledTimes(1);
await setAttachmentExpiry(admin, {enabled: false});
expect(addJob).toHaveBeenCalledTimes(1);
});
test('should not queue a reconcile on the hosted instance', async () => {
const admin = await createInstanceAdmin();
await setAttachmentExpiry(admin, {enabled: false});
getConfig().instance.selfHosted = false;
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await setAttachmentExpiry(admin, {enabled: true});
await setAttachmentExpiry(admin, {max_lifetime_days: 200});
expect(addJob).not.toHaveBeenCalled();
expect(await harness.kvProvider.exists('attachment_decay:reconcile_run')).toBe(0);
});
test('should not run a queued reconcile job on the hosted instance', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Hosted Guild');
await setAttachmentExpiry(admin, {enabled: false});
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'hosted.png');
const reconcile = await enableExpiryAndTakeReconcile(admin);
installWorkerDependencies();
getConfig().instance.selfHosted = false;
const addJob = vi.fn();
await reconcileAttachmentDecay({...reconcile, __jobId: '1'}, {addJob} as unknown as WorkerTaskHelpers);
expect(addJob).not.toHaveBeenCalled();
expect(await fetchDecayRow(upload.attachmentId)).toBeNull();
getConfig().instance.selfHosted = true;
expect(await runReconcile(reconcile)).toBe(1);
expect(await fetchDecayRow(upload.attachmentId)).not.toBeNull();
});
test('should reconcile in resumable chunks', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Chunked Guild');
const second = await createChannel(harness, admin.token, guild.id, 'second');
await setAttachmentExpiry(admin, {enabled: false});
const uploads = [
await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'a.png'),
await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'b.png'),
await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'c.png'),
await uploadWhileExpiryIsOff(admin.token, second.id, 'd.png'),
await uploadWhileExpiryIsOff(admin.token, second.id, 'e.png'),
];
const reconcile = await enableExpiryAndTakeReconcile(admin);
installWorkerDependencies();
const next = await reconcileAttachmentDecayChunk(reconcile, {messagePageSize: 1, messagePages: 1});
expect(next).toMatchObject({runId: reconcile.runId, chunk: 1, cursor: {beforeMessageId: expect.any(String)}});
const tracked = await Promise.all(uploads.map((upload) => fetchDecayRow(upload.attachmentId)));
expect(tracked.filter((row) => row !== null)).toHaveLength(1);
expect(await runReconcile(next!, {messagePageSize: 1, messagePages: 1})).toBeGreaterThan(3);
for (const upload of uploads) {
expect((await fetchDecayRow(upload.attachmentId))?.message_id).toBe(upload.messageId);
}
});
test('should run a queued reconcile job to the end', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Queued Job Guild');
await setAttachmentExpiry(admin, {enabled: false});
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'queued.png');
const reconcile = await enableExpiryAndTakeReconcile(admin);
installWorkerDependencies();
const addJob = vi.fn();
await reconcileAttachmentDecay({...reconcile, __jobId: '1'}, {addJob} as unknown as WorkerTaskHelpers);
expect(addJob).not.toHaveBeenCalled();
expect(await fetchDecayRow(upload.attachmentId)).not.toBeNull();
expect(await harness.kvProvider.exists('attachment_decay:reconcile_run')).toBe(0);
});
test('should stop a reconcile once a newer one starts', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Superseded Guild');
await setAttachmentExpiry(admin, {enabled: false});
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'old.png');
const first = await enableExpiryAndTakeReconcile(admin);
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await setAttachmentExpiry(admin, {max_lifetime_days: 200});
expect(addJob).toHaveBeenCalledTimes(1);
installWorkerDependencies();
expect(await reconcileAttachmentDecayChunk(first)).toBeNull();
expect(await fetchDecayRow(upload.attachmentId)).toBeNull();
});
test('should read the stored settings instead of a stale cache', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Stale Cache Guild');
await setAttachmentExpiry(admin, {enabled: false});
const upload = await uploadWhileExpiryIsOff(admin.token, guild.system_channel_id!, 'stale.png');
const reconcile = await enableExpiryAndTakeReconcile(admin);
installWorkerDependencies();
const current = await getInstanceConfigRepository().getEffectiveAttachmentDecayConfig();
vi.spyOn(getInstanceConfigRepository(), 'getEffectiveAttachmentDecayConfig').mockResolvedValue({
...current,
enabled: false,
});
await runReconcile(reconcile);
expect(await fetchDecayRow(upload.attachmentId)).not.toBeNull();
});
test('should not give a new expiry to an attachment whose file is gone', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Purged Guild');
const channelId = guild.system_channel_id!;
await setAttachmentExpiry(admin, {enabled: false});
const purged = await uploadWhileExpiryIsOff(admin.token, channelId, 'purged.png');
const kept = await uploadWhileExpiryIsOff(admin.token, channelId, 'kept.png');
await harness.storageService.deleteObject(
Config.s3.buckets.cdn,
`attachments/${channelId}/${purged.attachmentId}/purged.png`,
);
const reconcile = await enableExpiryAndTakeReconcile(admin);
installWorkerDependencies();
await runReconcile(reconcile);
expect(await fetchDecayRow(purged.attachmentId)).toBeNull();
expect(await fetchDecayRow(kept.attachmentId)).not.toBeNull();
});
test('should not delete expired attachments while a reconcile is running', async () => {
const admin = await createInstanceAdmin();
const guild = await createGuild(harness, admin.token, 'Paused Expiry Guild');
const channelId = guild.system_channel_id!;
await setAttachmentExpiry(admin, {enabled: true});
const {json} = await sendMessageWithAttachments(
harness,
admin.token,
channelId,
{content: 'Lapsed while expiry was off', attachments: [{id: 0, filename: 'lapsed.png'}]},
[{index: 0, filename: 'lapsed.png', data: loadFixture('yeah.png')}],
);
const attachmentId = json.attachments![0].id;
await setAttachmentExpiry(admin, {enabled: false});
const expiresAt = new Date(Date.now() - 60_000);
await new AttachmentDecayRepository().upsert({
attachment_id: createAttachmentID(BigInt(attachmentId)),
channel_id: createChannelID(BigInt(channelId)),
message_id: createMessageID(BigInt(json.id)),
filename: 'lapsed.png',
size_bytes: 1024n,
uploaded_at: expiresAt,
expires_at: expiresAt,
last_accessed_at: expiresAt,
cost: 1,
lifetime_days: 1,
status: null,
expiry_bucket: getExpiryBucket(expiresAt),
});
const reconcile = await enableExpiryAndTakeReconcile(admin);
installWorkerDependencies();
await processExpiredAttachments();
expect(new Date((await fetchDecayRow(attachmentId))!.expires_at).getTime()).toBeLessThan(Date.now());
await runReconcile(reconcile);
await processExpiredAttachments();
expect(new Date((await fetchDecayRow(attachmentId))!.expires_at).getTime()).toBeGreaterThan(Date.now());
const key = `attachments/${channelId}/${attachmentId}/lapsed.png`;
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, key)).toBe(true);
});
test('should create decay metadata when uploading attachment', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Decay Test Guild');
@@ -713,6 +713,7 @@ describe('Crosspost propagation', () => {
queued.push(item);
},
} as unknown as IAssetDeletionQueue,
kvClient: harness.kvProvider,
instanceConfigRepository: {
async getEffectiveAttachmentDecayConfig() {
return {enabled: true};
@@ -95,13 +95,13 @@ export class AvatarService {
return null;
}
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const maxAvatarSize = this.resolveSizeLimit('avatar_max_size', AVATAR_MAX_SIZE);
if (imageBuffer.length > maxAvatarSize) {
if (Buffer.byteLength(base64Data, 'base64') > maxAvatarSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxAvatarSize,
});
}
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const kind = this.prefixToKind(prefix);
const metadata = this.requireAllowedMetadata({
metadata: await this.mediaService.getMetadata({
@@ -143,13 +143,13 @@ export class AvatarService {
}> {
const {errorPath, base64Image, guildFeatures} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const maxEmojiSize = this.resolveSizeLimit('emoji_max_size', EMOJI_MAX_SIZE, guildFeatures);
if (imageBuffer.length > maxEmojiSize) {
if (Buffer.byteLength(base64Data, 'base64') > maxEmojiSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxEmojiSize,
});
}
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const metadata = this.requireAllowedMetadata({
metadata: await this.mediaService.getMetadata({
type: 'base64',
@@ -244,13 +244,13 @@ export class AvatarService {
}> {
const {errorPath, base64Image, guildFeatures} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const maxStickerSize = this.resolveSizeLimit('sticker_max_size', STICKER_MAX_SIZE, guildFeatures);
if (imageBuffer.length > maxStickerSize) {
if (Buffer.byteLength(base64Data, 'base64') > maxStickerSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxStickerSize,
});
}
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const metadata = this.requireAllowedMetadata({
metadata: await this.mediaService.getMetadata({
type: 'base64',
@@ -291,13 +291,13 @@ export class EntityAssetService {
animated: boolean;
}> {
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const maxAvatarSize = this.resolveAvatarSizeLimit();
if (imageBuffer.length > maxAvatarSize) {
if (Buffer.byteLength(base64Data, 'base64') > maxAvatarSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxAvatarSize,
});
}
const imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
const kind = this.mapAssetTypeToAssetKind(assetType);
const metadata = this.requireAllowedMetadata({
metadata: await this.mediaService.getMetadata({
@@ -42,6 +42,10 @@ export type MediaProxyMetadataRequest =
| MediaProxyMetadataBase64Request
| MediaProxyMetadataS3Request;
export interface MediaProxyMetadataOptions {
sizeBytes?: number;
}
export interface MediaProxyMetadataResponse {
format: string;
content_type: string;
@@ -83,7 +87,10 @@ export interface MediaProxyFrameResponse {
}
export abstract class IMediaService {
abstract getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null>;
abstract getMetadata(
request: MediaProxyMetadataRequest,
options?: MediaProxyMetadataOptions,
): Promise<MediaProxyMetadataResponse | null>;
abstract getExternalMediaProxyURL(url: string): string;
@@ -7,6 +7,7 @@ import {
MEDIA_PROXY_METADATA_REQUEST_VERSION,
type MediaProxyFrameRequest,
type MediaProxyFrameResponse,
type MediaProxyMetadataOptions,
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
type MediaProxyNsfwMode,
@@ -36,6 +37,17 @@ 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');
const MEDIA_PROXY_BUFFERED_INPUT_MAX_BYTES = 500 * 1024 * 1024;
const MEDIA_PROXY_SPOOLED_INPUT_BYTES_PER_SECOND = 16 * 1024 * 1024;
export function metadataRequestTimeoutMs(sizeBytes: number | undefined): number {
if (sizeBytes === undefined || sizeBytes <= MEDIA_PROXY_BUFFERED_INPUT_MAX_BYTES) {
return MEDIA_PROXY_REQUEST_TIMEOUT_MS;
}
return (
MEDIA_PROXY_REQUEST_TIMEOUT_MS + Math.ceil(sizeBytes / MEDIA_PROXY_SPOOLED_INPUT_BYTES_PER_SECOND) * ms('1 second')
);
}
function isMediaProxySniffResponse(value: unknown): value is MediaProxySniffResponse {
return isJsonRecord(value) && (value.content_type === null || typeof value.content_type === 'string');
@@ -105,9 +117,16 @@ export class MediaService extends IMediaService {
this.proxyURL = new URL(Config.endpoints.media);
}
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
async getMetadata(
request: MediaProxyMetadataRequest,
options: MediaProxyMetadataOptions = {},
): Promise<MediaProxyMetadataResponse | null> {
const nsfwMode = this.getNsfwMode(request);
const response = await this.makeRequest('/_metadata', this.toMetadataWireRequest(request, nsfwMode));
const response = await this.makeRequest(
'/_metadata',
this.toMetadataWireRequest(request, nsfwMode),
metadataRequestTimeoutMs(options.sizeBytes),
);
if (!response) {
return null;
}
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {metadataRequestTimeoutMs} from '@app/api/infrastructure/MediaService';
import {describe, expect, it} from 'vitest';
const MIB = 1024 * 1024;
describe('metadataRequestTimeoutMs', () => {
it.each([[undefined], [0], [100 * MIB], [500 * MIB]])(
'keeps the 30 second window for an input of %s bytes that media-proxy buffers',
(sizeBytes) => {
expect(metadataRequestTimeoutMs(sizeBytes)).toBe(30_000);
},
);
it.each([
[500 * MIB + 1, 62_000],
[700 * MIB, 74_000],
[2 * 1024 * MIB, 158_000],
[8 * 1024 * MIB, 542_000],
])('widens the window for an input of %s bytes that media-proxy copies to disk first', (sizeBytes, timeoutMs) => {
expect(metadataRequestTimeoutMs(sizeBytes)).toBe(timeoutMs);
});
});
@@ -228,15 +228,59 @@ async function stripMetadataWithExiftool(data: Uint8Array, extension: string): P
}
}
async function stripVideoMetadataInPlace(inputPath: string, outputPath: string): Promise<void> {
await execFilePromise('ffmpeg', [
function ffmpegMuxerForContentType(contentType: string): string | null {
if (contentType.includes('3gpp')) return '3gp';
if (contentType.includes('mp4')) return 'mp4';
if (contentType.includes('webm')) return 'webm';
if (contentType.includes('quicktime')) return 'mov';
if (contentType.includes('x-matroska')) return 'matroska';
if (contentType.includes('x-msvideo')) return 'avi';
if (contentType.includes('x-flv')) return 'flv';
if (contentType.includes('mp2t')) return 'mpegts';
if (contentType.includes('mpeg')) return contentType.startsWith('audio/') ? 'mp3' : 'mpeg';
if (contentType.includes('x-ms-wmv')) return 'asf';
if (contentType.includes('wav')) return 'wav';
if (contentType.includes('flac')) return 'flac';
if (contentType.includes('aac')) return 'adts';
if (contentType.includes('aiff')) return 'aiff';
if (contentType.includes('ogg')) return 'ogg';
return null;
}
const FFMPEG_FAST_START_MUXERS = new Set(['mp4', 'mov', '3gp']);
const FFMPEG_DEMUXER_FOR_MUXER: Readonly<Record<string, string>> = {
'3gp': 'mp4',
mov: 'mp4',
webm: 'matroska',
adts: 'aac',
};
const FFMPEG_STRIP_TIMEOUT_MS = 10 * 60 * 1000;
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const FFPROBE_TIMEOUT_MS = 30 * 1000;
const FFPROBE_MAX_BUFFER_BYTES = 64 * 1024;
function requireFfmpegMuxer(contentType: string): string {
const muxer = ffmpegMuxerForContentType(contentType);
if (!muxer) {
throw new Error(`No ffmpeg output format for content type ${contentType}`);
}
return muxer;
}
export function ffmpegMetadataStripArgs(inputPath: string, outputPath: string, contentType: string): Array<string> {
const muxer = requireFfmpegMuxer(contentType);
return [
'-hide_banner',
'-loglevel',
'warning',
'error',
'-i',
inputPath,
'-map',
'0',
'0:v?',
'-map',
'0:a?',
'-map',
'0:s?',
'-map_metadata',
'-1',
'-map_metadata:s',
@@ -247,9 +291,29 @@ async function stripVideoMetadataInPlace(inputPath: string, outputPath: string):
'-1',
'-c',
'copy',
...(FFMPEG_FAST_START_MUXERS.has(muxer) ? ['-movflags', '+faststart'] : []),
'-f',
muxer,
'-y',
outputPath,
]);
];
}
export function ffmpegContainerMatchesContentType(formatNames: string, contentType: string): boolean {
const muxer = requireFfmpegMuxer(contentType);
return formatNames
.trim()
.split(',')
.includes(FFMPEG_DEMUXER_FOR_MUXER[muxer] ?? muxer);
}
async function probeFfmpegContainer(inputPath: string): Promise<string> {
const {stdout} = await execFilePromise(
'ffprobe',
['-v', 'error', '-show_entries', 'format=format_name', '-of', 'default=nw=1:nk=1', inputPath],
{timeout: FFPROBE_TIMEOUT_MS, maxBuffer: FFPROBE_MAX_BUFFER_BYTES},
);
return stdout;
}
async function cleanupTempFiles(paths: ReadonlyArray<string>): Promise<
@@ -392,8 +456,16 @@ async function stripVideoFileToFile(
outputPath: string,
contentType: string,
): Promise<{contentType: string; width?: number; height?: number}> {
await stripVideoMetadataInPlace(inputPath, outputPath);
return {contentType: normalizeContentType(contentType)};
const normalizedContentType = normalizeContentType(contentType);
const container = await probeFfmpegContainer(inputPath);
if (!ffmpegContainerMatchesContentType(container, normalizedContentType)) {
throw new Error(`Container ${container.trim()} does not match content type ${normalizedContentType}`);
}
await execFilePromise('ffmpeg', ffmpegMetadataStripArgs(inputPath, outputPath, normalizedContentType), {
timeout: FFMPEG_STRIP_TIMEOUT_MS,
maxBuffer: FFMPEG_MAX_BUFFER_BYTES,
});
return {contentType: normalizedContentType};
}
export async function processMediaFile(
@@ -0,0 +1,188 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {execFileSync, spawnSync} from 'node:child_process';
import fs from 'node:fs';
import {
ffmpegContainerMatchesContentType,
ffmpegMetadataStripArgs,
processMediaFile,
} from '@app/api/infrastructure/StorageObjectHelpers';
import {temporaryFile} from 'tempy';
import {afterEach, describe, expect, it} from 'vitest';
const INPUT_PATH = '/tmp/0123456789abcdef';
const OUTPUT_PATH = '/tmp/fedcba9876543210';
function outputFormat(contentType: string): string | undefined {
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, contentType);
const flag = args.indexOf('-f');
return flag === -1 ? undefined : args[flag + 1];
}
describe('ffmpegMetadataStripArgs', () => {
it.each([
['video/mp4', 'mp4'],
['video/quicktime', 'mov'],
['video/3gpp', '3gp'],
['video/webm', 'webm'],
['video/x-matroska', 'matroska'],
['video/x-msvideo', 'avi'],
['video/x-flv', 'flv'],
['video/mp2t', 'mpegts'],
['video/mpeg', 'mpeg'],
['video/x-ms-wmv', 'asf'],
['video/ogg', 'ogg'],
['audio/mpeg', 'mp3'],
['audio/mp4', 'mp4'],
['audio/ogg', 'ogg'],
['audio/webm', 'webm'],
['audio/wav', 'wav'],
['audio/flac', 'flac'],
['audio/aac', 'adts'],
['audio/aiff', 'aiff'],
])('names the %s output format because the output path has no extension', (contentType, muxer) => {
expect(outputFormat(contentType)).toBe(muxer);
});
it('writes to the output path last and reads the input path', () => {
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, 'video/mp4');
expect(args[args.indexOf('-i') + 1]).toBe(INPUT_PATH);
expect(args.at(-1)).toBe(OUTPUT_PATH);
});
it.each([
['video/mp4', true],
['video/quicktime', true],
['video/webm', false],
['audio/mpeg', false],
])('moves the %s index to the front of the file: %s', (contentType, fastStart) => {
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, contentType);
expect(args.includes('+faststart')).toBe(fastStart);
});
it('refuses a container it has no output format for', () => {
expect(() => ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, 'video/x-unknown-container')).toThrow(
/output format/,
);
});
it('keeps video, audio and subtitle streams and leaves data streams behind', () => {
const args = ffmpegMetadataStripArgs(INPUT_PATH, OUTPUT_PATH, 'video/quicktime');
const maps = args.flatMap((arg, index) => (arg === '-map' ? [args[index + 1]] : []));
expect(maps).toEqual(['0:v?', '0:a?', '0:s?']);
});
});
describe('ffmpegContainerMatchesContentType', () => {
it.each([
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/mp4'],
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/quicktime'],
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/3gpp'],
['mov,mp4,m4a,3gp,3g2,mj2\n', 'audio/mp4'],
['matroska,webm\n', 'video/webm'],
['matroska,webm\n', 'video/x-matroska'],
['matroska,webm\n', 'audio/webm'],
['avi\n', 'video/x-msvideo'],
['flv\n', 'video/x-flv'],
['mpegts\n', 'video/mp2t'],
['mpeg\n', 'video/mpeg'],
['asf\n', 'video/x-ms-wmv'],
['ogg\n', 'video/ogg'],
['mp3\n', 'audio/mpeg'],
['ogg\n', 'audio/ogg'],
['wav\n', 'audio/wav'],
['flac\n', 'audio/flac'],
['aac\n', 'audio/aac'],
['aiff\n', 'audio/aiff'],
])('accepts the %s container for %s', (formatNames, contentType) => {
expect(ffmpegContainerMatchesContentType(formatNames, contentType)).toBe(true);
});
it.each([
['mov,mp4,m4a,3gp,3g2,mj2\n', 'video/x-matroska'],
['matroska,webm\n', 'video/mp4'],
['mp3\n', 'video/mpeg'],
['mpeg\n', 'audio/mpeg'],
['mpegts\n', 'video/mpeg'],
['wav\n', 'audio/flac'],
])('refuses the %s container for %s', (formatNames, contentType) => {
expect(ffmpegContainerMatchesContentType(formatNames, contentType)).toBe(false);
});
});
const hasFfmpeg = ['ffmpeg', 'ffprobe'].every((tool) => spawnSync(tool, ['-version']).status === 0);
describe.skipIf(!hasFfmpeg)('processMediaFile with ffmpeg', () => {
const paths: Array<string> = [];
function tempPath(): string {
const path = temporaryFile();
paths.push(path);
return path;
}
function generate(args: ReadonlyArray<string>): string {
const path = tempPath();
execFileSync('ffmpeg', [
'-loglevel',
'error',
'-f',
'lavfi',
'-i',
'testsrc=s=64x48:r=5',
'-f',
'lavfi',
'-i',
'sine=f=440',
'-t',
'1',
'-c:v',
'mpeg4',
'-c:a',
'aac',
'-metadata',
'title=private-title',
...args,
'-y',
path,
]);
return path;
}
function probe(path: string): {
format: {format_name: string; tags?: Record<string, string>};
streams: Array<{codec_type: string}>;
} {
return JSON.parse(
execFileSync('ffprobe', ['-v', 'error', '-show_format', '-show_streams', '-of', 'json', path]).toString(),
);
}
afterEach(() => {
for (const path of paths.splice(0)) {
fs.rmSync(path, {force: true});
}
});
it('rewrites a QuickTime file without its tags and data stream at paths that have no extension', async () => {
const input = generate(['-timecode', '01:00:00:00', '-f', 'mov']);
expect(probe(input).format.tags?.title).toBe('private-title');
expect(probe(input).streams.map((stream) => stream.codec_type)).toContain('data');
const output = tempPath();
const processed = await processMediaFile(input, output, 'video/quicktime');
const stripped = probe(output);
expect(processed?.contentType).toBe('video/quicktime');
expect(processed?.contentLength).toBe(fs.statSync(output).size);
expect(stripped.format.format_name).toContain('mov');
expect(stripped.format.tags?.title).toBeUndefined();
expect(stripped.streams.map((stream) => stream.codec_type).sort()).toEqual(['audio', 'video']);
});
it('refuses a file whose container is not the one its content type names', async () => {
const input = generate(['-f', 'matroska']);
await expect(processMediaFile(input, tempPath(), 'video/mp4')).rejects.toThrow(/does not match content type/);
});
});
@@ -950,6 +950,32 @@ function decodeInstanceMediaConfig(value: unknown): InstanceMediaConfig {
return validateStoredConfig(StoredInstanceMediaSchema, value, 'media');
}
function resolveEffectiveAttachmentDecayConfig(media: InstanceMediaConfig): InstanceAttachmentDecayEffectiveConfig {
const attachmentDecay = media.attachment_decay;
const minSizeMb = attachmentDecay.min_size_mb ?? DEFAULT_DECAY_CONSTANTS.MIN_MB;
const configuredMaxSizeMb = attachmentDecay.max_size_mb ?? DEFAULT_DECAY_CONSTANTS.MAX_MB;
const maxSizeMb = computeAttachmentDecayMaxSize(minSizeMb, configuredMaxSizeMb);
const configuredMaxEligibleSizeMb = attachmentDecay.max_eligible_size_mb ?? DEFAULT_DECAY_CONSTANTS.PLAN_MB;
const maxEligibleSizeMb = Math.max(maxSizeMb, configuredMaxEligibleSizeMb);
const minLifetimeDays = attachmentDecay.min_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MIN_DAYS;
const configuredMaxLifetimeDays = attachmentDecay.max_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MAX_DAYS;
const maxLifetimeDays =
configuredMaxLifetimeDays >= minLifetimeDays
? configuredMaxLifetimeDays
: Math.max(DEFAULT_DECAY_CONSTANTS.MAX_DAYS, minLifetimeDays);
return {
enabled: attachmentDecay.enabled ?? Config.attachmentDecayEnabled,
min_size_mb: minSizeMb,
max_size_mb: maxSizeMb,
max_eligible_size_mb: maxEligibleSizeMb,
min_lifetime_days: minLifetimeDays,
max_lifetime_days: maxLifetimeDays,
curve: attachmentDecay.curve ?? DEFAULT_DECAY_CONSTANTS.CURVE,
renew_threshold_days: attachmentDecay.renew_threshold_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS,
renew_window_days: attachmentDecay.renew_window_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS,
};
}
function parseStoredInstanceMediaConfig(raw: string | null): InstanceMediaConfig {
return decodeInstanceMediaConfig(parseStoredConfigValue(raw, 'media'));
}
@@ -1881,30 +1907,12 @@ export class InstanceConfigRepository {
}
async getEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
const media = await this.getInstanceMediaConfig();
const attachmentDecay = media.attachment_decay;
const minSizeMb = attachmentDecay.min_size_mb ?? DEFAULT_DECAY_CONSTANTS.MIN_MB;
const configuredMaxSizeMb = attachmentDecay.max_size_mb ?? DEFAULT_DECAY_CONSTANTS.MAX_MB;
const maxSizeMb = computeAttachmentDecayMaxSize(minSizeMb, configuredMaxSizeMb);
const configuredMaxEligibleSizeMb = attachmentDecay.max_eligible_size_mb ?? DEFAULT_DECAY_CONSTANTS.PLAN_MB;
const maxEligibleSizeMb = Math.max(maxSizeMb, configuredMaxEligibleSizeMb);
const minLifetimeDays = attachmentDecay.min_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MIN_DAYS;
const configuredMaxLifetimeDays = attachmentDecay.max_lifetime_days ?? DEFAULT_DECAY_CONSTANTS.MAX_DAYS;
const maxLifetimeDays =
configuredMaxLifetimeDays >= minLifetimeDays
? configuredMaxLifetimeDays
: Math.max(DEFAULT_DECAY_CONSTANTS.MAX_DAYS, minLifetimeDays);
return {
enabled: attachmentDecay.enabled ?? Config.attachmentDecayEnabled,
min_size_mb: minSizeMb,
max_size_mb: maxSizeMb,
max_eligible_size_mb: maxEligibleSizeMb,
min_lifetime_days: minLifetimeDays,
max_lifetime_days: maxLifetimeDays,
curve: attachmentDecay.curve ?? DEFAULT_DECAY_CONSTANTS.CURVE,
renew_threshold_days: attachmentDecay.renew_threshold_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS,
renew_window_days: attachmentDecay.renew_window_days ?? DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS,
};
return resolveEffectiveAttachmentDecayConfig(await this.getInstanceMediaConfig());
}
async fetchStoredEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
const raw = await this.fetchConfigFromDatabase(INSTANCE_MEDIA_CONFIG_KEY);
return resolveEffectiveAttachmentDecayConfig(parseStoredInstanceMediaConfig(raw));
}
async getInstanceMediaAdminConfig(): Promise<InstanceMediaAdminConfig> {
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {VALID_GIF_BASE64, VALID_PNG_BASE64} from '@app/api/emoji/tests/EmojiTestUtils';
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {AVATAR_MAX_SIZE, EMOJI_MAX_SIZE, STICKER_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const RAISED_EXPRESSION_SIZE = EMOJI_MAX_SIZE * 4;
const RAISED_AVATAR_SIZE = AVATAR_MAX_SIZE + 2 * 1024 * 1024;
interface LimitConfigReadResponse {
limit_config: LimitConfigSnapshot;
}
interface ValidationErrorResponse {
errors?: Array<{path?: string; code?: string; message?: string}>;
}
function paddedImage(mime: string, base64: string, byteLength: number): string {
const image = Buffer.from(base64, 'base64');
const padded = Buffer.concat([image, Buffer.alloc(byteLength - image.length)]);
return `data:${mime};base64,${padded.toString('base64')}`;
}
describe('configured image size limits', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
const raiseLimits = async (limits: Partial<Record<LimitKey, number>>): Promise<void> => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
]);
const current = await createBuilder<LimitConfigReadResponse>(harness, admin.token)
.get('/admin/limit-config')
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: current.limit_config.traitDefinitions,
rules: current.limit_config.rules.map((rule) => ({
id: rule.id,
filters: rule.filters,
limits: rule.id === 'very_large_guild' ? rule.limits : {...rule.limits, ...limits},
})),
},
})
.expect(HTTP_STATUS.OK)
.execute();
};
const createOwner = async (): Promise<TestAccount> => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
return owner;
};
it('accepts emoji and sticker uploads above the stock size once the limits are raised', async () => {
await raiseLimits({emoji_max_size: RAISED_EXPRESSION_SIZE, sticker_max_size: RAISED_EXPRESSION_SIZE});
const owner = await createOwner();
const guild = await createGuild(harness, owner.token, 'Raised expression limits');
const image = paddedImage('image/gif', VALID_GIF_BASE64, Math.max(EMOJI_MAX_SIZE, STICKER_MAX_SIZE) * 2);
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/emojis`)
.body({name: 'large_emoji', image})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/stickers`)
.body({name: 'large_sticker', description: 'large sticker', tags: [], image})
.expect(HTTP_STATUS.OK)
.execute();
});
it('rejects an emoji over a raised limit with the configured size', async () => {
await raiseLimits({emoji_max_size: RAISED_EXPRESSION_SIZE});
const owner = await createOwner();
const guild = await createGuild(harness, owner.token, 'Raised emoji ceiling');
const json = await createBuilder<ValidationErrorResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/emojis`)
.body({name: 'too_large', image: paddedImage('image/gif', VALID_GIF_BASE64, RAISED_EXPRESSION_SIZE + 1)})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(json.errors?.[0]).toMatchObject({
path: 'image',
code: ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT,
message: `Image size exceeds ${RAISED_EXPRESSION_SIZE} bytes.`,
});
});
it('accepts an avatar above the stock size once avatar_max_size is raised', async () => {
await raiseLimits({avatar_max_size: RAISED_AVATAR_SIZE});
const account = await createOwner();
const json = await createBuilder<{avatar: string | null}>(harness, account.token)
.patch('/users/@me')
.body({avatar: paddedImage('image/png', VALID_PNG_BASE64, AVATAR_MAX_SIZE + 1024 * 1024)})
.expect(HTTP_STATUS.OK)
.execute();
expect(json.avatar).toBeTruthy();
});
it('rejects an avatar over a raised limit with the configured size', async () => {
await raiseLimits({avatar_max_size: RAISED_AVATAR_SIZE});
const account = await createOwner();
const json = await createBuilder<ValidationErrorResponse>(harness, account.token)
.patch('/users/@me')
.body({avatar: paddedImage('image/png', VALID_PNG_BASE64, RAISED_AVATAR_SIZE + 1)})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(json.errors?.[0]).toMatchObject({
path: 'avatar',
code: ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT,
message: `Image size exceeds ${RAISED_AVATAR_SIZE} bytes.`,
});
});
});
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
import {AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
import {AttachmentDecayService, resolveDefaultAttachmentDecayConfig} 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';
@@ -378,9 +378,10 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
uploadedAt: snowflakeToDate(ownerMessageId),
})),
);
const decayEnabled = (await resolveDefaultAttachmentDecayConfig()).enabled;
return Promise.all(
attachments.map(async (attachment) => {
const decay = await this.attachmentDecayRepository.fetchById(attachment.id);
const decay = decayEnabled ? await this.attachmentDecayRepository.fetchById(attachment.id) : null;
const url = this.mapAttachmentUrl(message, attachment);
return {
id: attachment.id.toString(),
@@ -69,6 +69,7 @@ const LANE_CONFIG = {
consumerName: 'workers_batch',
tasks: [
'expireAttachments',
'reconcileAttachmentDecay',
'expireReportSnapshots',
'clearAuthenticatedReporterEmails',
'expireStaleJobs',
@@ -40,6 +40,7 @@ import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/Proces
import processStorePurchaseRefreshQueue from '@app/api/worker/tasks/ProcessStorePurchaseRefreshQueue';
import processStripeWebhook from '@app/api/worker/tasks/ProcessStripeWebhook';
import prunePostgresKvTtl from '@app/api/worker/tasks/PrunePostgresKvTtl';
import reconcileAttachmentDecay from '@app/api/worker/tasks/ReconcileAttachmentDecay';
import reconcileUserPayments from '@app/api/worker/tasks/ReconcileUserPayments';
import refreshSearchIndex from '@app/api/worker/tasks/RefreshSearchIndex';
import refreshStorePurchase from '@app/api/worker/tasks/RefreshStorePurchase';
@@ -104,6 +105,7 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
processInactivityDeletions,
processPendingBulkMessageDeletions,
processPremiumStateReconciliationQueue,
reconcileAttachmentDecay,
reconcileUserPayments,
processAppStoreNotification,
processGooglePlayNotification,
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isAttachmentDecayReconcileRunning} from '@app/api/attachment/AttachmentDecayReconcileRun';
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {Logger} from '@app/api/Logger';
@@ -11,12 +12,16 @@ const BUCKET_LOOKBACK_DAYS = 3;
const FETCH_LIMIT = 200;
export async function processExpiredAttachments(now = new Date()): Promise<void> {
const {assetDeletionQueue, instanceConfigRepository} = getWorkerDependencies();
const {assetDeletionQueue, instanceConfigRepository, kvClient} = getWorkerDependencies();
const attachmentDecay = await instanceConfigRepository.getEffectiveAttachmentDecayConfig();
if (!attachmentDecay.enabled) {
Logger.info('Attachment decay disabled; skipping expireAttachments task');
return;
}
if (await isAttachmentDecayReconcileRunning(kvClient)) {
Logger.info('Attachment decay reconcile in progress; skipping expireAttachments task');
return;
}
const repo = new AttachmentDecayRepository();
let totalQueued = 0;
let totalDeletedRows = 0;
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
attachmentDecayReconcileJobKey,
finishAttachmentDecayReconcileRun,
holdAttachmentDecayReconcileRun,
} from '@app/api/attachment/AttachmentDecayReconcileRun';
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
import {type AttachmentDecayPayload, AttachmentDecayService} from '@app/api/attachment/AttachmentDecayService';
import type {ChannelID, MessageID} from '@app/api/BrandedTypes';
import {createChannelID, createMessageID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
attachmentDecayPayloadsForMessage,
makeAttachmentCdnKey,
} from '@app/api/channel/services/message/MessageHelpers';
import {Logger} from '@app/api/Logger';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
const CHANNEL_PAGE_SIZE = 1000;
const CursorSchema = z.object({
pageState: z.string().nullable(),
channelId: z.string().nullable(),
beforeMessageId: z.string().nullable(),
});
const PayloadSchema = z.object({
runId: z.string(),
chunk: z.number().int().nonnegative(),
cursor: CursorSchema.optional(),
});
type ReconcileCursor = z.infer<typeof CursorSchema>;
export type ReconcileAttachmentDecayPayload = z.infer<typeof PayloadSchema>;
interface ReconcileChunkLimits {
messagePageSize: number;
messagePages: number;
}
const DEFAULT_CHUNK_LIMITS: ReconcileChunkLimits = {messagePageSize: 500, messagePages: 20};
interface ChunkProgress {
messagePageSize: number;
messagePagesLeft: number;
channels: number;
changed: number;
}
async function attachmentFileExists(attachment: AttachmentDecayPayload): Promise<boolean> {
const {storageService} = getWorkerDependencies();
const key = makeAttachmentCdnKey(attachment.channelId, attachment.attachmentId, attachment.filename);
return (await storageService.getObjectMetadata(Config.s3.buckets.cdn, key)) !== null;
}
async function reconcileChannel(
service: AttachmentDecayService,
channelId: ChannelID,
beforeMessageId: MessageID | undefined,
progress: ChunkProgress,
): Promise<MessageID | null> {
const {channelRepository} = getWorkerDependencies();
let cursor = beforeMessageId;
while (true) {
if (progress.messagePagesLeft <= 0 && cursor !== undefined) return cursor;
const messages = await channelRepository.messages.listMessages(channelId, cursor, progress.messagePageSize);
progress.messagePagesLeft--;
if (messages.length === 0) break;
progress.changed += await service.reconcile(
messages.flatMap(attachmentDecayPayloadsForMessage),
attachmentFileExists,
);
if (messages.length < progress.messagePageSize) break;
cursor = messages[messages.length - 1]!.id;
}
progress.channels++;
return null;
}
async function reconcileFromCursor(
service: AttachmentDecayService,
repo: AttachmentDecayRepository,
cursor: ReconcileCursor,
progress: ChunkProgress,
): Promise<ReconcileCursor | null> {
let {pageState, channelId} = cursor;
if (channelId !== null && cursor.beforeMessageId !== null) {
const beforeMessageId = await reconcileChannel(
service,
createChannelID(BigInt(channelId)),
createMessageID(BigInt(cursor.beforeMessageId)),
progress,
);
if (beforeMessageId !== null) return {pageState, channelId, beforeMessageId: beforeMessageId.toString()};
}
while (true) {
const page = await repo.scanChannelsWithMessagesPage(CHANNEL_PAGE_SIZE, pageState);
const handled = channelId === null ? -1 : page.channelIds.findIndex((id) => id.toString() === channelId);
for (const nextChannelId of page.channelIds.slice(handled + 1)) {
if (progress.messagePagesLeft <= 0) return {pageState, channelId, beforeMessageId: null};
channelId = nextChannelId.toString();
const beforeMessageId = await reconcileChannel(service, nextChannelId, undefined, progress);
if (beforeMessageId !== null) return {pageState, channelId, beforeMessageId: beforeMessageId.toString()};
}
if (page.pageState === null) return null;
pageState = page.pageState;
}
}
export async function reconcileAttachmentDecayChunk(
payload: ReconcileAttachmentDecayPayload,
limits = DEFAULT_CHUNK_LIMITS,
): Promise<ReconcileAttachmentDecayPayload | null> {
const {runId, chunk} = payload;
if (!Config.instance.selfHosted) {
Logger.info({runId, chunk}, 'Attachment decay reconcile only runs on self-hosted instances; stopping');
return null;
}
const {instanceConfigRepository, kvClient} = getWorkerDependencies();
if (!(await holdAttachmentDecayReconcileRun(kvClient, runId))) {
Logger.info({runId, chunk}, 'Attachment decay reconcile superseded by a newer run; stopping');
return null;
}
const config = await instanceConfigRepository.fetchStoredEffectiveAttachmentDecayConfig();
if (!config.enabled) {
await finishAttachmentDecayReconcileRun(kvClient, runId);
Logger.info({runId, chunk}, 'Attachment decay disabled; stopping reconcileAttachmentDecay task');
return null;
}
const repo = new AttachmentDecayRepository();
const service = new AttachmentDecayService(repo, async () => config);
const progress: ChunkProgress = {
messagePageSize: limits.messagePageSize,
messagePagesLeft: limits.messagePages,
channels: 0,
changed: 0,
};
const cursor = await reconcileFromCursor(
service,
repo,
payload.cursor ?? {pageState: null, channelId: null, beforeMessageId: null},
progress,
);
const summary = {runId, chunk, channels: progress.channels, changed: progress.changed};
if (cursor !== null) {
Logger.info(summary, 'Reconciled a chunk of attachment decay records');
return {runId, chunk: chunk + 1, cursor};
}
await finishAttachmentDecayReconcileRun(kvClient, runId);
Logger.info(summary, 'Reconciled attachment decay records with the instance settings');
return null;
}
const reconcileAttachmentDecay: WorkerTaskHandler = async (payload, helpers) => {
const next = await reconcileAttachmentDecayChunk(PayloadSchema.parse(payload));
if (next === null) return;
await helpers.addJob('reconcileAttachmentDecay', next, {
jobKey: attachmentDecayReconcileJobKey(next.runId, next.chunk),
skipLedger: true,
});
};
export default reconcileAttachmentDecay;
@@ -2,8 +2,10 @@
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
import {createAttachmentID, createChannelID, createMessageID} from '@app/api/BrandedTypes';
import {getConfig} from '@app/api/Config';
import type {IAssetDeletionQueue, QueuedAssetDeletion} from '@app/api/infrastructure/IAssetDeletionQueue';
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {getExpiryBucket} from '@app/api/utils/AttachmentDecay';
import {processExpiredAttachments} from '@app/api/worker/tasks/ExpireAttachments';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
@@ -28,9 +30,10 @@ function createQueue(): {queue: IAssetDeletionQueue; queued: Array<QueuedAssetDe
return {queue, queued};
}
function installDependencies(queue: IAssetDeletionQueue): void {
function installDependencies(queue: IAssetDeletionQueue, kvClient = new MockKVProvider()): void {
setWorkerDependenciesForTest({
assetDeletionQueue: queue,
kvClient,
instanceConfigRepository: {
async getEffectiveAttachmentDecayConfig() {
return {enabled: true};
@@ -57,10 +60,29 @@ async function writeDecayRecord(repository: AttachmentDecayRepository, expiresAt
}
describe('processExpiredAttachments', () => {
const originalSelfHosted = getConfig().instance.selfHosted;
afterEach(() => {
getConfig().instance.selfHosted = originalSelfHosted;
clearWorkerDependencies();
});
it.each([
[true, []],
[false, [`attachments/${CHANNEL_ID}/${ATTACHMENT_ID}/${FILENAME}`]],
])('pauses deletion during a reconcile run only when self-hosted (%s)', async (selfHosted, expectedKeys) => {
getConfig().instance.selfHosted = selfHosted;
const repository = new AttachmentDecayRepository();
const {queue, queued} = createQueue();
const kvClient = new MockKVProvider();
await kvClient.setex('attachment_decay:reconcile_run', 60, 'run');
installDependencies(queue, kvClient);
await writeDecayRecord(repository, FIRST_EXPIRY);
await processExpiredAttachments(new Date(FIRST_EXPIRY.getTime() + ms('1 day')));
expect(queued.map((item) => item.s3Key)).toEqual(expectedKeys);
});
it('keeps the decay record when it clears a superseded expiry row', async () => {
const repository = new AttachmentDecayRepository();
const {queue} = createQueue();