fix(api): bound storage listings, ranges and search paging (#2344)

This commit is contained in:
Hampus
2026-09-02 17:23:12 +02:00
committed by GitHub
parent e86e24a2db
commit c79c0ee138
8 changed files with 479 additions and 130 deletions
+30 -11
View File
@@ -11,7 +11,12 @@ import type {
DesktopPlatform,
} from '@fluxer/schema/src/domains/download/DownloadSchemas';
import {Config} from '../Config';
import type {IStorageService} from '../infrastructure/IStorageService';
import {
type IStorageService,
StorageObjectListingOverflowError,
StorageObjectRangeNotSatisfiableError,
} from '../infrastructure/IStorageService';
import {Logger} from '../Logger';
import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
@@ -45,10 +50,14 @@ function isStorageNotFoundError(error: unknown): boolean {
}
function isUnsatisfiableRangeError(error: unknown): boolean {
if (error instanceof StorageObjectRangeNotSatisfiableError) {
return true;
}
return (
error instanceof S3ServiceException && (error.name === 'InvalidRange' || error.$metadata?.httpStatusCode === 416)
);
}
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
@@ -349,11 +358,8 @@ export class DownloadService {
}
const prefix = `${basePrefix}/`;
try {
const objects = await this.storageService.listObjects({
bucket: Config.s3.buckets.downloads,
prefix,
});
if (!objects || objects.length === 0) {
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return {versions: [], hasMore: false};
}
const versionMap = new Map<
@@ -791,6 +797,22 @@ export class DownloadService {
return this.findLatestFilenameForRequestedArch(params);
}
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
try {
return await this.storageService.listObjects({
bucket: Config.s3.buckets.downloads,
prefix,
maxObjects: MAX_DESKTOP_OBJECTS_PER_PREFIX,
});
} catch (error) {
if (error instanceof StorageObjectListingOverflowError) {
Logger.warn({prefix, maxObjects: error.maxObjects}, 'Desktop artifact prefix outgrew its listing cap');
return [];
}
throw error;
}
}
private isFilenameCompatibleWithRequestedArch(params: ManifestFilenameResolutionParams): boolean {
const parsed = this.parseVersionFromFilename(params.filename, params.channel, params.plat, params.arch);
if (!parsed) {
@@ -815,11 +837,8 @@ export class DownloadService {
return null;
}
const prefix = `${basePrefix}/`;
const objects = await this.storageService.listObjects({
bucket: Config.s3.buckets.downloads,
prefix,
});
if (!objects || objects.length === 0) {
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return null;
}
let latestFilename: string | null = null;
@@ -2,6 +2,34 @@
import type {Readable} from 'node:stream';
export class StorageObjectRangeNotSatisfiableError extends Error {
readonly bucket: string;
readonly key: string;
readonly range: string;
constructor(bucket: string, key: string, range: string) {
super(`Requested range ${range} is not satisfiable for ${bucket}/${key}`);
this.name = 'StorageObjectRangeNotSatisfiableError';
this.bucket = bucket;
this.key = key;
this.range = range;
}
}
export class StorageObjectListingOverflowError extends Error {
readonly bucket: string;
readonly prefix: string;
readonly maxObjects: number;
constructor(bucket: string, prefix: string, maxObjects: number) {
super(`Object listing exceeds maximum of ${maxObjects} objects for ${bucket}/${prefix}`);
this.name = 'StorageObjectListingOverflowError';
this.bucket = bucket;
this.prefix = prefix;
this.maxObjects = maxObjects;
}
}
export interface ProcessedStorageObjectMetadata {
contentType: string;
contentLength: number;
@@ -97,7 +125,7 @@ export interface IStorageService {
purgeBucket(bucket: string): Promise<void>;
uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void>;
deleteAvatar(params: {prefix: string; key: string}): Promise<void>;
listObjects(params: {bucket: string; prefix: string}): Promise<
listObjects(params: {bucket: string; prefix: string; maxObjects?: number}): Promise<
ReadonlyArray<{
key: string;
lastModified?: Date;
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import type {Readable} from 'node:stream';
import {PassThrough, Readable} from 'node:stream';
import {describe, expect, it} from 'vitest';
import {Config} from '../Config';
import {StorageObjectListingOverflowError} from './IStorageService';
import {StorageService} from './StorageService';
interface CopyObjectTestParams {
@@ -210,3 +211,131 @@ describe('provider selection', () => {
expect(await service.client.config.region()).not.toBe(Config.s3.region);
});
});
function bodyStream(chunks: Array<Buffer>): PassThrough {
const stream = new PassThrough();
const writeChunk = (index: number): void => {
if (stream.destroyed) {
return;
}
const chunk = chunks[index];
if (chunk === undefined) {
stream.end();
return;
}
stream.write(chunk);
setImmediate(() => writeChunk(index + 1));
};
writeChunk(0);
return stream;
}
function serveGetObject(service: StorageService, out: {Body: PassThrough; ContentLength?: number}): void {
Object.assign(service, {client: {send: async () => out}});
}
describe('StorageService.readObject', () => {
it('refuses an object whose declared length is already over the cap', async () => {
const service = new StorageService();
const body = bodyStream([Buffer.alloc(64, 1)]);
serveGetObject(service, {Body: body, ContentLength: 64});
await expect(service.readObject('fluxer-downloads', 'desktop/stable/manifest.json', 32)).rejects.toThrow(
/exceeds maximum buffer size of 32 bytes \(got 64 bytes\)/u,
);
expect(body.destroyed).toBe(true);
});
it('refuses an object that outgrows the cap mid-stream when no length is declared', async () => {
const service = new StorageService();
const body = bodyStream([Buffer.alloc(24, 1), Buffer.alloc(24, 2)]);
serveGetObject(service, {Body: body});
await expect(service.readObject('fluxer-downloads', 'desktop/stable/manifest.json', 32)).rejects.toThrow(
/exceeds maximum buffer size of 32 bytes \(got at least 48 bytes\)/u,
);
expect(body.destroyed).toBe(true);
});
it('returns every byte of an object that exactly fills the cap', async () => {
const service = new StorageService();
serveGetObject(service, {Body: bodyStream([Buffer.alloc(16, 7), Buffer.alloc(16, 9)])});
await expect(service.readObject('fluxer-downloads', 'desktop/stable/manifest.json', 32)).resolves.toEqual(
new Uint8Array([...Array(16).fill(7), ...Array(16).fill(9)]),
);
});
it('rejects rather than hanging when the object store resets the body mid-transfer', async () => {
const service = new StorageService();
const reset = new Error('socket hang up');
const body = new Readable({read() {}});
Object.assign(service, {client: {send: async () => ({Body: body})}});
const read = service.readObject('fluxer-downloads', 'desktop/stable/manifest.json', 1024);
body.push(Buffer.alloc(8, 1));
setImmediate(() => body.destroy(reset));
await expect(read).rejects.toThrow(/socket hang up/u);
}, 5000);
});
interface ListObjectsPage {
Contents?: Array<{Key: string; LastModified?: Date}>;
IsTruncated?: boolean;
NextContinuationToken?: string;
}
function serveListPages(service: StorageService, pages: Array<ListObjectsPage>): {tokens: Array<string | undefined>} {
const tokens: Array<string | undefined> = [];
let index = 0;
Object.assign(service, {
client: {
send: async (command: {input: {ContinuationToken?: string}}) => {
tokens.push(command.input.ContinuationToken);
const page = pages[index] ?? {};
index += 1;
return page;
},
},
});
return {tokens};
}
describe('StorageService.listObjects', () => {
it('returns every page of a truncated listing', async () => {
const service = new StorageService();
const {tokens} = serveListPages(service, [
{
Contents: [{Key: 'desktop/a.exe'}, {Key: 'desktop/b.exe'}],
IsTruncated: true,
NextContinuationToken: 'page-2',
},
{Contents: [{Key: 'desktop/c.exe'}], IsTruncated: false},
]);
const objects = await service.listObjects({bucket: 'fluxer-downloads', prefix: 'desktop/'});
expect(objects.map(({key}) => key)).toEqual(['desktop/a.exe', 'desktop/b.exe', 'desktop/c.exe']);
expect(tokens).toEqual([undefined, 'page-2']);
});
it('throws a typed overflow error when the prefix outgrows the requested cap', async () => {
const service = new StorageService();
serveListPages(service, [
{
Contents: [{Key: 'desktop/a.exe'}, {Key: 'desktop/b.exe'}],
IsTruncated: true,
NextContinuationToken: 'page-2',
},
]);
await expect(
service.listObjects({bucket: 'fluxer-downloads', prefix: 'desktop/', maxObjects: 2}),
).rejects.toBeInstanceOf(StorageObjectListingOverflowError);
});
it('throws rather than returning a partial listing when the continuation token is missing', async () => {
const service = new StorageService();
serveListPages(service, [{Contents: [{Key: 'desktop/a.exe'}], IsTruncated: true}]);
await expect(service.listObjects({bucket: 'fluxer-downloads', prefix: 'desktop/'})).rejects.toThrow(
/continuation token/u,
);
});
});
@@ -32,7 +32,12 @@ import {seconds} from 'itty-time';
import {temporaryFile} from 'tempy';
import {Config} from '../Config';
import {Logger} from '../Logger';
import type {IStorageService, ProcessedStorageObjectMetadata} from './IStorageService';
import {
type IStorageService,
type ProcessedStorageObjectMetadata,
StorageObjectListingOverflowError,
StorageObjectRangeNotSatisfiableError,
} from './IStorageService';
import {processMediaFile} from './StorageObjectHelpers';
const STREAM_UPLOAD_PART_BYTES = 8 * 1024 * 1024;
@@ -73,18 +78,13 @@ async function streamToUint8Array(body: Readable, maxBytes?: number): Promise<Ui
for await (const chunk of body) {
const buf = chunk instanceof Buffer ? chunk : Buffer.from(chunk as Uint8Array);
if (maxBytes !== undefined && total + buf.length > maxBytes) {
const remaining = maxBytes - total;
if (remaining > 0) {
chunks.push(buf.subarray(0, remaining));
total += remaining;
}
break;
body.destroy();
throw new Error(
`Stream exceeds maximum buffer size of ${maxBytes} bytes (got at least ${total + buf.length} bytes)`,
);
}
chunks.push(buf);
total += buf.length;
if (maxBytes !== undefined && total >= maxBytes) {
break;
}
}
const out = new Uint8Array(total);
let offset = 0;
@@ -97,10 +97,15 @@ async function streamToUint8Array(body: Readable, maxBytes?: number): Promise<Ui
function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
const body = out.Body;
if (body instanceof Readable) {
return body instanceof PassThrough ? body : body.pipe(new PassThrough());
if (!(body instanceof Readable)) {
throw new Error('Unexpected S3 response body type (not a Node Readable)');
}
throw new Error('Unexpected S3 response body type (not a Node Readable)');
if (body instanceof PassThrough) {
return body;
}
const wrapped = new PassThrough();
pipeline(body, wrapped, () => undefined);
return wrapped;
}
export class StorageService implements IStorageService {
@@ -362,7 +367,12 @@ export class StorageService implements IStorageService {
async readObject(bucket: string, key: string, maxBytes?: number): Promise<Uint8Array> {
const out = await this.client.send(new GetObjectCommand({Bucket: bucket, Key: key}));
return streamToUint8Array(extractStreamFromGet(out), maxBytes);
const body = extractStreamFromGet(out);
if (maxBytes !== undefined && out.ContentLength !== undefined && out.ContentLength > maxBytes) {
body.destroy();
throw new Error(`Stream exceeds maximum buffer size of ${maxBytes} bytes (got ${out.ContentLength} bytes)`);
}
return streamToUint8Array(body, maxBytes);
}
async streamObject(params: {bucket: string; key: string; range?: string}): Promise<{
@@ -395,6 +405,13 @@ export class StorageService implements IStorageService {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return null;
}
if (
params.range !== undefined &&
error instanceof S3ServiceException &&
(error.name === 'InvalidRange' || error.$metadata?.httpStatusCode === 416)
) {
throw new StorageObjectRangeNotSatisfiableError(params.bucket, params.key, params.range);
}
throw error;
}
}
@@ -563,31 +580,49 @@ export class StorageService implements IStorageService {
return this.client.send(new HeadObjectCommand({Bucket: params.bucket, Key: params.key}));
}
async listObjects(params: {bucket: string; prefix: string}): Promise<
async listObjects(params: {bucket: string; prefix: string; maxObjects?: number}): Promise<
ReadonlyArray<{
key: string;
lastModified?: Date;
}>
> {
if (params.maxObjects !== undefined && (!Number.isSafeInteger(params.maxObjects) || params.maxObjects <= 0)) {
throw new RangeError('maxObjects must be a positive safe integer');
}
const result: Array<{
key: string;
lastModified?: Date;
}> = [];
let listedObjects = 0;
let continuationToken: string | undefined;
do {
const remaining = params.maxObjects === undefined ? undefined : params.maxObjects - listedObjects;
const command = new ListObjectsV2Command({
Bucket: params.bucket,
Prefix: params.prefix,
ContinuationToken: continuationToken,
MaxKeys: remaining === undefined ? undefined : Math.min(remaining, 1000),
});
const response = await this.client.send(command);
if (response.Contents) {
for (const obj of response.Contents) {
listedObjects += 1;
if (obj.Key) {
result.push({key: obj.Key, lastModified: obj.LastModified});
}
}
}
if (
params.maxObjects !== undefined &&
(listedObjects > params.maxObjects || (listedObjects === params.maxObjects && response.IsTruncated))
) {
throw new StorageObjectListingOverflowError(params.bucket, params.prefix, params.maxObjects);
}
if (response.IsTruncated && !response.NextContinuationToken) {
throw new Error(
`Truncated object listing omitted its continuation token for ${params.bucket}/${params.prefix}`,
);
}
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
} while (continuationToken);
return result;
@@ -2,12 +2,17 @@
import type {SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {MessageSearchFilters, SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import {createChannelID, createMessageID, type MessageID} from '../BrandedTypes';
import {type ChannelID, createChannelID, createMessageID, type MessageID} from '../BrandedTypes';
import type {IMessageRepository} from '../channel/repositories/IMessageRepository';
import {Logger} from '../Logger';
import type {Message} from '../models/Message';
import type {IMessageSearchService} from './IMessageSearchService';
import {deleteMessageSearchDocuments} from './MessageSearchIndexCleanup';
const RECONCILE_BATCH_SIZE = 250;
const MAX_RECONCILE_PAGES = 40;
const MAX_STALE_DELETE_ABSOLUTE = 250;
const MAX_STALE_DELETE_RATIO = 0.5;
interface MessageLookupRepository {
readonly messages: Pick<IMessageRepository, 'getMessage'>;
@@ -26,6 +31,7 @@ interface SearchExistingMessagesParams {
interface ValidatedHits {
validHits: Array<SearchableMessage>;
staleMessageIds: Array<MessageID>;
lookupErrorCount: number;
}
export async function searchExistingMessages({
@@ -47,7 +53,9 @@ export async function searchExistingMessages({
return result;
}
if (cursor?.length) {
await deleteStaleSearchDocuments(searchService, validated.staleMessageIds);
if (validated.lookupErrorCount === 0) {
await deleteStaleSearchDocuments(searchService, validated.staleMessageIds, result.hits.length);
}
return {
...result,
hits: validated.validHits,
@@ -75,18 +83,24 @@ async function reconcileOffsetSearchResult({
const requestedOffset = (page - 1) * hitsPerPage;
const pageHits: Array<SearchableMessage> = [];
const staleMessageIds: Array<MessageID> = [];
let lookupErrorCount = 0;
let examinedCount = 0;
let validTotal = 0;
let rawOffset = 0;
let rawPage = 1;
while (true) {
let corpusTotal = 0;
while (rawPage <= MAX_RECONCILE_PAGES) {
const result = await searchService.searchMessages(query, filters, {
hitsPerPage: RECONCILE_BATCH_SIZE,
page: rawPage,
});
corpusTotal = result.total;
if (result.hits.length === 0) {
break;
}
const validated = await validateSearchHits(messageRepository, result.hits);
lookupErrorCount += validated.lookupErrorCount;
examinedCount += result.hits.length;
staleMessageIds.push(...validated.staleMessageIds);
for (const hit of validated.validHits) {
if (validTotal >= requestedOffset && pageHits.length < hitsPerPage) {
@@ -95,15 +109,20 @@ async function reconcileOffsetSearchResult({
validTotal += 1;
}
rawOffset += result.hits.length;
if (pageHits.length >= hitsPerPage && rawOffset >= requestedOffset) {
break;
}
if (rawOffset >= result.total) {
break;
}
rawPage += 1;
}
await deleteStaleSearchDocuments(searchService, staleMessageIds);
if (lookupErrorCount === 0) {
await deleteStaleSearchDocuments(searchService, staleMessageIds, examinedCount);
}
return {
hits: pageHits,
total: validTotal,
total: Math.max(pageHits.length, corpusTotal - staleMessageIds.length),
};
}
@@ -113,26 +132,33 @@ async function validateSearchHits(
): Promise<ValidatedHits> {
const checked = await Promise.all(
hits.map(async (hit) => {
let channelId: ChannelID;
let messageId: MessageID;
try {
const channelId = createChannelID(BigInt(hit.channelId));
channelId = createChannelID(BigInt(hit.channelId));
messageId = createMessageID(BigInt(hit.id));
const message = await messageRepository.messages.getMessage(channelId, messageId);
if (message && message.channelId.toString() === hit.channelId) {
return {hit, staleMessageId: null};
}
} catch (_error) {
try {
messageId = createMessageID(BigInt(hit.id));
} catch (_invalidMessageId) {
return {hit: null, staleMessageId: null};
}
} catch (_invalidId) {
return {hit: null, staleMessageId: null, lookupError: false};
}
return {hit: null, staleMessageId: messageId};
let message: Message | null;
try {
message = await messageRepository.messages.getMessage(channelId, messageId);
} catch (error) {
Logger.warn(
{error, messageId: hit.id, channelId: hit.channelId},
'Search read repair lookup failed; keeping document',
);
return {hit, staleMessageId: null, lookupError: true};
}
if (message && message.channelId.toString() === hit.channelId) {
return {hit, staleMessageId: null, lookupError: false};
}
return {hit: null, staleMessageId: messageId, lookupError: false};
}),
);
const validHits: Array<SearchableMessage> = [];
const staleMessageIds: Array<MessageID> = [];
let lookupErrorCount = 0;
for (const item of checked) {
if (item.hit) {
validHits.push(item.hit);
@@ -140,14 +166,31 @@ async function validateSearchHits(
if (item.staleMessageId) {
staleMessageIds.push(item.staleMessageId);
}
if (item.lookupError) {
lookupErrorCount += 1;
}
}
return {validHits, staleMessageIds};
return {validHits, staleMessageIds, lookupErrorCount};
}
async function deleteStaleSearchDocuments(
searchService: IMessageSearchService,
messageIds: Array<MessageID>,
examinedCount: number,
): Promise<void> {
if (messageIds.length === 0) {
return;
}
if (
messageIds.length > MAX_STALE_DELETE_ABSOLUTE ||
(examinedCount > 0 && messageIds.length / examinedCount > MAX_STALE_DELETE_RATIO)
) {
Logger.warn(
{staleMessageCount: messageIds.length, examinedCount},
'Search read repair delete exceeded safety cap; skipping delete',
);
return;
}
await deleteMessageSearchDocuments(messageIds, {
searchService,
context: {source: 'message_search_read_repair', staleMessageCount: messageIds.length},
@@ -6,7 +6,25 @@ import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
import {vi} from 'vitest';
import type {IStorageService, ProcessedStorageObjectMetadata} from '../../infrastructure/IStorageService';
import {Config} from '../../Config';
import {
type IStorageService,
type ProcessedStorageObjectMetadata,
StorageObjectListingOverflowError,
StorageObjectRangeNotSatisfiableError,
} from '../../infrastructure/IStorageService';
const OBJECT_ID_SEPARATOR = '\u0000';
const BYTE_RANGE_PATTERN = /^bytes=(\d*)-(\d*)$/u;
interface StoredObject {
data: Uint8Array;
contentType?: string;
etag: string;
lastModified: Date;
}
type ParsedByteRange = {start: number; end: number} | 'ignored' | 'unsatisfiable';
interface MockStorageServiceConfig {
fileData?: Uint8Array | null;
@@ -17,14 +35,40 @@ interface MockStorageServiceConfig {
shouldFailCopy?: boolean;
}
function computeEtag(data: Uint8Array): string {
return `"${createHash('md5').update(data).digest('hex')}"`;
}
function parseByteRange(range: string, totalLength: number): ParsedByteRange {
const match = BYTE_RANGE_PATTERN.exec(range.trim());
if (!match) return 'ignored';
const rawStart = match[1] ?? '';
const rawEnd = match[2] ?? '';
if (rawStart === '' && rawEnd === '') return 'ignored';
if (rawStart === '') {
const suffixLength = Number(rawEnd);
if (suffixLength === 0 || totalLength === 0) return 'unsatisfiable';
return {start: Math.max(totalLength - suffixLength, 0), end: totalLength - 1};
}
const start = Number(rawStart);
if (start >= totalLength) return 'unsatisfiable';
if (rawEnd === '') return {start, end: totalLength - 1};
const end = Number(rawEnd);
if (end < start) return 'ignored';
return {start, end: Math.min(end, totalLength - 1)};
}
function noSuchKeyError(key: string): S3ServiceException {
return new S3ServiceException({
name: 'NoSuchKey',
$fault: 'client',
$metadata: {},
message: `The specified key does not exist: ${key}`,
});
}
export class MockStorageService implements IStorageService {
private objects: Map<
string,
{
data: Uint8Array;
contentType?: string;
}
> = new Map();
private objects: Map<string, StoredObject> = new Map();
private multipartUploads: Map<
string,
{
@@ -77,6 +121,19 @@ export class MockStorageService implements IStorageService {
this.config = {...this.config, ...config};
}
private objectId(bucket: string, key: string): string {
return `${bucket}${OBJECT_ID_SEPARATOR}${key}`;
}
private storeObject(bucket: string, key: string, data: Uint8Array, contentType?: string): void {
this.objects.set(this.objectId(bucket, key), {
data,
contentType,
etag: computeEtag(data),
lastModified: new Date(),
});
}
async uploadObject(params: {
bucket: string;
key: string;
@@ -89,7 +146,7 @@ export class MockStorageService implements IStorageService {
throw new Error('Mock storage upload failure');
}
const data = params.body instanceof Uint8Array ? params.body : await this.readableToBuffer(params.body);
this.objects.set(params.key, {data, contentType: params.contentType});
this.storeObject(params.bucket, params.key, data, params.contentType);
}
async uploadObjectFromFile(params: {
@@ -105,7 +162,7 @@ export class MockStorageService implements IStorageService {
throw new Error('Mock storage upload failure');
}
const data = await fs.promises.readFile(params.filePath);
this.objects.set(params.key, {data: new Uint8Array(data), contentType: params.contentType});
this.storeObject(params.bucket, params.key, new Uint8Array(data), params.contentType);
}
private async readableToBuffer(stream: Readable): Promise<Uint8Array> {
@@ -129,7 +186,7 @@ export class MockStorageService implements IStorageService {
throw new Error('Mock storage delete failure');
}
this.deletedObjects.push({bucket, key});
this.objects.delete(key);
this.objects.delete(this.objectId(bucket, key));
}
async getObjectMetadata(
@@ -138,16 +195,23 @@ export class MockStorageService implements IStorageService {
): Promise<{
contentLength: number;
contentType: string;
etag?: string;
lastModified?: Date;
} | null> {
this.getObjectMetadataSpy(bucket, key);
const obj = this.objects.get(key);
const obj = this.objects.get(this.objectId(bucket, key));
if (!obj) return null;
return {contentLength: obj.data.length, contentType: obj.contentType ?? 'application/octet-stream'};
return {
contentLength: obj.data.length,
contentType: obj.contentType ?? 'application/octet-stream',
etag: obj.etag,
lastModified: obj.lastModified,
};
}
async computeObjectSha256(bucket: string, key: string): Promise<string> {
this.computeObjectSha256Spy(bucket, key);
const data = this.config.fileData ?? this.objects.get(key)?.data ?? new Uint8Array();
const data = this.config.fileData ?? this.objects.get(this.objectId(bucket, key))?.data ?? new Uint8Array();
return createHash('sha256').update(data).digest('hex');
}
@@ -164,25 +228,13 @@ export class MockStorageService implements IStorageService {
}
if (this.config.fileData !== undefined) {
if (this.config.fileData === null) {
const error = new S3ServiceException({
name: 'NoSuchKey',
$fault: 'client',
$metadata: {},
message: `The specified key does not exist: ${key}`,
});
throw error;
throw noSuchKeyError(key);
}
return assertWithinLimit(this.config.fileData);
}
const obj = this.objects.get(key);
const obj = this.objects.get(this.objectId(bucket, key));
if (!obj) {
const error = new S3ServiceException({
name: 'NoSuchKey',
$fault: 'client',
$metadata: {},
message: `The specified key does not exist: ${key}`,
});
throw error;
throw noSuchKeyError(key);
}
return assertWithinLimit(obj.data);
}
@@ -205,35 +257,41 @@ export class MockStorageService implements IStorageService {
if (this.config.fileData === null) {
return null;
}
const obj = this.objects.get(params.key);
const obj = this.objects.get(this.objectId(params.bucket, params.key));
const data = this.config.fileData ?? obj?.data;
if (!data) {
return null;
}
let slice = data;
let contentRange: string | null = null;
if (params.range !== undefined) {
const parsedRange = parseByteRange(params.range, data.length);
if (parsedRange === 'unsatisfiable') {
throw new StorageObjectRangeNotSatisfiableError(params.bucket, params.key, params.range);
}
if (parsedRange !== 'ignored') {
slice = data.subarray(parsedRange.start, parsedRange.end + 1);
contentRange = `bytes ${parsedRange.start}-${parsedRange.end}/${data.length}`;
}
}
return {
body: Readable.from([Buffer.from(data)]),
contentLength: data.length,
contentRange: null,
body: Readable.from([Buffer.from(slice)]),
contentLength: slice.length,
contentRange,
contentType: obj?.contentType ?? 'application/octet-stream',
cacheControl: null,
contentDisposition: null,
expires: null,
etag: `"${createHash('md5').update(data).digest('hex')}"`,
lastModified: null,
etag: computeEtag(data),
lastModified: obj?.lastModified ?? null,
};
}
async writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void> {
this.writeObjectToDiskSpy(bucket, key, filePath);
const data = this.config.fileData ?? this.objects.get(key)?.data;
const data = this.config.fileData ?? this.objects.get(this.objectId(bucket, key))?.data;
if (!data) {
const error = new S3ServiceException({
name: 'NoSuchKey',
$fault: 'client',
$metadata: {},
message: `The specified key does not exist: ${key}`,
});
throw error;
throw noSuchKeyError(key);
}
await fs.promises.writeFile(filePath, data);
}
@@ -255,12 +313,14 @@ export class MockStorageService implements IStorageService {
destinationBucket: params.destinationBucket,
destinationKey: params.destinationKey,
});
const sourceObj = this.objects.get(params.sourceKey);
const sourceObj = this.objects.get(this.objectId(params.sourceBucket, params.sourceKey));
if (sourceObj) {
this.objects.set(params.destinationKey, {
data: sourceObj.data,
contentType: params.newContentType ?? sourceObj.contentType,
});
this.storeObject(
params.destinationBucket,
params.destinationKey,
sourceObj.data,
params.newContentType ?? sourceObj.contentType,
);
}
}
@@ -283,7 +343,8 @@ export class MockStorageService implements IStorageService {
if (!isSupportedMediaContentType(params.contentType)) {
return null;
}
const data = this.objects.get(params.destinationKey)?.data ?? new Uint8Array();
const data =
this.objects.get(this.objectId(params.destinationBucket, params.destinationKey))?.data ?? new Uint8Array();
return {
contentType: params.contentType,
contentLength: data.length,
@@ -338,37 +399,63 @@ export class MockStorageService implements IStorageService {
return `https://presigned-upload.url/test?partNumber=${params.partNumber}&uploadId=${params.uploadId}`;
}
async purgeBucket(_bucket: string): Promise<void> {
this.purgeBucketSpy(_bucket);
async purgeBucket(bucket: string): Promise<void> {
this.purgeBucketSpy(bucket);
const prefix = this.objectId(bucket, '');
for (const id of [...this.objects.keys()]) {
if (id.startsWith(prefix)) {
this.objects.delete(id);
this.deletedObjects.push({bucket, key: id.slice(prefix.length)});
}
}
}
async uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void> {
this.uploadAvatarSpy(params);
await this.uploadObject({bucket: 'cdn', key: `${params.prefix}/${params.key}`, body: params.body});
await this.uploadObject({
bucket: Config.s3.buckets.cdn,
key: `${params.prefix}/${params.key}`,
body: params.body,
});
}
async deleteAvatar(params: {prefix: string; key: string}): Promise<void> {
this.deleteAvatarSpy(params);
await this.deleteObject('cdn', `${params.prefix}/${params.key}`);
await this.deleteObject(Config.s3.buckets.cdn, `${params.prefix}/${params.key}`);
}
async listObjects(_params: {bucket: string; prefix: string}): Promise<
async listObjects(params: {bucket: string; prefix: string; maxObjects?: number}): Promise<
ReadonlyArray<{
key: string;
lastModified?: Date;
}>
> {
this.listObjectsSpy(_params);
return [];
this.listObjectsSpy(params);
if (params.maxObjects !== undefined && (!Number.isSafeInteger(params.maxObjects) || params.maxObjects <= 0)) {
throw new RangeError('maxObjects must be a positive safe integer');
}
const keyOffset = params.bucket.length + OBJECT_ID_SEPARATOR.length;
const matches = [...this.objects.entries()]
.filter(([id]) => id.startsWith(this.objectId(params.bucket, params.prefix)))
.map(([id, object]) => ({key: id.slice(keyOffset), lastModified: object.lastModified}))
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
if (params.maxObjects !== undefined && matches.length > params.maxObjects) {
throw new StorageObjectListingOverflowError(params.bucket, params.prefix, params.maxObjects);
}
return matches;
}
async deleteObjects(_params: {
async deleteObjects(params: {
bucket: string;
objects: ReadonlyArray<{
Key: string;
}>;
}): Promise<void> {
this.deleteObjectsSpy(_params);
this.deleteObjectsSpy(params);
for (const object of params.objects) {
this.objects.delete(this.objectId(params.bucket, object.Key));
this.deletedObjects.push({bucket: params.bucket, key: object.Key});
}
}
async createMultipartUpload(params: {bucket: string; key: string; contentType?: string}): Promise<{
@@ -440,7 +527,7 @@ export class MockStorageService implements IStorageService {
combined.set(data, offset);
offset += data.length;
}
this.objects.set(upload.key, {data: combined});
this.storeObject(upload.bucket, upload.key, combined);
this.multipartUploads.delete(params.uploadId);
}
@@ -465,8 +552,8 @@ export class MockStorageService implements IStorageService {
return [...this.copiedObjects];
}
hasObject(_bucket: string, key: string): boolean {
return this.objects.has(key);
hasObject(bucket: string, key: string): boolean {
return this.objects.has(this.objectId(bucket, key));
}
reset(): void {
@@ -18,6 +18,7 @@ import {
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {SavedMessageEntryListResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {createChannelID, createMessageID} from '../../BrandedTypes';
import {StorageObjectRangeNotSatisfiableError} from '../../infrastructure/IStorageService';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -290,28 +291,35 @@ export function UserContentController(app: HonoApp) {
if (!token) {
return ctx.text('Not Found', 404);
}
const result = await ctx.get('userContentRequestService').streamHarvestDownload({
harvestId,
token,
range: ctx.req.header('range') ?? undefined,
storageService: ctx.get('storageService'),
});
if (!result) {
return ctx.text('Not Found', 404);
try {
const result = await ctx.get('userContentRequestService').streamHarvestDownload({
harvestId,
token,
range: ctx.req.header('range') ?? undefined,
storageService: ctx.get('storageService'),
});
if (!result) {
return ctx.text('Not Found', 404);
}
const headers = new Headers();
headers.set('Content-Type', result.contentType ?? 'application/zip');
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.filename)}"`);
headers.set('Content-Length', String(result.contentLength));
headers.set('Cache-Control', 'private, no-store');
headers.set('Accept-Ranges', 'bytes');
if (result.contentRange) {
headers.set('Content-Range', result.contentRange);
}
return new Response(Readable.toWeb(result.body) as ReadableStream, {
status: result.contentRange ? 206 : 200,
headers,
});
} catch (error) {
if (error instanceof StorageObjectRangeNotSatisfiableError) {
return ctx.text('Range Not Satisfiable', 416);
}
throw error;
}
const headers = new Headers();
headers.set('Content-Type', result.contentType ?? 'application/zip');
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.filename)}"`);
headers.set('Content-Length', String(result.contentLength));
headers.set('Cache-Control', 'private, no-store');
headers.set('Accept-Ranges', 'bytes');
if (result.contentRange) {
headers.set('Content-Range', result.contentRange);
}
return new Response(Readable.toWeb(result.body) as ReadableStream, {
status: result.contentRange ? 206 : 200,
headers,
});
},
);
app.get(
@@ -217,9 +217,9 @@ export const MessageSearchRequest = z.object({
.number()
.int()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.max(400)
.default(1)
.describe('Page number for pagination (ignored when cursor is provided)'),
.describe('Page number for pagination (ignored when cursor is provided). Use cursor to page beyond this.'),
cursor: z
.array(z.string())
.optional()