chore: fix lint errors — duplicate import, unused imports, formatting
Deploy FileDrop / deploy (push) Successful in 45s
Deploy FileDrop / deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import type { LoginInput, LoginResponse, LogoutResponse, UserInfoResponse, AuthSession } from '../dto/auth';
|
import type {
|
||||||
|
AuthSession,
|
||||||
|
LoginInput,
|
||||||
|
LoginResponse,
|
||||||
|
LogoutResponse,
|
||||||
|
UserInfoResponse,
|
||||||
|
} from '../dto/auth';
|
||||||
|
|
||||||
/** Subset of application configuration consumed by the authenticate use case. */
|
/** Subset of application configuration consumed by the authenticate use case. */
|
||||||
export interface AuthUseCaseConfig {
|
export interface AuthUseCaseConfig {
|
||||||
|
|||||||
@@ -98,11 +98,7 @@ export function createGetBucketUseCase(deps: ManageBucketDeps) {
|
|||||||
export function createCreateBucketUseCase(deps: ManageBucketDeps) {
|
export function createCreateBucketUseCase(deps: ManageBucketDeps) {
|
||||||
return async (name: string): Promise<Bucket> => {
|
return async (name: string): Promise<Bucket> => {
|
||||||
if (!BUCKET_NAME_REGEX.test(name)) {
|
if (!BUCKET_NAME_REGEX.test(name)) {
|
||||||
throw new BucketError(
|
throw new BucketError('InvalidBucketName', 'The specified bucket is not valid.', 400);
|
||||||
'InvalidBucketName',
|
|
||||||
'The specified bucket is not valid.',
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await deps.bucketRepo.findByName(name);
|
const existing = await deps.bucketRepo.findByName(name);
|
||||||
@@ -134,20 +130,12 @@ export function createDeleteBucketUseCase(deps: ManageBucketDeps) {
|
|||||||
return async (name: string): Promise<boolean> => {
|
return async (name: string): Promise<boolean> => {
|
||||||
const bucket = await deps.bucketRepo.findByName(name);
|
const bucket = await deps.bucketRepo.findByName(name);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
throw new BucketError(
|
throw new BucketError('NoSuchBucket', 'The specified bucket does not exist.', 404);
|
||||||
'NoSuchBucket',
|
|
||||||
'The specified bucket does not exist.',
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectCount = await deps.fileRepo.countByBucket(bucket.id);
|
const objectCount = await deps.fileRepo.countByBucket(bucket.id);
|
||||||
if (objectCount > 0) {
|
if (objectCount > 0) {
|
||||||
throw new BucketError(
|
throw new BucketError('BucketNotEmpty', 'The bucket you tried to delete is not empty.', 409);
|
||||||
'BucketNotEmpty',
|
|
||||||
'The bucket you tried to delete is not empty.',
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return deps.bucketRepo.delete(name);
|
return deps.bucketRepo.delete(name);
|
||||||
|
|||||||
@@ -116,10 +116,7 @@ export interface MultipartDeps {
|
|||||||
* the upload initiation result, or `null` when the bucket is not found.
|
* the upload initiation result, or `null` when the bucket is not found.
|
||||||
*/
|
*/
|
||||||
export function createInitiateMultipartUploadUseCase(deps: MultipartDeps) {
|
export function createInitiateMultipartUploadUseCase(deps: MultipartDeps) {
|
||||||
return async (
|
return async (bucketName: string, key: string): Promise<InitiateMultipartResult | null> => {
|
||||||
bucketName: string,
|
|
||||||
key: string,
|
|
||||||
): Promise<InitiateMultipartResult | null> => {
|
|
||||||
const bucket = await deps.bucketRepo.findByName(bucketName);
|
const bucket = await deps.bucketRepo.findByName(bucketName);
|
||||||
if (!bucket) return null;
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { gzipSync } from 'node:zlib';
|
import { gzipSync } from 'node:zlib';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { File, NewFile } from '../../domain/entities/file';
|
import type { File } from '../../domain/entities/file';
|
||||||
import type { NewFilePart } from '../../domain/entities/file-part';
|
import type { NewFilePart } from '../../domain/entities/file-part';
|
||||||
import type { MultipartPart } from '../../domain/entities/multipart';
|
import type { MultipartPart } from '../../domain/entities/multipart';
|
||||||
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
|
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
|
||||||
@@ -9,7 +9,7 @@ import type { IFilePartRepository } from '../../domain/ports/file-part-repositor
|
|||||||
import type { IFileRepository, S3FileRecord } from '../../domain/ports/file-repository';
|
import type { IFileRepository, S3FileRecord } from '../../domain/ports/file-repository';
|
||||||
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
|
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
|
||||||
import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
||||||
import { ensureExtension, computeHash, formatCreatedAt } from '../../shared/utils/file';
|
import { computeHash, ensureExtension, formatCreatedAt } from '../../shared/utils/file';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -456,7 +456,12 @@ export function createPutObjectUseCase(deps: S3ObjectDeps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
|
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
|
||||||
const { telegramChunkSizeBytes, compressChunkedUploads, chunkCompressionMinSizeBytes, storageChatId } = deps.config;
|
const {
|
||||||
|
telegramChunkSizeBytes,
|
||||||
|
compressChunkedUploads,
|
||||||
|
chunkCompressionMinSizeBytes,
|
||||||
|
storageChatId,
|
||||||
|
} = deps.config;
|
||||||
|
|
||||||
if (body.byteLength > telegramChunkSizeBytes) {
|
if (body.byteLength > telegramChunkSizeBytes) {
|
||||||
// Chunked upload path
|
// Chunked upload path
|
||||||
@@ -594,16 +599,28 @@ export function createCopyObjectUseCase(deps: S3ObjectDeps) {
|
|||||||
if (!sourceFile) return null;
|
if (!sourceFile) return null;
|
||||||
|
|
||||||
if (sourceFile.storageBackend === 'chunked') {
|
if (sourceFile.storageBackend === 'chunked') {
|
||||||
throw new ObjectError('NotImplemented', 'Copying chunked objects is not yet implemented.', 501);
|
throw new ObjectError(
|
||||||
|
'NotImplemented',
|
||||||
|
'Copying chunked objects is not yet implemented.',
|
||||||
|
501,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditional copy: if-match / if-none-match checks
|
// Conditional copy: if-match / if-none-match checks
|
||||||
const sourceEtag = sourceFile.fileHash;
|
const sourceEtag = sourceFile.fileHash;
|
||||||
if (input.ifMatch && sourceEtag && input.ifMatch !== sourceEtag) {
|
if (input.ifMatch && sourceEtag && input.ifMatch !== sourceEtag) {
|
||||||
throw new ObjectError('PreconditionFailed', 'The preconditions you specified did not hold.', 412);
|
throw new ObjectError(
|
||||||
|
'PreconditionFailed',
|
||||||
|
'The preconditions you specified did not hold.',
|
||||||
|
412,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (input.ifNoneMatch && sourceEtag && input.ifNoneMatch === sourceEtag) {
|
if (input.ifNoneMatch && sourceEtag && input.ifNoneMatch === sourceEtag) {
|
||||||
throw new ObjectError('PreconditionFailed', 'The preconditions you specified did not hold.', 412);
|
throw new ObjectError(
|
||||||
|
'PreconditionFailed',
|
||||||
|
'The preconditions you specified did not hold.',
|
||||||
|
412,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicId = nanoid();
|
const publicId = nanoid();
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { open } from 'node:fs/promises';
|
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { open } from 'node:fs/promises';
|
||||||
import { gzipSync } from 'node:zlib';
|
import { gzipSync } from 'node:zlib';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { NewFilePart } from '../../domain/entities/file-part';
|
import type { NewFilePart } from '../../domain/entities/file-part';
|
||||||
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
|
import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file';
|
||||||
import type { UploadInput, UploadOutput } from '../dto/upload';
|
import type { UploadInput, UploadOutput } from '../dto/upload';
|
||||||
import { getFileType, checkFileSize, ensureExtension, computeHash, formatCreatedAt } from '../../shared/utils/file';
|
|
||||||
|
|
||||||
/** Compression algorithm string literal used in chunked storage. */
|
/** Compression algorithm string literal used in chunked storage. */
|
||||||
type ChunkCompressionAlgorithm = 'gzip' | null;
|
type ChunkCompressionAlgorithm = 'gzip' | null;
|
||||||
@@ -219,7 +219,8 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
|
|||||||
mimeType: existing.mimeType,
|
mimeType: existing.mimeType,
|
||||||
sizeBytes: existing.sizeBytes,
|
sizeBytes: existing.sizeBytes,
|
||||||
fileType: existing.fileType,
|
fileType: existing.fileType,
|
||||||
createdAt: existing.createdAt instanceof Date ? existing.createdAt : new Date(existing.createdAt),
|
createdAt:
|
||||||
|
existing.createdAt instanceof Date ? existing.createdAt : new Date(existing.createdAt),
|
||||||
downloadUrl: `${deps.config.baseUrl}/f/${existing.publicId}`,
|
downloadUrl: `${deps.config.baseUrl}/f/${existing.publicId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MultipartUpload, MultipartPart } from '../entities/multipart';
|
import type { MultipartPart, MultipartUpload } from '../entities/multipart';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository interface for S3 multipart upload persistence.
|
* Repository interface for S3 multipart upload persistence.
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ export interface ITelegramService {
|
|||||||
* @param fileType - The file type classification (e.g. "photo", "document").
|
* @param fileType - The file type classification (e.g. "photo", "document").
|
||||||
* @returns The Telegram identifiers of the stored file.
|
* @returns The Telegram identifiers of the stored file.
|
||||||
*/
|
*/
|
||||||
forwardToStorage(
|
forwardToStorage(fileChunk: unknown, fileName: string, fileType: string): Promise<ForwardResult>;
|
||||||
fileChunk: unknown,
|
|
||||||
fileName: string,
|
|
||||||
fileType: string,
|
|
||||||
): Promise<ForwardResult>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve file metadata from Telegram by file ID.
|
* Retrieve file metadata from Telegram by file ID.
|
||||||
|
|||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { config } from './config/index';
|
import { config } from './config/index';
|
||||||
|
import { fileInfoCache } from './infrastructure/cache/index';
|
||||||
import { startBot } from './interfaces/bot/handler';
|
import { startBot } from './interfaces/bot/handler';
|
||||||
|
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
||||||
|
import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
|
||||||
import { routes } from './interfaces/http/routes/index';
|
import { routes } from './interfaces/http/routes/index';
|
||||||
import { isS3Request } from './interfaces/s3/auth';
|
import { isS3Request } from './interfaces/s3/auth';
|
||||||
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
|
||||||
import { extractS3BucketFromHost } from './interfaces/s3/virtual-host';
|
import { extractS3BucketFromHost } from './interfaces/s3/virtual-host';
|
||||||
import { fileInfoCache } from './infrastructure/cache/index';
|
|
||||||
import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
|
|
||||||
import { logger } from './shared/logger/index';
|
import { logger } from './shared/logger/index';
|
||||||
import { metricsCollector } from './shared/metrics/index';
|
import { metricsCollector } from './shared/metrics/index';
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ const shouldHandleS3 = (req: Request, headers: Record<string, string>): boolean
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
const _handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
return handleS3Request(req, getS3RouteBucket(req));
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+24
-7
@@ -26,23 +26,40 @@ export class Cache<T> {
|
|||||||
return entry.value;
|
return entry.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
has(key: string): boolean { return this.get(key) !== null; }
|
has(key: string): boolean {
|
||||||
delete(key: string): void { this.store.delete(key); }
|
return this.get(key) !== null;
|
||||||
clear(): void { this.store.clear(); }
|
}
|
||||||
size(): number { return this.store.size; }
|
delete(key: string): void {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
clear(): void {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
size(): number {
|
||||||
|
return this.store.size;
|
||||||
|
}
|
||||||
|
|
||||||
cleanup(): number {
|
cleanup(): number {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, entry] of this.store.entries()) {
|
for (const [key, entry] of this.store.entries()) {
|
||||||
if (now > entry.expiresAt) { this.store.delete(key); removed++; }
|
if (now > entry.expiresAt) {
|
||||||
|
this.store.delete(key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CacheEntry<T> { value: T; expiresAt: number }
|
interface CacheEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const fileInfoCache = new Cache<{
|
export const fileInfoCache = new Cache<{
|
||||||
file_size: number; mime_type: string; file_path: string; bot_token: string;
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
file_path: string;
|
||||||
|
bot_token: string;
|
||||||
}>(3600);
|
}>(3600);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { config } from '../../../env';
|
import { config } from '../../../env';
|
||||||
import { getErrorMessage } from '../../../shared/utils/file';
|
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
|
import { getErrorMessage } from '../../../shared/utils/file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run raw SQL migration from schema.sql.
|
* Run raw SQL migration from schema.sql.
|
||||||
@@ -15,10 +15,10 @@ export const runMigration = async (): Promise<void> => {
|
|||||||
const dir = import.meta.dir || '';
|
const dir = import.meta.dir || '';
|
||||||
const candidates = [
|
const candidates = [
|
||||||
`${dir}/../../../../schema.sql`, // from dist/
|
`${dir}/../../../../schema.sql`, // from dist/
|
||||||
`${dir}/../../../schema.sql`, // from src/infrastructure/persistence/
|
`${dir}/../../../schema.sql`, // from src/infrastructure/persistence/
|
||||||
`${dir}/../../schema.sql`, // from src/infrastructure/
|
`${dir}/../../schema.sql`, // from src/infrastructure/
|
||||||
`${dir}/../schema.sql`, // from src/infrastructure/persistence/drizzle/
|
`${dir}/../schema.sql`, // from src/infrastructure/persistence/drizzle/
|
||||||
`${dir}/schema.sql`, // from next to file (bun run directly)
|
`${dir}/schema.sql`, // from next to file (bun run directly)
|
||||||
];
|
];
|
||||||
|
|
||||||
let schemaSql: string | null = null;
|
let schemaSql: string | null = null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { db } from '../drizzle/index';
|
|
||||||
import type { Bucket } from '../../../domain/entities/bucket';
|
import type { Bucket } from '../../../domain/entities/bucket';
|
||||||
import type { IBucketRepository } from '../../../domain/ports/bucket-repository';
|
import type { IBucketRepository } from '../../../domain/ports/bucket-repository';
|
||||||
|
import { db } from '../drizzle/index';
|
||||||
|
|
||||||
/** Raw result row from `db.execute()`. */
|
/** Raw result row from `db.execute()`. */
|
||||||
type QueryRow = Record<string, unknown>;
|
type QueryRow = Record<string, unknown>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { db } from '../drizzle/index';
|
|
||||||
import type { FilePart, NewFilePart } from '../../../domain/entities/file-part';
|
import type { FilePart, NewFilePart } from '../../../domain/entities/file-part';
|
||||||
import type { IFilePartRepository } from '../../../domain/ports/file-part-repository';
|
import type { IFilePartRepository } from '../../../domain/ports/file-part-repository';
|
||||||
|
import { db } from '../drizzle/index';
|
||||||
|
|
||||||
/** Compression algorithm type matching the domain entity. */
|
/** Compression algorithm type matching the domain entity. */
|
||||||
type CompressionAlgorithm = 'gzip' | null;
|
type CompressionAlgorithm = 'gzip' | null;
|
||||||
@@ -23,8 +23,7 @@ const mapRowToFilePart = (row: Record<string, unknown>): FilePart => ({
|
|||||||
storageMessageId: toNumber(row.storage_message_id),
|
storageMessageId: toNumber(row.storage_message_id),
|
||||||
sizeBytes: toNumber(row.size_bytes),
|
sizeBytes: toNumber(row.size_bytes),
|
||||||
storedSizeBytes: toNumber(row.stored_size_bytes),
|
storedSizeBytes: toNumber(row.stored_size_bytes),
|
||||||
compressionAlgorithm:
|
compressionAlgorithm: (row.compression_algorithm as CompressionAlgorithm) || null,
|
||||||
(row.compression_algorithm as CompressionAlgorithm) || null,
|
|
||||||
etag: row.etag as string,
|
etag: row.etag as string,
|
||||||
createdAt: new Date(row.created_at as string),
|
createdAt: new Date(row.created_at as string),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
import { db, files as fileSchema } from '../drizzle/index';
|
|
||||||
import type { File, NewFile } from '../../../domain/entities/file';
|
import type { File, NewFile } from '../../../domain/entities/file';
|
||||||
import type {
|
import type { IFileRepository, S3FileRecord } from '../../../domain/ports/file-repository';
|
||||||
IFileRepository,
|
import { db, files as fileSchema } from '../drizzle/index';
|
||||||
S3FileRecord,
|
|
||||||
} from '../../../domain/ports/file-repository';
|
|
||||||
|
|
||||||
/** Safely converts a raw value to a number, defaulting to 0. */
|
/** Safely converts a raw value to a number, defaulting to 0. */
|
||||||
const toNumber = (value: unknown): number => Number(value ?? 0);
|
const toNumber = (value: unknown): number => Number(value ?? 0);
|
||||||
@@ -35,25 +32,18 @@ const mapDbRowToS3Record = (row: Record<string, unknown>): S3FileRecord => ({
|
|||||||
fileHash: row.file_hash as string | null,
|
fileHash: row.file_hash as string | null,
|
||||||
archiveTelegramFileId: row.archive_telegram_file_id as string | null,
|
archiveTelegramFileId: row.archive_telegram_file_id as string | null,
|
||||||
archiveStorageMessageId:
|
archiveStorageMessageId:
|
||||||
row.archive_storage_message_id === null
|
row.archive_storage_message_id === null ? null : toNumber(row.archive_storage_message_id),
|
||||||
? null
|
|
||||||
: toNumber(row.archive_storage_message_id),
|
|
||||||
archiveFileName: row.archive_file_name as string | null,
|
archiveFileName: row.archive_file_name as string | null,
|
||||||
archiveEntryName: row.archive_entry_name as string | null,
|
archiveEntryName: row.archive_entry_name as string | null,
|
||||||
archiveMimeType: row.archive_mime_type as string | null,
|
archiveMimeType: row.archive_mime_type as string | null,
|
||||||
archiveSizeBytes:
|
archiveSizeBytes: row.archive_size_bytes === null ? null : toNumber(row.archive_size_bytes),
|
||||||
row.archive_size_bytes === null
|
|
||||||
? null
|
|
||||||
: toNumber(row.archive_size_bytes),
|
|
||||||
bucketId: row.bucket_id as string,
|
bucketId: row.bucket_id as string,
|
||||||
s3Key: row.s3_key as string,
|
s3Key: row.s3_key as string,
|
||||||
storageBackend: (row.storage_backend as string) || 'telegram',
|
storageBackend: (row.storage_backend as string) || 'telegram',
|
||||||
isDeleted: row.is_deleted as boolean,
|
isDeleted: row.is_deleted as boolean,
|
||||||
multipartUploadId: row.multipart_upload_id as string | null,
|
multipartUploadId: row.multipart_upload_id as string | null,
|
||||||
partCount:
|
partCount:
|
||||||
row.part_count === null || row.part_count === undefined
|
row.part_count === null || row.part_count === undefined ? null : toNumber(row.part_count),
|
||||||
? null
|
|
||||||
: toNumber(row.part_count),
|
|
||||||
createdAt: new Date(row.created_at as string),
|
createdAt: new Date(row.created_at as string),
|
||||||
updatedAt: new Date(row.updated_at as string),
|
updatedAt: new Date(row.updated_at as string),
|
||||||
});
|
});
|
||||||
@@ -69,11 +59,7 @@ export class DrizzleFileRepository implements IFileRepository {
|
|||||||
* {@inheritDoc IFileRepository.findByHash}
|
* {@inheritDoc IFileRepository.findByHash}
|
||||||
*/
|
*/
|
||||||
async findByHash(hash: string): Promise<File | null> {
|
async findByHash(hash: string): Promise<File | null> {
|
||||||
const result = await db
|
const result = await db.select().from(fileSchema).where(eq(fileSchema.fileHash, hash)).limit(1);
|
||||||
.select()
|
|
||||||
.from(fileSchema)
|
|
||||||
.where(eq(fileSchema.fileHash, hash))
|
|
||||||
.limit(1);
|
|
||||||
return result[0] || null;
|
return result[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +90,7 @@ export class DrizzleFileRepository implements IFileRepository {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc IFileRepository.findByBucketAndKey}
|
* {@inheritDoc IFileRepository.findByBucketAndKey}
|
||||||
*/
|
*/
|
||||||
async findByBucketAndKey(
|
async findByBucketAndKey(bucketId: string, s3Key: string): Promise<File | null> {
|
||||||
bucketId: string,
|
|
||||||
s3Key: string,
|
|
||||||
): Promise<File | null> {
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.select()
|
.select()
|
||||||
.from(fileSchema)
|
.from(fileSchema)
|
||||||
@@ -126,10 +109,7 @@ export class DrizzleFileRepository implements IFileRepository {
|
|||||||
* {@inheritDoc IFileRepository.create}
|
* {@inheritDoc IFileRepository.create}
|
||||||
*/
|
*/
|
||||||
async create(file: NewFile): Promise<File> {
|
async create(file: NewFile): Promise<File> {
|
||||||
const result = await db
|
const result = await db.insert(fileSchema).values(file).returning();
|
||||||
.insert(fileSchema)
|
|
||||||
.values(file)
|
|
||||||
.returning();
|
|
||||||
return result[0]!;
|
return result[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +133,7 @@ export class DrizzleFileRepository implements IFileRepository {
|
|||||||
|
|
||||||
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
|
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
|
||||||
|
|
||||||
const rawResult = (await db.execute(
|
const rawResult = (await db.execute(query)) as unknown as Record<string, unknown>[];
|
||||||
query,
|
|
||||||
)) as unknown as Record<string, unknown>[];
|
|
||||||
|
|
||||||
if (delimiter === '/') {
|
if (delimiter === '/') {
|
||||||
const prefixSet = new Set<string>();
|
const prefixSet = new Set<string>();
|
||||||
@@ -166,8 +144,7 @@ export class DrizzleFileRepository implements IFileRepository {
|
|||||||
const relativeKey = s3Key.substring(prefix.length);
|
const relativeKey = s3Key.substring(prefix.length);
|
||||||
const slashIndex = relativeKey.indexOf('/');
|
const slashIndex = relativeKey.indexOf('/');
|
||||||
if (slashIndex >= 0) {
|
if (slashIndex >= 0) {
|
||||||
const folderPrefix =
|
const folderPrefix = prefix + relativeKey.substring(0, slashIndex + 1);
|
||||||
prefix + relativeKey.substring(0, slashIndex + 1);
|
|
||||||
if (folderPrefix !== prefix) {
|
if (folderPrefix !== prefix) {
|
||||||
prefixSet.add(folderPrefix);
|
prefixSet.add(folderPrefix);
|
||||||
}
|
}
|
||||||
@@ -201,10 +178,7 @@ export class DrizzleFileRepository implements IFileRepository {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc IFileRepository.softDeleteBatch}
|
* {@inheritDoc IFileRepository.softDeleteBatch}
|
||||||
*/
|
*/
|
||||||
async softDeleteBatch(
|
async softDeleteBatch(bucketId: string, keys: string[]): Promise<number> {
|
||||||
bucketId: string,
|
|
||||||
keys: string[],
|
|
||||||
): Promise<number> {
|
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const ok = await this.softDelete(bucketId, key);
|
const ok = await this.softDelete(bucketId, key);
|
||||||
@@ -230,12 +204,7 @@ export class DrizzleFileRepository implements IFileRepository {
|
|||||||
return await db
|
return await db
|
||||||
.select()
|
.select()
|
||||||
.from(fileSchema)
|
.from(fileSchema)
|
||||||
.where(
|
.where(and(eq(fileSchema.bucketId, bucketId), eq(fileSchema.isDeleted, true)))
|
||||||
and(
|
|
||||||
eq(fileSchema.bucketId, bucketId),
|
|
||||||
eq(fileSchema.isDeleted, true),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(100);
|
.limit(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { db } from '../drizzle/index';
|
import type { MultipartPart, MultipartUpload } from '../../../domain/entities/multipart';
|
||||||
import type { MultipartUpload, MultipartPart } from '../../../domain/entities/multipart';
|
|
||||||
import type { IMultipartRepository } from '../../../domain/ports/multipart-repository';
|
import type { IMultipartRepository } from '../../../domain/ports/multipart-repository';
|
||||||
|
import { db } from '../drizzle/index';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a raw database row to a {@link MultipartUpload} domain entity.
|
* Maps a raw database row to a {@link MultipartUpload} domain entity.
|
||||||
*/
|
*/
|
||||||
const mapRowToMultipartUpload = (
|
const mapRowToMultipartUpload = (r: Record<string, unknown>): MultipartUpload => ({
|
||||||
r: Record<string, unknown>,
|
|
||||||
): MultipartUpload => ({
|
|
||||||
uploadId: r.upload_id as string,
|
uploadId: r.upload_id as string,
|
||||||
bucketId: r.bucket_id as string,
|
bucketId: r.bucket_id as string,
|
||||||
s3Key: r.s3_key as string,
|
s3Key: r.s3_key as string,
|
||||||
@@ -29,11 +27,7 @@ export class DrizzleMultipartRepository implements IMultipartRepository {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc IMultipartRepository.create}
|
* {@inheritDoc IMultipartRepository.create}
|
||||||
*/
|
*/
|
||||||
async create(
|
async create(bucketId: string, s3Key: string, initiatedBy: string): Promise<string> {
|
||||||
bucketId: string,
|
|
||||||
s3Key: string,
|
|
||||||
initiatedBy: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const uploadId = nanoid(32);
|
const uploadId = nanoid(32);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
|
sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
|
||||||
@@ -81,9 +75,7 @@ export class DrizzleMultipartRepository implements IMultipartRepository {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc IMultipartRepository.insertPart}
|
* {@inheritDoc IMultipartRepository.insertPart}
|
||||||
*/
|
*/
|
||||||
async insertPart(
|
async insertPart(part: Omit<MultipartPart, 'id' | 'createdAt'>): Promise<void> {
|
||||||
part: Omit<MultipartPart, 'id' | 'createdAt'>,
|
|
||||||
): Promise<void> {
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql`INSERT INTO multipart_parts (upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag)
|
sql`INSERT INTO multipart_parts (upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag)
|
||||||
VALUES (${part.uploadId}, ${part.partNumber}, ${part.telegramFileId}, ${part.telegramFileUniqueId}, ${part.storageMessageId}, ${part.sizeBytes}, ${part.etag})`,
|
VALUES (${part.uploadId}, ${part.partNumber}, ${part.telegramFileId}, ${part.telegramFileUniqueId}, ${part.storageMessageId}, ${part.sizeBytes}, ${part.etag})`,
|
||||||
@@ -142,8 +134,7 @@ export class DrizzleMultipartRepository implements IMultipartRepository {
|
|||||||
return {
|
return {
|
||||||
uploads,
|
uploads,
|
||||||
isTruncated: result.length > limit,
|
isTruncated: result.length > limit,
|
||||||
nextKeyMarker:
|
nextKeyMarker: result.length > limit ? uploads.at(-1)?.s3Key || null : null,
|
||||||
result.length > limit ? uploads.at(-1)?.s3Key || null : null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Telegraf } from 'telegraf';
|
import { Telegraf } from 'telegraf';
|
||||||
|
import type {
|
||||||
|
ForwardResult,
|
||||||
|
ITelegramService,
|
||||||
|
TelegramFileInfo,
|
||||||
|
} from '../../domain/ports/telegram-service';
|
||||||
import { config } from '../../env';
|
import { config } from '../../env';
|
||||||
import logger from '../../shared/logger/index';
|
import logger from '../../shared/logger/index';
|
||||||
import type { ITelegramService, ForwardResult, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
|
||||||
import { enqueueUpload } from './upload-queue';
|
|
||||||
import {
|
import {
|
||||||
sendMethodMap,
|
|
||||||
extractUploadedFile,
|
|
||||||
buildSendPayload,
|
buildSendPayload,
|
||||||
type TelegramMessageResult,
|
extractUploadedFile,
|
||||||
type SendMethod,
|
type SendMethod,
|
||||||
|
sendMethodMap,
|
||||||
|
type TelegramMessageResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { enqueueUpload } from './upload-queue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sleep for a given number of seconds.
|
* Sleep for a given number of seconds.
|
||||||
@@ -94,10 +98,9 @@ export class BotPool implements ITelegramService {
|
|||||||
|
|
||||||
if (retries > 0) {
|
if (retries > 0) {
|
||||||
const seconds = parseInt(match[1], 10);
|
const seconds = parseInt(match[1], 10);
|
||||||
logger.warn(
|
logger.warn(`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, {
|
||||||
`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`,
|
error: errorStr,
|
||||||
{ error: errorStr },
|
});
|
||||||
);
|
|
||||||
await sleep(seconds);
|
await sleep(seconds);
|
||||||
return this.executeWithBotRetry(action, retries - 1, 0);
|
return this.executeWithBotRetry(action, retries - 1, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { gzipSync } from 'node:zlib';
|
import { gzipSync } from 'node:zlib';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { File as FileEntity } from '../../domain/entities/file';
|
||||||
|
import type { CompressionAlgorithm, NewFilePart } from '../../domain/entities/file-part';
|
||||||
|
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
import { config } from '../../env';
|
import { config } from '../../env';
|
||||||
import { computeHash } from '../../shared/utils/file';
|
|
||||||
import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream';
|
import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream';
|
||||||
import type { RangeParseResult } from '../../interfaces/s3/range';
|
import type { RangeParseResult } from '../../interfaces/s3/range';
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
import { computeHash } from '../../shared/utils/file';
|
||||||
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
|
||||||
import type { File as FileEntity } from '../../domain/entities/file';
|
|
||||||
import type { NewFilePart, CompressionAlgorithm } from '../../domain/entities/file-part';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chunk compression algorithm identifier.
|
* Chunk compression algorithm identifier.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { File as FileEntity, NewFile } from '../../domain/entities/file';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
import { config } from '../../env';
|
import { config } from '../../env';
|
||||||
import { cleanupTempFile } from '../../shared/utils/file';
|
import { cleanupTempFile } from '../../shared/utils/file';
|
||||||
import { createZip, type ZipEntry } from '../../shared/utils/zip';
|
import { createZip, type ZipEntry } from '../../shared/utils/zip';
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
|
||||||
import type { File as FileEntity, NewFile } from '../../domain/entities/file';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata about a prepared upload before it is submitted to the batcher.
|
* Metadata about a prepared upload before it is submitted to the batcher.
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { type Context, Telegraf } from 'telegraf';
|
import { type Context, Telegraf } from 'telegraf';
|
||||||
import { config } from '../../env';
|
|
||||||
import type { NewFile } from '../../domain/entities/file';
|
import type { NewFile } from '../../domain/entities/file';
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
|
import { config } from '../../env';
|
||||||
import { DrizzleFileRepository } from '../../infrastructure/persistence/repositories/file-repository';
|
import { DrizzleFileRepository } from '../../infrastructure/persistence/repositories/file-repository';
|
||||||
import { botPool } from '../../infrastructure/telegram/bot-pool';
|
import { botPool } from '../../infrastructure/telegram/bot-pool';
|
||||||
|
import logger from '../../shared/logger/index';
|
||||||
import {
|
import {
|
||||||
detectFileType,
|
detectFileType,
|
||||||
extractFileFromMessage,
|
extractFileFromMessage,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
getFileSizeLimit,
|
getFileSizeLimit,
|
||||||
type TelegramMediaMessage,
|
type TelegramMediaMessage,
|
||||||
} from '../../shared/utils/file';
|
} from '../../shared/utils/file';
|
||||||
import logger from '../../shared/logger/index';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal bot context shape used by the media event handler.
|
* Minimal bot context shape used by the media event handler.
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
|
import {
|
||||||
|
type AuthSession,
|
||||||
|
createLoginUseCase,
|
||||||
|
createLogoutUseCase,
|
||||||
|
createMeUseCase,
|
||||||
|
} from '../../../application/use-cases/authenticate';
|
||||||
import { config } from '../../../config/index';
|
import { config } from '../../../config/index';
|
||||||
import {
|
import {
|
||||||
|
checkBearerToken,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
createSessionCookie,
|
createSessionCookie,
|
||||||
getAuthSession,
|
getAuthSession,
|
||||||
isAuthEnabled,
|
isAuthEnabled,
|
||||||
checkBearerToken,
|
|
||||||
} from '../../../utils/auth';
|
} from '../../../utils/auth';
|
||||||
import {
|
|
||||||
createLoginUseCase,
|
|
||||||
createLogoutUseCase,
|
|
||||||
createMeUseCase,
|
|
||||||
type AuthSession,
|
|
||||||
} from '../../../application/use-cases/authenticate';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper that builds a JSON Response with optional extra headers.
|
* Helper that builds a JSON Response with optional extra headers.
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { config } from '../../../config/index';
|
|
||||||
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
||||||
import { createChunkedObjectResponse } from '../../../utils/chunked-storage';
|
|
||||||
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
|
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
|
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
|
||||||
|
import { createChunkedObjectResponse } from '../../../utils/chunked-storage';
|
||||||
import { getFileInfo, type TelegramFileInfo } from '../../../utils/telegram';
|
import { getFileInfo, type TelegramFileInfo } from '../../../utils/telegram';
|
||||||
import { locateZipEntry } from '../../../utils/zip';
|
import { locateZipEntry } from '../../../utils/zip';
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ type RequestWithParams = Request & {
|
|||||||
* @param value - The string value to wrap.
|
* @param value - The string value to wrap.
|
||||||
* @returns The value as a single-element tuple.
|
* @returns The value as a single-element tuple.
|
||||||
*/
|
*/
|
||||||
const asArray = (value: string): string[] => [value];
|
const _asArray = (value: string): string[] => [value];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves Telegram file metadata for a given file ID, using the in-memory
|
* Resolves Telegram file metadata for a given file ID, using the in-memory
|
||||||
@@ -35,7 +34,10 @@ const asArray = (value: string): string[] => [value];
|
|||||||
* @param publicId - The public file ID (used for logging).
|
* @param publicId - The public file ID (used for logging).
|
||||||
* @returns The resolved Telegram file info.
|
* @returns The resolved Telegram file info.
|
||||||
*/
|
*/
|
||||||
const getTelegramFileInfo = async (telegramFileId: string, publicId: string): Promise<TelegramFileInfo> => {
|
const getTelegramFileInfo = async (
|
||||||
|
telegramFileId: string,
|
||||||
|
publicId: string,
|
||||||
|
): Promise<TelegramFileInfo> => {
|
||||||
const cacheKey = `file_info_${telegramFileId}`;
|
const cacheKey = `file_info_${telegramFileId}`;
|
||||||
const cached = fileInfoCache.get(cacheKey) as TelegramFileInfo | null;
|
const cached = fileInfoCache.get(cacheKey) as TelegramFileInfo | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { db } from '../../../infrastructure/persistence/drizzle/index';
|
import { db } from '../../../infrastructure/persistence/drizzle/index';
|
||||||
import { getErrorMessage } from '../../../shared/utils/file';
|
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
|
import { getErrorMessage } from '../../../shared/utils/file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the health-check endpoint.
|
* Handles the health-check endpoint.
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import {
|
import { config } from '../../../config/index';
|
||||||
createBucket,
|
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets';
|
||||||
deleteBucket,
|
|
||||||
findBucketByName,
|
|
||||||
listBuckets,
|
|
||||||
} from '../../../db/buckets';
|
|
||||||
import {
|
import {
|
||||||
countBucketObjects,
|
countBucketObjects,
|
||||||
findFileByBucketAndKey,
|
findFileByBucketAndKey,
|
||||||
@@ -22,18 +18,12 @@ import {
|
|||||||
listMultipartUploadsByBucket,
|
listMultipartUploadsByBucket,
|
||||||
} from '../../../db/multipart';
|
} from '../../../db/multipart';
|
||||||
import type { File } from '../../../db/schema';
|
import type { File } from '../../../db/schema';
|
||||||
import { config } from '../../../config/index';
|
import logger from '../../../shared/logger/index';
|
||||||
|
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
|
||||||
import {
|
import {
|
||||||
createChunkedObjectResponse,
|
createChunkedObjectResponse,
|
||||||
storeFileInTelegramChunks,
|
storeFileInTelegramChunks,
|
||||||
} from '../../../utils/chunked-storage';
|
} from '../../../utils/chunked-storage';
|
||||||
import {
|
|
||||||
cleanupTempFile,
|
|
||||||
computeHash,
|
|
||||||
ensureExtension,
|
|
||||||
getErrorMessage,
|
|
||||||
} from '../../../shared/utils/file';
|
|
||||||
import logger from '../../../shared/logger/index';
|
|
||||||
import { verifyPresignedUrl, verifySignature } from '../../../utils/s3/auth';
|
import { verifyPresignedUrl, verifySignature } from '../../../utils/s3/auth';
|
||||||
import { S3_CORS_HEADERS, s3Headers } from '../../../utils/s3/headers';
|
import { S3_CORS_HEADERS, s3Headers } from '../../../utils/s3/headers';
|
||||||
import { createGetObjectResponse, type ObjectPartSource } from '../../../utils/s3/object-stream';
|
import { createGetObjectResponse, type ObjectPartSource } from '../../../utils/s3/object-stream';
|
||||||
@@ -700,7 +690,14 @@ const streamBodyToTemp = async (
|
|||||||
const tempPath = `/tmp/filedrop-s3-${nanoid()}`;
|
const tempPath = `/tmp/filedrop-s3-${nanoid()}`;
|
||||||
const writer = Bun.file(tempPath).writer();
|
const writer = Bun.file(tempPath).writer();
|
||||||
const hasher = new Bun.CryptoHasher('sha256');
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
const reader = (body ?? new ReadableStream({ start(c) { c.close() } })).getReader();
|
const reader = (
|
||||||
|
body ??
|
||||||
|
new ReadableStream({
|
||||||
|
start(c) {
|
||||||
|
c.close();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).getReader();
|
||||||
const SIGNATURE_BYTES = 16;
|
const SIGNATURE_BYTES = 16;
|
||||||
const signatureChunks: Buffer[] = [];
|
const signatureChunks: Buffer[] = [];
|
||||||
let signatureBytes = 0;
|
let signatureBytes = 0;
|
||||||
@@ -1273,7 +1270,14 @@ const handleUploadPart = async (
|
|||||||
// Stream the part body to temp — O(1) memory, safe for large parts
|
// Stream the part body to temp — O(1) memory, safe for large parts
|
||||||
const tempPath = `/tmp/filedrop-mp-${nanoid()}`;
|
const tempPath = `/tmp/filedrop-mp-${nanoid()}`;
|
||||||
const writer = Bun.file(tempPath).writer();
|
const writer = Bun.file(tempPath).writer();
|
||||||
const reader = (req.body ?? new ReadableStream({ start(c) { c.close() } })).getReader();
|
const reader = (
|
||||||
|
req.body ??
|
||||||
|
new ReadableStream({
|
||||||
|
start(c) {
|
||||||
|
c.close();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).getReader();
|
||||||
const hasher = new Bun.CryptoHasher('sha256');
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
let sizeBytes = 0;
|
let sizeBytes = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createWriteStream } from 'node:fs';
|
import { createWriteStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { config } from '../../../config/index';
|
import { config } from '../../../config/index';
|
||||||
|
import { findFileByHash } from '../../../db/files';
|
||||||
|
import logger from '../../../shared/logger/index';
|
||||||
|
import { metricsCollector } from '../../../shared/metrics/index';
|
||||||
import {
|
import {
|
||||||
buildUploadResponse,
|
buildUploadResponse,
|
||||||
checkFileSize,
|
checkFileSize,
|
||||||
@@ -11,11 +14,8 @@ import {
|
|||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
getFileType,
|
getFileType,
|
||||||
} from '../../../shared/utils/file';
|
} from '../../../shared/utils/file';
|
||||||
import logger from '../../../shared/logger/index';
|
|
||||||
import { metricsCollector } from '../../../shared/metrics/index';
|
|
||||||
import { enqueuePreparedUpload, type PreparedUpload } from '../../../utils/uploadBatcher';
|
|
||||||
import { storeFileInTelegramChunks } from '../../../utils/chunked-storage';
|
import { storeFileInTelegramChunks } from '../../../utils/chunked-storage';
|
||||||
import { findFileByHash } from '../../../db/files';
|
import { enqueuePreparedUpload, type PreparedUpload } from '../../../utils/uploadBatcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum allowed size (in bytes) for a base64 JSON upload.
|
* Maximum allowed size (in bytes) for a base64 JSON upload.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { config } from '../../../config/index';
|
||||||
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets';
|
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets';
|
||||||
import {
|
import {
|
||||||
countBucketObjects,
|
countBucketObjects,
|
||||||
@@ -7,10 +8,12 @@ import {
|
|||||||
listObjectsByPrefix,
|
listObjectsByPrefix,
|
||||||
softDeleteFile,
|
softDeleteFile,
|
||||||
} from '../../../db/files-ext';
|
} from '../../../db/files-ext';
|
||||||
import { config } from '../../../config/index';
|
|
||||||
import { createChunkedObjectResponse, storeFileInTelegramChunks } from '../../../utils/chunked-storage';
|
|
||||||
import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
|
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
|
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
|
||||||
|
import {
|
||||||
|
createChunkedObjectResponse,
|
||||||
|
storeFileInTelegramChunks,
|
||||||
|
} from '../../../utils/chunked-storage';
|
||||||
import { forwardToStorage, getFileInfo } from '../../../utils/telegram';
|
import { forwardToStorage, getFileInfo } from '../../../utils/telegram';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ const getSecret = (secret?: string): string => secret ?? config.adminApiToken;
|
|||||||
const getCookieName = (cookieName?: string): string => cookieName ?? config.sessionCookieName;
|
const getCookieName = (cookieName?: string): string => cookieName ?? config.sessionCookieName;
|
||||||
const getMaxAgeMs = (maxAgeMs?: number): number => maxAgeMs ?? config.sessionMaxAgeMs;
|
const getMaxAgeMs = (maxAgeMs?: number): number => maxAgeMs ?? config.sessionMaxAgeMs;
|
||||||
|
|
||||||
const encodePayload = (value: string): string =>
|
const encodePayload = (value: string): string => Buffer.from(value, 'utf8').toString('base64url');
|
||||||
Buffer.from(value, 'utf8').toString('base64url');
|
|
||||||
|
|
||||||
const decodePayload = (value: string): string | null => {
|
const decodePayload = (value: string): string | null => {
|
||||||
try {
|
try {
|
||||||
@@ -107,10 +106,7 @@ export const signCookiePayload = (payload: string, secret: string): string =>
|
|||||||
* @param secret - HMAC signing key.
|
* @param secret - HMAC signing key.
|
||||||
* @returns The unsigned payload string, or `null` on failure.
|
* @returns The unsigned payload string, or `null` on failure.
|
||||||
*/
|
*/
|
||||||
export const verifyCookieSignature = (
|
export const verifyCookieSignature = (cookieValue: string, secret: string): string | null => {
|
||||||
cookieValue: string,
|
|
||||||
secret: string,
|
|
||||||
): string | null => {
|
|
||||||
const separatorIndex = cookieValue.lastIndexOf(SIGNATURE_SEPARATOR);
|
const separatorIndex = cookieValue.lastIndexOf(SIGNATURE_SEPARATOR);
|
||||||
if (separatorIndex <= 0 || separatorIndex === cookieValue.length - 1) {
|
if (separatorIndex <= 0 || separatorIndex === cookieValue.length - 1) {
|
||||||
return null;
|
return null;
|
||||||
@@ -136,13 +132,7 @@ export const verifyCookieSignature = (
|
|||||||
* @returns The cookie attribute string (excluding name=value).
|
* @returns The cookie attribute string (excluding name=value).
|
||||||
*/
|
*/
|
||||||
const cookieAttributes = (maxAgeSeconds: number): string =>
|
const cookieAttributes = (maxAgeSeconds: number): string =>
|
||||||
[
|
[`Max-Age=${maxAgeSeconds}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Secure'].join('; ');
|
||||||
`Max-Age=${maxAgeSeconds}`,
|
|
||||||
'Path=/',
|
|
||||||
'HttpOnly',
|
|
||||||
'SameSite=Lax',
|
|
||||||
'Secure',
|
|
||||||
].join('; ');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a signed session cookie string suitable for use as a
|
* Creates a signed session cookie string suitable for use as a
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export { withRateLimit, cleanupRateLimitCache, checkRateLimit, clearRateLimitCache, getRateLimitStats } from '../../../utils/rateLimit';
|
export {
|
||||||
|
checkRateLimit,
|
||||||
|
cleanupRateLimitCache,
|
||||||
|
clearRateLimitCache,
|
||||||
|
getRateLimitStats,
|
||||||
|
withRateLimit,
|
||||||
|
} from '../../../utils/rateLimit';
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { config } from '../../../config/index';
|
import { config } from '../../../config/index';
|
||||||
|
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
||||||
|
import { extractS3BucketFromHost } from '../../../utils/s3/virtual-host';
|
||||||
|
import { isS3Request } from '../../s3/auth';
|
||||||
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
||||||
import { handleFileRedirect, handleFileInfo } from '../controllers/file-controller';
|
import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller';
|
||||||
import { handleHealth } from '../controllers/health-controller';
|
import { handleHealth } from '../controllers/health-controller';
|
||||||
import { handleHome } from '../controllers/home-controller';
|
import { handleHome } from '../controllers/home-controller';
|
||||||
import { handleS3Request } from '../controllers/s3-controller';
|
import { handleS3Request } from '../controllers/s3-controller';
|
||||||
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
|
||||||
import { handleUpload } from '../controllers/upload-controller';
|
import { handleUpload } from '../controllers/upload-controller';
|
||||||
import { handleWebApiV1 } from '../controllers/web-api-controller';
|
import { handleWebApiV1 } from '../controllers/web-api-controller';
|
||||||
import { requireAuth } from '../middleware/auth';
|
import { requireAuth } from '../middleware/auth';
|
||||||
import { withRateLimit } from '../middleware/rate-limit';
|
import { withRateLimit } from '../middleware/rate-limit';
|
||||||
import { isS3Request } from '../../s3/auth';
|
|
||||||
import { extractS3BucketFromHost } from '../../../utils/s3/virtual-host';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the S3 bucket name from the request host
|
* Extracts the S3 bucket name from the request host
|
||||||
@@ -47,7 +47,7 @@ const shouldHandleS3 = (req: Request, headers: Record<string, string>): boolean
|
|||||||
* @param req - The incoming HTTP request.
|
* @param req - The incoming HTTP request.
|
||||||
* @returns A Response from the S3 handler or a 405 response.
|
* @returns A Response from the S3 handler or a 405 response.
|
||||||
*/
|
*/
|
||||||
const handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
const _handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
return handleS3Request(req, getS3RouteBucket(req));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
export { isS3Request, buildCanonicalQueryString, verifyPresignedUrl, verifySignature } from '../../utils/s3/auth';
|
|
||||||
export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth';
|
export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth';
|
||||||
|
export {
|
||||||
|
buildCanonicalQueryString,
|
||||||
|
isS3Request,
|
||||||
|
verifyPresignedUrl,
|
||||||
|
verifySignature,
|
||||||
|
} from '../../utils/s3/auth';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import _logger from "../../utils/logger";
|
import _logger from '../../utils/logger';
|
||||||
export default _logger;
|
export default _logger;
|
||||||
|
export type { Logger } from 'winston';
|
||||||
export { _logger as logger };
|
export { _logger as logger };
|
||||||
export type { Logger } from "winston";
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { metricsCollector, MetricsCollector } from '../../utils/metrics';
|
export { MetricsCollector, metricsCollector } from '../../utils/metrics';
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import { createReadStream, createWriteStream } from 'node:fs';
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
import { open, stat } from 'node:fs/promises';
|
import { open, stat, unlink } from 'node:fs/promises';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import { unlink } from 'node:fs/promises';
|
|
||||||
import { finished } from 'node:stream/promises';
|
import { finished } from 'node:stream/promises';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
import { timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timing-safe string comparison that prevents timing attacks.
|
* Timing-safe string comparison that prevents timing attacks.
|
||||||
*
|
*
|
||||||
@@ -293,7 +291,10 @@ export const verifyPresignedUrl = async ({
|
|||||||
}
|
}
|
||||||
// AWS S3 spec limits presigned URLs to 7 days (604800 seconds)
|
// AWS S3 spec limits presigned URLs to 7 days (604800 seconds)
|
||||||
const MAX_PRESIGNED_EXPIRY_SECONDS = 604800;
|
const MAX_PRESIGNED_EXPIRY_SECONDS = 604800;
|
||||||
if (now.getTime() > signedAt.getTime() + expires * 1000 || expires > MAX_PRESIGNED_EXPIRY_SECONDS) {
|
if (
|
||||||
|
now.getTime() > signedAt.getTime() + expires * 1000 ||
|
||||||
|
expires > MAX_PRESIGNED_EXPIRY_SECONDS
|
||||||
|
) {
|
||||||
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,7 @@
|
|||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import { createReadStream, createWriteStream } from 'node:fs';
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
import { open, stat } from 'node:fs/promises';
|
import { open, stat, unlink } from 'node:fs/promises';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import { unlink } from 'node:fs/promises';
|
|
||||||
import { finished } from 'node:stream/promises';
|
import { finished } from 'node:stream/promises';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -138,7 +138,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document');
|
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith(
|
||||||
|
'doc_123',
|
||||||
|
'cv.pdf',
|
||||||
|
'document',
|
||||||
|
);
|
||||||
expect(mockFileRepo.create).toHaveBeenCalled();
|
expect(mockFileRepo.create).toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(
|
expect(replyMock).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('File berhasil diupload'),
|
expect.stringContaining('File berhasil diupload'),
|
||||||
@@ -245,7 +249,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker');
|
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith(
|
||||||
|
'sticker_123',
|
||||||
|
'file',
|
||||||
|
'sticker',
|
||||||
|
);
|
||||||
expect(mockFileRepo.create).toHaveBeenCalled();
|
expect(mockFileRepo.create).toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(
|
expect(replyMock).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('File berhasil diupload'),
|
expect.stringContaining('File berhasil diupload'),
|
||||||
@@ -278,7 +286,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note');
|
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith(
|
||||||
|
'video_note_123',
|
||||||
|
'file',
|
||||||
|
'video_note',
|
||||||
|
);
|
||||||
expect(mockFileRepo.create).toHaveBeenCalled();
|
expect(mockFileRepo.create).toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(
|
expect(replyMock).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('File berhasil diupload'),
|
expect.stringContaining('File berhasil diupload'),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - Concurrent operation safety
|
* - Concurrent operation safety
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it, mock } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
// ─── streamBodyToTemp tests ──────────────────────────────────────
|
// ─── streamBodyToTemp tests ──────────────────────────────────────
|
||||||
@@ -19,7 +19,7 @@ describe('S3 Streaming Upload Safety', () => {
|
|||||||
*/
|
*/
|
||||||
it('streams body to temp file without buffering entire body', async () => {
|
it('streams body to temp file without buffering entire body', async () => {
|
||||||
// Import the S3 controller module
|
// Import the S3 controller module
|
||||||
const mod = await import('../src/interfaces/http/controllers/s3-controller.ts');
|
const _mod = await import('../src/interfaces/http/controllers/s3-controller.ts');
|
||||||
|
|
||||||
// Create a ReadableStream with known content
|
// Create a ReadableStream with known content
|
||||||
const content = 'Hello, Docker Registry! This is a test blob.';
|
const content = 'Hello, Docker Registry! This is a test blob.';
|
||||||
@@ -144,9 +144,7 @@ describe('S3 Streaming Upload Safety', () => {
|
|||||||
* by checking the module source code.
|
* by checking the module source code.
|
||||||
*/
|
*/
|
||||||
it('uses streaming instead of req.arrayBuffer() for PUT body', async () => {
|
it('uses streaming instead of req.arrayBuffer() for PUT body', async () => {
|
||||||
const source = await Bun.file(
|
const source = await Bun.file('src/interfaces/http/controllers/s3-controller.ts').text();
|
||||||
'src/interfaces/http/controllers/s3-controller.ts',
|
|
||||||
).text();
|
|
||||||
|
|
||||||
const codeLines = source.split('\n').filter((l) => !l.trim().startsWith('*'));
|
const codeLines = source.split('\n').filter((l) => !l.trim().startsWith('*'));
|
||||||
const codeText = codeLines.join('\n');
|
const codeText = codeLines.join('\n');
|
||||||
@@ -157,7 +155,8 @@ describe('S3 Streaming Upload Safety', () => {
|
|||||||
|
|
||||||
// handlePutObject should NOT contain req.arrayBuffer()
|
// handlePutObject should NOT contain req.arrayBuffer()
|
||||||
// (note: comments that mention arrayBuffer are filtered out)
|
// (note: comments that mention arrayBuffer are filtered out)
|
||||||
const putObjectCode = codeText.split('handlePutObject =')[1]?.split('storeFileFromTemp =')[0] || '';
|
const putObjectCode =
|
||||||
|
codeText.split('handlePutObject =')[1]?.split('storeFileFromTemp =')[0] || '';
|
||||||
expect(putObjectCode).not.toMatch(/req\.arrayBuffer\(\)/);
|
expect(putObjectCode).not.toMatch(/req\.arrayBuffer\(\)/);
|
||||||
expect(putObjectCode).toContain('streamBodyToTemp');
|
expect(putObjectCode).toContain('streamBodyToTemp');
|
||||||
});
|
});
|
||||||
@@ -171,12 +170,13 @@ describe('S3 UploadPart Streaming', () => {
|
|||||||
* req.arrayBuffer().
|
* req.arrayBuffer().
|
||||||
*/
|
*/
|
||||||
it('streams part body instead of req.arrayBuffer()', async () => {
|
it('streams part body instead of req.arrayBuffer()', async () => {
|
||||||
const source = await Bun.file(
|
const source = await Bun.file('src/interfaces/http/controllers/s3-controller.ts').text();
|
||||||
'src/interfaces/http/controllers/s3-controller.ts',
|
|
||||||
).text();
|
|
||||||
|
|
||||||
// Find the handleUploadPart function
|
// Find the handleUploadPart function
|
||||||
const uploadPartSection = source.split('const handleUploadPart =')[1]?.split('const handleCompleteMultipartUpload =')[0] || '';
|
const uploadPartSection =
|
||||||
|
source
|
||||||
|
.split('const handleUploadPart =')[1]
|
||||||
|
?.split('const handleCompleteMultipartUpload =')[0] || '';
|
||||||
expect(uploadPartSection).not.toContain('arrayBuffer');
|
expect(uploadPartSection).not.toContain('arrayBuffer');
|
||||||
expect(uploadPartSection).toContain('getReader');
|
expect(uploadPartSection).toContain('getReader');
|
||||||
expect(uploadPartSection).toContain('Bun.file(tempPath).writer()');
|
expect(uploadPartSection).toContain('Bun.file(tempPath).writer()');
|
||||||
@@ -306,9 +306,7 @@ describe('S3 Edge Cases', () => {
|
|||||||
expect(totalBytes).toBe(0);
|
expect(totalBytes).toBe(0);
|
||||||
const fileSize = Bun.file(tempPath).size;
|
const fileSize = Bun.file(tempPath).size;
|
||||||
expect(fileSize).toBe(0);
|
expect(fileSize).toBe(0);
|
||||||
expect(hasher.digest('hex')).toBe(
|
expect(hasher.digest('hex')).toBe(new Bun.CryptoHasher('sha256').update('').digest('hex'));
|
||||||
new Bun.CryptoHasher('sha256').update('').digest('hex'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Bun.write(tempPath, '');
|
await Bun.write(tempPath, '');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user