mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(media): stream large files and honour size settings (#3387)
This commit is contained in:
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();
|
||||
|
||||
Reference in new issue
Block a user