Merge branch 'worktree-ddd-clean-architecture-restructure'
# Conflicts: # src/infrastructure/cache/index.ts # src/interfaces/http/middleware/auth.ts # src/interfaces/http/middleware/rate-limit.ts # src/shared/logger/index.ts
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Input for the login endpoint.
|
||||||
|
* The caller provides the admin API token to obtain a session cookie.
|
||||||
|
*/
|
||||||
|
export interface LoginInput {
|
||||||
|
/** Admin API token for authentication */
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active authentication session information.
|
||||||
|
*/
|
||||||
|
export interface AuthSession {
|
||||||
|
/** Authenticated username (currently always "admin") */
|
||||||
|
username: string;
|
||||||
|
/** Session expiry timestamp; null for bearer-token sessions */
|
||||||
|
expiresAt: Date | null;
|
||||||
|
/** Authentication method used */
|
||||||
|
method: 'cookie' | 'bearer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for a successful login.
|
||||||
|
*/
|
||||||
|
export interface LoginResponse {
|
||||||
|
/** Authenticated username */
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for logout.
|
||||||
|
*/
|
||||||
|
export interface LogoutResponse {
|
||||||
|
/** Whether the logout succeeded */
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for the current-user (/me) endpoint.
|
||||||
|
*/
|
||||||
|
export interface UserInfoResponse {
|
||||||
|
/** Authenticated username */
|
||||||
|
username: string;
|
||||||
|
/** ISO-8601 session expiry timestamp; null when using bearer token */
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Input for creating a new bucket.
|
||||||
|
*/
|
||||||
|
export interface CreateBucketInput {
|
||||||
|
/** Bucket name (must match S3 naming rules: 3-63 chars, lowercase, no underscore) */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single bucket representation returned by bucket endpoints.
|
||||||
|
*/
|
||||||
|
export interface BucketResponse {
|
||||||
|
/** Bucket UUID */
|
||||||
|
id: string;
|
||||||
|
/** Bucket name */
|
||||||
|
name: string;
|
||||||
|
/** ISO-8601 timestamp of when the bucket was created */
|
||||||
|
createdAt: string;
|
||||||
|
/** Number of non-deleted objects in the bucket */
|
||||||
|
objectCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for the list-buckets endpoint.
|
||||||
|
*/
|
||||||
|
export interface BucketListResponse {
|
||||||
|
/** Array of buckets */
|
||||||
|
buckets: BucketResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for bucket creation.
|
||||||
|
*/
|
||||||
|
export interface CreateBucketResponse {
|
||||||
|
/** Bucket UUID */
|
||||||
|
id: string;
|
||||||
|
/** Bucket name */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for bucket deletion.
|
||||||
|
*/
|
||||||
|
export interface DeleteBucketResponse {
|
||||||
|
/** Whether the deletion succeeded */
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Public file information response returned by the file-info endpoint.
|
||||||
|
* Mirrors the JSON shape of GET /file/:publicId/info.
|
||||||
|
*/
|
||||||
|
export interface FileInfoResponse {
|
||||||
|
/** Public, shareable identifier (nanoid) */
|
||||||
|
public_id: string;
|
||||||
|
/** Stored file name */
|
||||||
|
file_name: string;
|
||||||
|
/** MIME type of the stored file */
|
||||||
|
mime_type: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
size_bytes: number;
|
||||||
|
/** High-level file category (e.g. "document", "photo") */
|
||||||
|
file_type: string;
|
||||||
|
/** ISO-8601 timestamp of when the file record was created */
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary-level file metadata used internally for constructing
|
||||||
|
* upload responses and object listing entries.
|
||||||
|
*/
|
||||||
|
export interface FileMetadata {
|
||||||
|
/** Public, shareable identifier (nanoid) */
|
||||||
|
publicId: string;
|
||||||
|
/** Telegram file identifier used to retrieve the file from Telegram CDN */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram unique file identifier (persists across re‑uploads) */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** Chat ID where the file or archive was stored */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Message ID of the stored file or archive */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Stored file name */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the stored file */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** High-level file category */
|
||||||
|
fileType: string;
|
||||||
|
/** Telegram user ID of the uploader; 0 when unknown or system */
|
||||||
|
uploaderId: number;
|
||||||
|
/** Timestamp of file record creation */
|
||||||
|
createdAt: Date | string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload response shape returned to API callers.
|
||||||
|
* Mirrors the JSON output of the /api/upload endpoint.
|
||||||
|
*/
|
||||||
|
export interface UploadResponse {
|
||||||
|
/** Public, shareable identifier */
|
||||||
|
public_id: string;
|
||||||
|
/** Stored file name */
|
||||||
|
file_name: string;
|
||||||
|
/** MIME type */
|
||||||
|
mime_type: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
size_bytes: number;
|
||||||
|
/** High-level file category */
|
||||||
|
file_type: string;
|
||||||
|
/** ISO-8601 creation timestamp */
|
||||||
|
created_at: string;
|
||||||
|
/** Public download URL */
|
||||||
|
download_url: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* A single S3 object as it appears in listing results.
|
||||||
|
*/
|
||||||
|
export interface S3ObjectResponse {
|
||||||
|
/** The object key (full path within the bucket) */
|
||||||
|
key: string;
|
||||||
|
/** Stored file name (basename of the key) */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the stored object */
|
||||||
|
mimeType: string;
|
||||||
|
/** Object size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** High-level file category */
|
||||||
|
fileType: string;
|
||||||
|
/** SHA-256 hex digest of the object content */
|
||||||
|
etag: string | null;
|
||||||
|
/** ISO-8601 timestamp of last modification */
|
||||||
|
lastModified: string;
|
||||||
|
/** Public download URL */
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for S3 ListObjectsV1 / ListObjectsV2.
|
||||||
|
*/
|
||||||
|
export interface S3ListObjectsResponse {
|
||||||
|
/** Array of object summaries */
|
||||||
|
objects: S3ObjectResponse[];
|
||||||
|
/** Common prefixes when a delimiter was used (e.g. "folder/" entries) */
|
||||||
|
prefixes: string[];
|
||||||
|
/** Whether more results are available */
|
||||||
|
isTruncated: boolean;
|
||||||
|
/** Token to pass as continuation-token to retrieve the next page */
|
||||||
|
nextContinuationToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for the copy-object operation (Web API v1).
|
||||||
|
*/
|
||||||
|
export interface S3CopyObjectInput {
|
||||||
|
/** Source object key within the same or source bucket */
|
||||||
|
sourceKey: string;
|
||||||
|
/** Destination bucket name; defaults to the source bucket when omitted */
|
||||||
|
destBucket?: string;
|
||||||
|
/** Destination object key */
|
||||||
|
destKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response payload for the copy-object operation.
|
||||||
|
*/
|
||||||
|
export interface S3CopyObjectResponse {
|
||||||
|
/** Source object key that was copied */
|
||||||
|
sourceKey: string;
|
||||||
|
/** Destination object key */
|
||||||
|
destKey: string;
|
||||||
|
/** Destination bucket name */
|
||||||
|
destBucket: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of a multipart upload in listing results.
|
||||||
|
*/
|
||||||
|
export interface S3MultipartUploadResponse {
|
||||||
|
/** The object key being uploaded */
|
||||||
|
key: string;
|
||||||
|
/** Upload identifier (nanoid) */
|
||||||
|
uploadId: string;
|
||||||
|
/** ISO-8601 timestamp when the upload was initiated */
|
||||||
|
initiatedAt: Date;
|
||||||
|
/** Identifier string of the upload initiator */
|
||||||
|
initiatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of a single part within a multipart upload.
|
||||||
|
*/
|
||||||
|
export interface S3MultipartPartResponse {
|
||||||
|
/** 1-indexed part number */
|
||||||
|
partNumber: number;
|
||||||
|
/** ETag of the part content */
|
||||||
|
etag: string;
|
||||||
|
/** Part size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** ISO-8601 timestamp when the part was stored */
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Input for the upload file use case.
|
||||||
|
* Carries all metadata needed to persist an uploaded file,
|
||||||
|
* including its temporary location on disk and optional bucket/S3 context.
|
||||||
|
*/
|
||||||
|
export interface UploadInput {
|
||||||
|
/** Absolute path to the temporary file on disk */
|
||||||
|
tempPath: string;
|
||||||
|
/** SHA-256 hex digest of the file content */
|
||||||
|
fileHash: string;
|
||||||
|
/** Original file name (may include extension) */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type detected from content inspection or request header */
|
||||||
|
mimeType: string;
|
||||||
|
/** High-level file category (e.g. "document", "photo", "video") */
|
||||||
|
fileType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Telegram user ID of the uploader; 0 when unknown or system */
|
||||||
|
uploaderId?: number;
|
||||||
|
/** Target bucket UUID for S3-compatible storage; null when un-bucketed */
|
||||||
|
bucketId?: string | null;
|
||||||
|
/** Object key within the bucket for S3-compatible storage; null when un-bucketed */
|
||||||
|
s3Key?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output from the upload file use case.
|
||||||
|
* Contains the public-facing file metadata returned to the caller.
|
||||||
|
*/
|
||||||
|
export interface UploadOutput {
|
||||||
|
/** Public, shareable identifier (nanoid) */
|
||||||
|
publicId: string;
|
||||||
|
/** Stored file name (may have been normalized with extension) */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the stored file */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** High-level file category */
|
||||||
|
fileType: string;
|
||||||
|
/** ISO-8601 timestamp of when the file record was created */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Public download URL */
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { LoginInput, LoginResponse, LogoutResponse, UserInfoResponse, AuthSession } from '../dto/auth';
|
||||||
|
|
||||||
|
/** Subset of application configuration consumed by the authenticate use case. */
|
||||||
|
export interface AuthUseCaseConfig {
|
||||||
|
/** Admin API token used to authenticate login requests. */
|
||||||
|
adminApiToken: string;
|
||||||
|
/** Name of the session cookie. */
|
||||||
|
sessionCookieName: string;
|
||||||
|
/** Session lifetime in milliseconds. */
|
||||||
|
sessionMaxAgeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the authenticate use case factory. */
|
||||||
|
export interface AuthenticateUseCaseDeps {
|
||||||
|
/** Application configuration subset. */
|
||||||
|
config: AuthUseCaseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a constant-time string comparison to prevent timing attacks.
|
||||||
|
*
|
||||||
|
* @param left - The first string to compare.
|
||||||
|
* @param right - The second string to compare.
|
||||||
|
* @returns `true` if the strings are equal, `false` otherwise.
|
||||||
|
*/
|
||||||
|
const timingSafeCompare = (left: string, right: string): boolean => {
|
||||||
|
const leftBuffer = Buffer.from(left);
|
||||||
|
const rightBuffer = Buffer.from(right);
|
||||||
|
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether authentication is enabled based on the configured token.
|
||||||
|
*
|
||||||
|
* @param adminApiToken - The admin API token value.
|
||||||
|
* @returns `true` if the token is non-empty (auth is enabled).
|
||||||
|
*/
|
||||||
|
const isAuthEnabled = (adminApiToken: string): boolean => adminApiToken.length > 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the login use case.
|
||||||
|
*
|
||||||
|
* Validates the provided admin API token and returns session metadata on
|
||||||
|
* success. The caller (controller/adapter) is responsible for translating
|
||||||
|
* the result into an HTTP response (e.g. setting a session cookie).
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting login input and returning a login response.
|
||||||
|
*/
|
||||||
|
export function createLoginUseCase(deps: AuthenticateUseCaseDeps) {
|
||||||
|
return async (input: LoginInput): Promise<LoginResponse> => {
|
||||||
|
if (!isAuthEnabled(deps.config.adminApiToken)) {
|
||||||
|
return { username: 'admin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timingSafeCompare(input.token, deps.config.adminApiToken)) {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { username: 'admin' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the logout use case.
|
||||||
|
*
|
||||||
|
* Always succeeds — the caller is responsible for clearing the session cookie.
|
||||||
|
*
|
||||||
|
* @returns An async function returning a logout response.
|
||||||
|
*/
|
||||||
|
export function createLogoutUseCase() {
|
||||||
|
return async (): Promise<LogoutResponse> => {
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the current-user (me) use case.
|
||||||
|
*
|
||||||
|
* Accepts an already-parsed auth session (from cookie or bearer token) and
|
||||||
|
* returns the user info response. The caller (controller/adapter) is
|
||||||
|
* responsible for extracting the session from the raw HTTP request.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting an optional session and returning user info.
|
||||||
|
*/
|
||||||
|
export function createMeUseCase(deps: AuthenticateUseCaseDeps) {
|
||||||
|
return async (session: AuthSession | null): Promise<UserInfoResponse | null> => {
|
||||||
|
if (!isAuthEnabled(deps.config.adminApiToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: session.username,
|
||||||
|
expiresAt: session.expiresAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { File } from '../../domain/entities/file';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result type for a simple file-info lookup.
|
||||||
|
*/
|
||||||
|
export interface FileInfoResult {
|
||||||
|
/** Whether the file was found. */
|
||||||
|
found: true;
|
||||||
|
/** Public unique identifier. */
|
||||||
|
publicId: string;
|
||||||
|
/** Original file name. */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type. */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Telegram file type (document, photo, video, etc.). */
|
||||||
|
fileType: string;
|
||||||
|
/** ISO-8601 creation timestamp. */
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result type for a file-not-found lookup.
|
||||||
|
*/
|
||||||
|
export interface FileNotFoundResult {
|
||||||
|
/** Always `false` for a not-found result. */
|
||||||
|
found: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of all possible file-info lookup outcomes.
|
||||||
|
*/
|
||||||
|
export type GetFileInfoResult = FileInfoResult | FileNotFoundResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a redirect-based file retrieval.
|
||||||
|
*/
|
||||||
|
export interface RedirectRetrieval {
|
||||||
|
/** Discriminant. */
|
||||||
|
type: 'redirect';
|
||||||
|
/** The resolved file entity. */
|
||||||
|
file: File;
|
||||||
|
/** Full Telegram CDN URL to redirect the client to. */
|
||||||
|
redirectUrl: string;
|
||||||
|
/** Cached Telegram file metadata. */
|
||||||
|
fileInfo: TelegramFileInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a chunked file retrieval that needs a multi-part response.
|
||||||
|
*/
|
||||||
|
export interface ChunkedRetrieval {
|
||||||
|
/** Discriminant. */
|
||||||
|
type: 'chunked';
|
||||||
|
/** The resolved file entity. */
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes an archive-entry file retrieval.
|
||||||
|
*/
|
||||||
|
export interface ArchiveEntryRetrieval {
|
||||||
|
/** Discriminant. */
|
||||||
|
type: 'archive-entry';
|
||||||
|
/** The resolved file entity. */
|
||||||
|
file: File;
|
||||||
|
/** Telegram file metadata for the archive container. */
|
||||||
|
archiveInfo: TelegramFileInfo;
|
||||||
|
/** Name of the entry within the archive. */
|
||||||
|
entryName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of all possible file retrieval outcomes.
|
||||||
|
*/
|
||||||
|
export type FileRetrievalResult = RedirectRetrieval | ChunkedRetrieval | ArchiveEntryRetrieval;
|
||||||
|
|
||||||
|
/** Subset of application configuration consumed by the get-file use case. */
|
||||||
|
export interface GetFileConfig {
|
||||||
|
/** Server base URL (used in constructing archive download URLs). */
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the get-file use case factory. */
|
||||||
|
export interface GetFileUseCaseDeps {
|
||||||
|
/** File repository for looking up file records. */
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
/** Telegram service for resolving file identifiers to download paths. */
|
||||||
|
telegramService: ITelegramService;
|
||||||
|
/** Application configuration subset. */
|
||||||
|
config: GetFileConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the get-file info use case.
|
||||||
|
*
|
||||||
|
* Looks up a file by its public identifier and returns its metadata.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting a public ID and returning file info.
|
||||||
|
*/
|
||||||
|
export function createGetFileInfoUseCase(deps: Pick<GetFileUseCaseDeps, 'fileRepo'>) {
|
||||||
|
return async (publicId: string): Promise<GetFileInfoResult> => {
|
||||||
|
const file = await deps.fileRepo.findByPublicId(publicId);
|
||||||
|
if (!file) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
publicId: file.publicId,
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
sizeBytes: file.sizeBytes,
|
||||||
|
fileType: file.fileType,
|
||||||
|
createdAt: formatCreatedAtForInfo(file.createdAt),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date-like value into an ISO-8601 string.
|
||||||
|
*
|
||||||
|
* @param date - A Date instance, date string, or numeric timestamp.
|
||||||
|
* @returns The ISO-8601 string.
|
||||||
|
*/
|
||||||
|
const formatCreatedAtForInfo = (date: Date | string | number): string => {
|
||||||
|
if (date instanceof Date) return date.toISOString();
|
||||||
|
return new Date(date).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the get-file retrieval use case.
|
||||||
|
*
|
||||||
|
* Determines how a file should be delivered to the client:
|
||||||
|
* - **redirect**: For regular (non-chunked, non-archive) files — returns a
|
||||||
|
* Telegram CDN redirect URL.
|
||||||
|
* - **chunked**: For files stored across multiple Telegram parts — returns
|
||||||
|
* the file entity so the caller can build a multi-part streaming response.
|
||||||
|
* - **archive-entry**: For files stored inside a Telegram archive (zip) —
|
||||||
|
* returns the archive's Telegram metadata and the entry name so the caller
|
||||||
|
* can extract and stream the entry.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting a public ID and returning a retrieval result.
|
||||||
|
*/
|
||||||
|
export function createGetFileUseCase(deps: GetFileUseCaseDeps) {
|
||||||
|
return async (publicId: string): Promise<FileRetrievalResult | null> => {
|
||||||
|
const file = await deps.fileRepo.findByPublicId(publicId);
|
||||||
|
if (!file) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunked file — return the entity for multi-part response building
|
||||||
|
if (file.storageBackend === 'chunked') {
|
||||||
|
return { type: 'chunked', file };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive entry — resolve the archive's Telegram location
|
||||||
|
const archiveEntryName = file.archiveEntryName;
|
||||||
|
if (archiveEntryName) {
|
||||||
|
const archiveFileId = file.archiveTelegramFileId || file.telegramFileId;
|
||||||
|
const archiveInfo = await deps.telegramService.getFileInfo(archiveFileId);
|
||||||
|
return { type: 'archive-entry', file, archiveInfo, entryName: archiveEntryName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular file — resolve Telegram CDN path for a redirect
|
||||||
|
const fileInfo = await deps.telegramService.getFileInfo(file.telegramFileId);
|
||||||
|
const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
|
||||||
|
|
||||||
|
return { type: 'redirect', file, redirectUrl, fileInfo };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { Bucket } from '../../domain/entities/bucket';
|
||||||
|
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 bucket name validation regex.
|
||||||
|
*
|
||||||
|
* Bucket names must be 3-63 characters, start/end with a lowercase letter or
|
||||||
|
* digit, and contain only lowercase letters, digits, dots, and hyphens.
|
||||||
|
*/
|
||||||
|
const BUCKET_NAME_REGEX = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error type for bucket-level application errors that carry an S3-compatible
|
||||||
|
* error code and an HTTP status suggestion.
|
||||||
|
*/
|
||||||
|
export class BucketError extends Error {
|
||||||
|
/** S3-compatible error code (e.g. "NoSuchBucket", "BucketAlreadyExists"). */
|
||||||
|
readonly code: string;
|
||||||
|
/** Suggested HTTP status code. */
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param code - The S3 error code.
|
||||||
|
* @param message - Human-readable error description.
|
||||||
|
* @param status - Suggested HTTP status.
|
||||||
|
*/
|
||||||
|
constructor(code: string, message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BucketError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a bucket entry returned by the list-buckets use case,
|
||||||
|
* enriched with the current count of non-deleted objects.
|
||||||
|
*/
|
||||||
|
export interface BucketWithCount {
|
||||||
|
/** The bucket domain entity. */
|
||||||
|
bucket: Bucket;
|
||||||
|
/** Number of non-deleted objects in the bucket. */
|
||||||
|
objectCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the manage-bucket use case factories. */
|
||||||
|
export interface ManageBucketDeps {
|
||||||
|
/** Bucket repository for CRUD operations. */
|
||||||
|
bucketRepo: IBucketRepository;
|
||||||
|
/** File repository for counting and checking objects within buckets. */
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that lists all buckets together with their object counts.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function that returns a list of buckets with counts.
|
||||||
|
*/
|
||||||
|
export function createListBucketsUseCase(deps: ManageBucketDeps) {
|
||||||
|
return async (): Promise<BucketWithCount[]> => {
|
||||||
|
const buckets = await deps.bucketRepo.list();
|
||||||
|
const results = await Promise.all(
|
||||||
|
buckets.map(async (bucket) => ({
|
||||||
|
bucket,
|
||||||
|
objectCount: await deps.fileRepo.countByBucket(bucket.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that retrieves a single bucket by its name.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting a bucket name and returning the
|
||||||
|
* bucket, or `null` when not found.
|
||||||
|
*/
|
||||||
|
export function createGetBucketUseCase(deps: ManageBucketDeps) {
|
||||||
|
return async (name: string): Promise<Bucket | null> => {
|
||||||
|
return deps.bucketRepo.findByName(name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that creates a new bucket.
|
||||||
|
*
|
||||||
|
* Validates the bucket name format and checks for duplicates before
|
||||||
|
* persisting.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting a bucket name and returning the
|
||||||
|
* newly created bucket.
|
||||||
|
* @throws {BucketError} When the name is invalid or the bucket already exists.
|
||||||
|
*/
|
||||||
|
export function createCreateBucketUseCase(deps: ManageBucketDeps) {
|
||||||
|
return async (name: string): Promise<Bucket> => {
|
||||||
|
if (!BUCKET_NAME_REGEX.test(name)) {
|
||||||
|
throw new BucketError(
|
||||||
|
'InvalidBucketName',
|
||||||
|
'The specified bucket is not valid.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await deps.bucketRepo.findByName(name);
|
||||||
|
if (existing) {
|
||||||
|
throw new BucketError(
|
||||||
|
'BucketAlreadyExists',
|
||||||
|
'The requested bucket name is not available.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deps.bucketRepo.create(name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that deletes a bucket.
|
||||||
|
*
|
||||||
|
* Ensures the bucket exists and is empty (no non-deleted objects) before
|
||||||
|
* proceeding with deletion.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting a bucket name. Returns `true` when
|
||||||
|
* the bucket was deleted, throws when the bucket is missing or
|
||||||
|
* not empty.
|
||||||
|
* @throws {BucketError} When the bucket does not exist or is not empty.
|
||||||
|
*/
|
||||||
|
export function createDeleteBucketUseCase(deps: ManageBucketDeps) {
|
||||||
|
return async (name: string): Promise<boolean> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(name);
|
||||||
|
if (!bucket) {
|
||||||
|
throw new BucketError(
|
||||||
|
'NoSuchBucket',
|
||||||
|
'The specified bucket does not exist.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectCount = await deps.fileRepo.countByBucket(bucket.id);
|
||||||
|
if (objectCount > 0) {
|
||||||
|
throw new BucketError(
|
||||||
|
'BucketNotEmpty',
|
||||||
|
'The bucket you tried to delete is not empty.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deps.bucketRepo.delete(name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that checks whether a bucket exists.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting a bucket name and returning `true`
|
||||||
|
* when the bucket exists.
|
||||||
|
*/
|
||||||
|
export function createBucketExistsUseCase(deps: ManageBucketDeps) {
|
||||||
|
return async (name: string): Promise<boolean> => {
|
||||||
|
return deps.bucketRepo.exists(name);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { MultipartUpload } from '../../domain/entities/multipart';
|
||||||
|
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
|
||||||
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
|
import { computeHash } from '../../shared/utils/file';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single part reference as submitted in a complete-multipart-upload request.
|
||||||
|
*/
|
||||||
|
export interface CompletePartInput {
|
||||||
|
/** 1-based part number. */
|
||||||
|
partNumber: number;
|
||||||
|
/** ETag returned when the part was uploaded. */
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of initiating a multipart upload.
|
||||||
|
*/
|
||||||
|
export interface InitiateMultipartResult {
|
||||||
|
/** The generated upload identifier (nanoid). */
|
||||||
|
uploadId: string;
|
||||||
|
/** The bucket name. */
|
||||||
|
bucket: string;
|
||||||
|
/** The S3 object key. */
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of uploading a single part.
|
||||||
|
*/
|
||||||
|
export interface UploadPartResult {
|
||||||
|
/** ETag of the uploaded part (SHA-256 hex digest). */
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of completing a multipart upload.
|
||||||
|
*/
|
||||||
|
export interface CompleteMultipartResult {
|
||||||
|
/** Public-facing unique identifier of the created file record. */
|
||||||
|
publicId: string;
|
||||||
|
/** The S3 location URL of the completed object. */
|
||||||
|
location: string;
|
||||||
|
/** Combined ETag (all part etags joined by hyphens). */
|
||||||
|
etag: string;
|
||||||
|
/** Total object size in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of a single part within a multipart upload used in listing results.
|
||||||
|
*/
|
||||||
|
export interface PartSummary {
|
||||||
|
/** 1-based part number. */
|
||||||
|
partNumber: number;
|
||||||
|
/** ETag of the part content. */
|
||||||
|
etag: string;
|
||||||
|
/** Part size in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** ISO-8601 timestamp when the part was stored. */
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of listing multipart uploads within a bucket.
|
||||||
|
*/
|
||||||
|
export interface ListMultipartUploadsResult {
|
||||||
|
/** Array of in-progress upload summaries. */
|
||||||
|
uploads: MultipartUpload[];
|
||||||
|
/** Whether more results are available. */
|
||||||
|
isTruncated: boolean;
|
||||||
|
/** Marker for the next page, or null when not truncated. */
|
||||||
|
nextKeyMarker: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Subset of application configuration consumed by the multipart use cases. */
|
||||||
|
export interface MultipartConfig {
|
||||||
|
/** Maximum chunk size in bytes for Telegram uploads (part size limit). */
|
||||||
|
telegramChunkSizeBytes: number;
|
||||||
|
/** Telegram chat ID where part data is stored. */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Server base URL for constructing location URLs. */
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the multipart upload use case factories. */
|
||||||
|
export interface MultipartDeps {
|
||||||
|
/** Bucket repository for bucket lookups. */
|
||||||
|
bucketRepo: IBucketRepository;
|
||||||
|
/** File repository for creating the final file record on completion. */
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
/** Multipart repository for managing upload sessions and parts. */
|
||||||
|
multipartRepo: IMultipartRepository;
|
||||||
|
/** Telegram service for forwarding part data to storage. */
|
||||||
|
telegramService: ITelegramService;
|
||||||
|
/** Application configuration subset. */
|
||||||
|
config: MultipartConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Use Case Factories ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that initiates an S3 multipart upload.
|
||||||
|
*
|
||||||
|
* Validates the bucket exists and creates a new multipart upload session.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting bucket name and object key, returning
|
||||||
|
* the upload initiation result, or `null` when the bucket is not found.
|
||||||
|
*/
|
||||||
|
export function createInitiateMultipartUploadUseCase(deps: MultipartDeps) {
|
||||||
|
return async (
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<InitiateMultipartResult | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
const uploadId = await deps.multipartRepo.create(bucket.id, key, 's3');
|
||||||
|
|
||||||
|
return { uploadId, bucket: bucketName, key };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that uploads a single part of a multipart upload.
|
||||||
|
*
|
||||||
|
* Validates the part number range (1-10000), checks the upload session exists
|
||||||
|
* and matches the expected key, checks part size against the configured limit,
|
||||||
|
* forwards the part data to Telegram storage, and persists the part record.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting upload details and part data, returning
|
||||||
|
* the part ETag, or `null` when the upload session is not found.
|
||||||
|
*/
|
||||||
|
export function createUploadPartUseCase(deps: MultipartDeps) {
|
||||||
|
return async (input: {
|
||||||
|
/** Bucket name for the multipart upload. */
|
||||||
|
bucketName: string;
|
||||||
|
/** S3 object key for the multipart upload. */
|
||||||
|
key: string;
|
||||||
|
/** Upload identifier returned by initiate. */
|
||||||
|
uploadId: string;
|
||||||
|
/** 1-based part number (1-10000). */
|
||||||
|
partNumber: number;
|
||||||
|
/** Raw part data. */
|
||||||
|
body: Buffer;
|
||||||
|
}): Promise<UploadPartResult | null> => {
|
||||||
|
if (input.partNumber < 1 || input.partNumber > 10000) {
|
||||||
|
throw new MultipartError(
|
||||||
|
'InvalidArgument',
|
||||||
|
'Part number must be an integer between 1 and 10000',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const multipart = await deps.multipartRepo.findById(input.uploadId);
|
||||||
|
if (!multipart || multipart.s3Key !== input.key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.body.byteLength > deps.config.telegramChunkSizeBytes) {
|
||||||
|
throw new MultipartError(
|
||||||
|
'EntityTooLarge',
|
||||||
|
`Your proposed upload part size (${input.body.byteLength} bytes) exceeds the maximum allowed part size (${deps.config.telegramChunkSizeBytes} bytes) for this storage backend. Use smaller part sizes.`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardResult = await deps.telegramService.forwardToStorage(
|
||||||
|
input.body,
|
||||||
|
`mp-${input.uploadId}-part-${input.partNumber}`,
|
||||||
|
'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
const etag = computeHash(input.body);
|
||||||
|
|
||||||
|
await deps.multipartRepo.insertPart({
|
||||||
|
uploadId: input.uploadId,
|
||||||
|
partNumber: input.partNumber,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
sizeBytes: input.body.byteLength,
|
||||||
|
etag,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { etag: `"${etag}"` };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error type for multipart-level application errors.
|
||||||
|
*/
|
||||||
|
export class MultipartError extends Error {
|
||||||
|
/** S3-compatible error code. */
|
||||||
|
readonly code: string;
|
||||||
|
/** Suggested HTTP status code. */
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param code - The S3 error code.
|
||||||
|
* @param message - Human-readable error description.
|
||||||
|
* @param status - Suggested HTTP status.
|
||||||
|
*/
|
||||||
|
constructor(code: string, message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'MultipartError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that completes an S3 multipart upload.
|
||||||
|
*
|
||||||
|
* Validates the submitted part list (all parts must be present and in ascending
|
||||||
|
* order), creates the final file record referencing the first part's Telegram
|
||||||
|
* data, marks the upload session as completed, and returns the combined result.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting upload details and submitted parts,
|
||||||
|
* returning the completion result, or `null` when the upload session
|
||||||
|
* is not found.
|
||||||
|
*/
|
||||||
|
export function createCompleteMultipartUploadUseCase(deps: MultipartDeps) {
|
||||||
|
return async (input: {
|
||||||
|
/** Bucket name for the multipart upload. */
|
||||||
|
bucketName: string;
|
||||||
|
/** S3 object key for the multipart upload. */
|
||||||
|
key: string;
|
||||||
|
/** Upload identifier. */
|
||||||
|
uploadId: string;
|
||||||
|
/** Parts submitted by the client (in ascending part number order). */
|
||||||
|
parts: CompletePartInput[];
|
||||||
|
}): Promise<CompleteMultipartResult | null> => {
|
||||||
|
const multipart = await deps.multipartRepo.findById(input.uploadId);
|
||||||
|
if (!multipart) return null;
|
||||||
|
|
||||||
|
const storedParts = await deps.multipartRepo.listParts(input.uploadId);
|
||||||
|
|
||||||
|
// Validate ascending part order
|
||||||
|
const partNumbers = input.parts.map((p) => p.partNumber);
|
||||||
|
if (partNumbers.length > 1 && partNumbers.some((n, i) => i > 0 && n <= partNumbers[i - 1])) {
|
||||||
|
throw new MultipartError(
|
||||||
|
'InvalidPartOrder',
|
||||||
|
'The list of parts was not in ascending order.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate part count matches
|
||||||
|
if (input.parts.length !== storedParts.length) {
|
||||||
|
throw new MultipartError(
|
||||||
|
'InvalidPart',
|
||||||
|
'One or more specified parts could not be found.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = storedParts.reduce((sum, p) => sum + p.sizeBytes, 0);
|
||||||
|
const firstPart = storedParts[0];
|
||||||
|
if (!firstPart) {
|
||||||
|
throw new MultipartError('InternalError', 'Multipart object has no parts.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
await deps.fileRepo.create({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: firstPart.telegramFileId,
|
||||||
|
telegramFileUniqueId: firstPart.telegramFileUniqueId,
|
||||||
|
storageChatId: deps.config.storageChatId,
|
||||||
|
storageMessageId: firstPart.storageMessageId,
|
||||||
|
fileName: input.key.split('/').pop() || 'file',
|
||||||
|
mimeType: 'application/octet-stream',
|
||||||
|
sizeBytes: totalSize,
|
||||||
|
fileType: 'document',
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: null,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: multipart.bucketId,
|
||||||
|
s3Key: input.key,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: input.uploadId,
|
||||||
|
partCount: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deps.multipartRepo.complete(input.uploadId);
|
||||||
|
|
||||||
|
const location = `${deps.config.baseUrl}/${input.bucketName}/${input.key}`;
|
||||||
|
const combinedEtag = storedParts.map((p) => p.etag).join('-');
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicId,
|
||||||
|
location,
|
||||||
|
etag: combinedEtag,
|
||||||
|
sizeBytes: totalSize,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that aborts an S3 multipart upload.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting an upload identifier, returning `true`
|
||||||
|
* when the upload was aborted, or `null` when the upload session
|
||||||
|
* is not found.
|
||||||
|
*/
|
||||||
|
export function createAbortMultipartUploadUseCase(deps: MultipartDeps) {
|
||||||
|
return async (uploadId: string): Promise<boolean | null> => {
|
||||||
|
const multipart = await deps.multipartRepo.findById(uploadId);
|
||||||
|
if (!multipart) return null;
|
||||||
|
|
||||||
|
await deps.multipartRepo.abort(uploadId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that lists in-progress multipart uploads within a bucket.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting query parameters and returning the
|
||||||
|
* listing result, or `null` when the bucket is not found.
|
||||||
|
*/
|
||||||
|
export function createListMultipartUploadsUseCase(deps: MultipartDeps) {
|
||||||
|
return async (input: {
|
||||||
|
/** Bucket name to list uploads from. */
|
||||||
|
bucketName: string;
|
||||||
|
/** Maximum number of uploads to return (clamped 1-1000). */
|
||||||
|
maxUploads: number;
|
||||||
|
/** Return only uploads whose S3 key is strictly greater than this, or null. */
|
||||||
|
keyMarker: string | null;
|
||||||
|
}): Promise<ListMultipartUploadsResult | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(input.bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
return deps.multipartRepo.listByBucket(bucket.id, input.maxUploads, input.keyMarker);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that lists parts of a specific multipart upload.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting an upload identifier and returning the
|
||||||
|
* list of parts, or `null` when the upload session is not found.
|
||||||
|
*/
|
||||||
|
export function createListPartsUseCase(deps: MultipartDeps) {
|
||||||
|
return async (uploadId: string): Promise<PartSummary[] | null> => {
|
||||||
|
const multipart = await deps.multipartRepo.findById(uploadId);
|
||||||
|
if (!multipart) return null;
|
||||||
|
|
||||||
|
const parts = await deps.multipartRepo.listParts(uploadId);
|
||||||
|
|
||||||
|
return parts.map((p) => ({
|
||||||
|
partNumber: p.partNumber,
|
||||||
|
etag: p.etag,
|
||||||
|
sizeBytes: p.sizeBytes,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { File, NewFile } from '../../domain/entities/file';
|
||||||
|
import type { NewFilePart } from '../../domain/entities/file-part';
|
||||||
|
import type { MultipartPart } from '../../domain/entities/multipart';
|
||||||
|
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
|
||||||
|
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
||||||
|
import type { IFileRepository, S3FileRecord } from '../../domain/ports/file-repository';
|
||||||
|
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
|
||||||
|
import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
||||||
|
import { ensureExtension, computeHash, formatCreatedAt } from '../../shared/utils/file';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compression algorithm used for chunked object storage.
|
||||||
|
*/
|
||||||
|
type CompressionAlgorithm = 'gzip' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single part source for building a multi-part streaming response.
|
||||||
|
* Each part corresponds to a Telegram-stored file chunk.
|
||||||
|
*/
|
||||||
|
export interface ObjectPartSource {
|
||||||
|
/** Telegram file identifier for retrieving this part. */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram CDN URL for downloading this part. */
|
||||||
|
telegramUrl: string;
|
||||||
|
/** Original size of this part in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** 1-based part number within the object. */
|
||||||
|
partNumber: number;
|
||||||
|
/** Stored (post-compression) size in bytes, when applicable. */
|
||||||
|
storedSizeBytes?: number;
|
||||||
|
/** Compression algorithm applied, or null if uncompressed. */
|
||||||
|
compressionAlgorithm?: CompressionAlgorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A regular (direct) S3 object resolved to a Telegram CDN URL.
|
||||||
|
*/
|
||||||
|
export interface DirectObjectResult {
|
||||||
|
/** Discriminant. */
|
||||||
|
type: 'direct';
|
||||||
|
/** The resolved file entity. */
|
||||||
|
file: File;
|
||||||
|
/** Full Telegram CDN URL for downloading the object. */
|
||||||
|
telegramUrl: string;
|
||||||
|
/** Telegram file metadata. */
|
||||||
|
fileInfo: TelegramFileInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chunked S3 object stored across multiple Telegram file parts.
|
||||||
|
*/
|
||||||
|
export interface ChunkedObjectResult {
|
||||||
|
/** Discriminant. */
|
||||||
|
type: 'chunked';
|
||||||
|
/** The resolved file entity. */
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An S3 object assembled from a completed multipart upload.
|
||||||
|
*/
|
||||||
|
export interface MultipartObjectResult {
|
||||||
|
/** Discriminant. */
|
||||||
|
type: 'multipart';
|
||||||
|
/** The resolved file entity. */
|
||||||
|
file: File;
|
||||||
|
/** Resolved part sources with Telegram CDN URLs. */
|
||||||
|
parts: ObjectPartSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of all possible S3 get-object outcomes.
|
||||||
|
*/
|
||||||
|
export type GetObjectResult = DirectObjectResult | ChunkedObjectResult | MultipartObjectResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of an S3 put-object operation.
|
||||||
|
*/
|
||||||
|
export interface PutObjectResult {
|
||||||
|
/** SHA-256 hex digest of the object content. */
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of an S3 copy-object operation.
|
||||||
|
*/
|
||||||
|
export interface CopyObjectResult {
|
||||||
|
/** SHA-256 hex digest of the source object content. */
|
||||||
|
etag: string;
|
||||||
|
/** ISO-8601 timestamp of the copy operation. */
|
||||||
|
lastModified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single S3 object as returned in listing results.
|
||||||
|
*/
|
||||||
|
export interface ListObjectEntry {
|
||||||
|
/** The object key (full path within the bucket). */
|
||||||
|
key: string;
|
||||||
|
/** Object size in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** SHA-256 hex digest or fallback identifier. */
|
||||||
|
etag: string;
|
||||||
|
/** ISO-8601 timestamp of last modification. */
|
||||||
|
lastModified: string;
|
||||||
|
/** MIME type of the stored object. */
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of an S3 list-objects operation (both V1 and V2).
|
||||||
|
*/
|
||||||
|
export interface ListObjectsResult {
|
||||||
|
/** Array of object summaries. */
|
||||||
|
objects: ListObjectEntry[];
|
||||||
|
/** Common prefixes when a delimiter was used. */
|
||||||
|
prefixes: string[];
|
||||||
|
/** Whether more results are available. */
|
||||||
|
isTruncated: boolean;
|
||||||
|
/** The last key in the returned page, for use as the next marker. */
|
||||||
|
nextMarker: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Head-object metadata.
|
||||||
|
*/
|
||||||
|
export interface HeadObjectMetadata {
|
||||||
|
/** MIME type of the object. */
|
||||||
|
contentType: string;
|
||||||
|
/** Object size in bytes. */
|
||||||
|
contentLength: number;
|
||||||
|
/** Entity tag (SHA-256 hex digest or fallback). */
|
||||||
|
etag: string;
|
||||||
|
/** ISO-8601 timestamp of last modification. */
|
||||||
|
lastModified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Subset of application configuration consumed by the s3-object use cases. */
|
||||||
|
export interface S3ObjectConfig {
|
||||||
|
/** Maximum chunk size in bytes for Telegram chunked uploads. */
|
||||||
|
telegramChunkSizeBytes: number;
|
||||||
|
/** Whether gzip compression is enabled for chunked uploads. */
|
||||||
|
compressChunkedUploads: boolean;
|
||||||
|
/** Minimum chunk size in bytes below which compression is skipped. */
|
||||||
|
chunkCompressionMinSizeBytes: number;
|
||||||
|
/** Telegram chat ID where file parts are stored. */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Server base URL for constructing download links. */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Whether to proxy S3 GET requests through the server. */
|
||||||
|
proxyS3Get: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the s3-object use case factories. */
|
||||||
|
export interface S3ObjectDeps {
|
||||||
|
/** Bucket repository for bucket lookups. */
|
||||||
|
bucketRepo: IBucketRepository;
|
||||||
|
/** File repository for object CRUD operations. */
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
/** File-part repository for chunked upload part records. */
|
||||||
|
filePartRepo: IFilePartRepository;
|
||||||
|
/** Multipart repository for resolving multipart-upload objects. */
|
||||||
|
multipartRepo: IMultipartRepository;
|
||||||
|
/** Telegram service for uploading and resolving file metadata. */
|
||||||
|
telegramService: ITelegramService;
|
||||||
|
/** Application configuration subset. */
|
||||||
|
config: S3ObjectConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the configured chunk size and returns it as a safe integer.
|
||||||
|
*
|
||||||
|
* @param chunkSizeBytes - The configured chunk size in bytes.
|
||||||
|
* @returns The same value if it is a positive safe integer.
|
||||||
|
*/
|
||||||
|
const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
||||||
|
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
||||||
|
throw new Error('Invalid Telegram chunk size');
|
||||||
|
}
|
||||||
|
return chunkSizeBytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally gzip-compresses a chunk if compression is enabled and the chunk
|
||||||
|
* is large enough to benefit from it.
|
||||||
|
*
|
||||||
|
* @param chunk - The raw chunk buffer.
|
||||||
|
* @param compress - Whether compression is enabled.
|
||||||
|
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
||||||
|
* @returns The (possibly compressed) buffer and the algorithm used.
|
||||||
|
*/
|
||||||
|
const maybeCompressChunk = (
|
||||||
|
chunk: Buffer,
|
||||||
|
compress: boolean,
|
||||||
|
compressionMinSizeBytes: number,
|
||||||
|
): { bytes: Buffer; compressionAlgorithm: CompressionAlgorithm } => {
|
||||||
|
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gzipped = gzipSync(chunk);
|
||||||
|
if (gzipped.byteLength >= chunk.byteLength) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for a single uploaded chunk/part during S3 put-object.
|
||||||
|
*/
|
||||||
|
interface UploadedPart {
|
||||||
|
/** 1-based part number. */
|
||||||
|
partNumber: number;
|
||||||
|
/** Telegram file identifier for this part. */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram unique file identifier (stable across bot tokens). */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** Message ID within the storage chat. */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Original size of the chunk in bytes before compression. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Stored (post-compression) size in bytes. */
|
||||||
|
storedSizeBytes: number;
|
||||||
|
/** Compression algorithm applied, or null if uncompressed. */
|
||||||
|
compressionAlgorithm: CompressionAlgorithm;
|
||||||
|
/** SHA-256 hash of the original chunk content. */
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of uploading an object in multiple Telegram chunks. */
|
||||||
|
interface ChunkedUploadResult {
|
||||||
|
/** Metadata for each uploaded part. */
|
||||||
|
parts: UploadedPart[];
|
||||||
|
/** SHA-256 hex digest of the complete object content. */
|
||||||
|
fileHash: string;
|
||||||
|
/** Total object size in bytes (sum of all original chunks). */
|
||||||
|
totalSizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a buffer to Telegram in chunks, returning metadata for all parts.
|
||||||
|
*
|
||||||
|
* @param buffer - The full object buffer.
|
||||||
|
* @param partFileNamePrefix - Prefix used for each chunk's file name in Telegram.
|
||||||
|
* @param chunkSizeBytes - Maximum size of each chunk in bytes.
|
||||||
|
* @param compress - Whether gzip compression is enabled.
|
||||||
|
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
||||||
|
* @param telegramService - The Telegram service to forward each chunk.
|
||||||
|
* @returns The aggregated chunked upload result.
|
||||||
|
*/
|
||||||
|
const uploadInChunks = async (
|
||||||
|
buffer: Buffer,
|
||||||
|
partFileNamePrefix: string,
|
||||||
|
chunkSizeBytes: number,
|
||||||
|
compress: boolean,
|
||||||
|
compressionMinSizeBytes: number,
|
||||||
|
telegramService: ITelegramService,
|
||||||
|
): Promise<ChunkedUploadResult> => {
|
||||||
|
const safeChunkSize = asSafeChunkSize(chunkSizeBytes);
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
const parts: UploadedPart[] = [];
|
||||||
|
let totalSizeBytes = 0;
|
||||||
|
let partNumber = 0;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset < buffer.byteLength) {
|
||||||
|
const chunk = buffer.subarray(offset, offset + safeChunkSize);
|
||||||
|
if (chunk.byteLength === 0) break;
|
||||||
|
|
||||||
|
partNumber += 1;
|
||||||
|
totalSizeBytes += chunk.byteLength;
|
||||||
|
hasher.update(chunk);
|
||||||
|
|
||||||
|
const { bytes, compressionAlgorithm } = maybeCompressChunk(
|
||||||
|
chunk,
|
||||||
|
compress,
|
||||||
|
compressionMinSizeBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const forwardResult = await telegramService.forwardToStorage(
|
||||||
|
bytes,
|
||||||
|
`${partFileNamePrefix}.part-${partNumber}`,
|
||||||
|
'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
partNumber,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
sizeBytes: chunk.byteLength,
|
||||||
|
storedSizeBytes: bytes.byteLength,
|
||||||
|
compressionAlgorithm,
|
||||||
|
etag: computeHash(chunk),
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += safeChunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parts,
|
||||||
|
fileHash: hasher.digest('hex'),
|
||||||
|
totalSizeBytes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a list of multipart parts to their Telegram CDN URLs.
|
||||||
|
*
|
||||||
|
* @param parts - The stored multipart parts.
|
||||||
|
* @param telegramService - The Telegram service for resolving file metadata.
|
||||||
|
* @returns An array of resolved part sources.
|
||||||
|
*/
|
||||||
|
const resolveMultipartParts = async (
|
||||||
|
parts: MultipartPart[],
|
||||||
|
telegramService: ITelegramService,
|
||||||
|
): Promise<ObjectPartSource[]> => {
|
||||||
|
const sources: ObjectPartSource[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const fileInfo = await telegramService.getFileInfo(part.telegramFileId);
|
||||||
|
sources.push({
|
||||||
|
telegramFileId: part.telegramFileId,
|
||||||
|
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
|
||||||
|
sizeBytes: part.sizeBytes,
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a `createdAt` value into an HTTP Last-Modified header value.
|
||||||
|
*
|
||||||
|
* @param date - The date to format.
|
||||||
|
* @returns The UTC string representation.
|
||||||
|
*/
|
||||||
|
const formatLastModified = (date: Date | string | number): string => {
|
||||||
|
return date instanceof Date ? date.toUTCString() : new Date(date).toUTCString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Use Case Factories ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that resolves an S3 object for retrieval (GET).
|
||||||
|
*
|
||||||
|
* Looks up the bucket and file by key, then determines the storage type:
|
||||||
|
* - **direct**: regular Telegram-stored object — resolves the Telegram CDN URL.
|
||||||
|
* - **chunked**: object stored across multiple Telegram file parts.
|
||||||
|
* - **multipart**: object assembled from a completed multipart upload — resolves
|
||||||
|
* the Telegram CDN URLs for each part.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting bucket name and object key, returning
|
||||||
|
* a discriminated union of possible results, or `null` when the
|
||||||
|
* bucket or file is not found.
|
||||||
|
*/
|
||||||
|
export function createGetObjectUseCase(deps: S3ObjectDeps) {
|
||||||
|
return async (bucketName: string, key: string): Promise<GetObjectResult | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
const file = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
// Chunked storage — return the entity; the caller resolves parts via
|
||||||
|
// chunked-storage helpers.
|
||||||
|
if (file.storageBackend === 'chunked') {
|
||||||
|
return { type: 'chunked', file };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multipart upload object — resolve part Telegram URLs
|
||||||
|
if (file.multipartUploadId) {
|
||||||
|
const parts = await deps.multipartRepo.listParts(file.multipartUploadId);
|
||||||
|
const resolvedParts = await resolveMultipartParts(parts, deps.telegramService);
|
||||||
|
return { type: 'multipart', file, parts: resolvedParts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular direct object — resolve Telegram CDN URL
|
||||||
|
const fileInfo = await deps.telegramService.getFileInfo(file.telegramFileId);
|
||||||
|
const telegramUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
|
||||||
|
|
||||||
|
return { type: 'direct', file, telegramUrl, fileInfo };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that retrieves S3 object metadata (HEAD).
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting bucket name and object key, returning
|
||||||
|
* metadata or `null` when the bucket or file is not found.
|
||||||
|
*/
|
||||||
|
export function createHeadObjectUseCase(deps: S3ObjectDeps) {
|
||||||
|
return async (bucketName: string, key: string): Promise<HeadObjectMetadata | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
const file = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: file.mimeType,
|
||||||
|
contentLength: file.sizeBytes,
|
||||||
|
etag: file.fileHash || nanoid(16),
|
||||||
|
lastModified: formatLastModified(file.createdAt),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that stores an S3 object (PUT).
|
||||||
|
*
|
||||||
|
* Handles both chunked (large) and single-message (small) upload paths,
|
||||||
|
* deduplicates by bucket+key (idempotent PUT), and persists the file
|
||||||
|
* record and (for chunked storage) part records.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting bucket name, key, body buffer, and
|
||||||
|
* content type, returning the etag of the stored object. Returns
|
||||||
|
* `null` when the bucket is not found.
|
||||||
|
*/
|
||||||
|
export function createPutObjectUseCase(deps: S3ObjectDeps) {
|
||||||
|
return async (
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
body: Buffer,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<PutObjectResult | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
const hash = computeHash(body);
|
||||||
|
|
||||||
|
// Idempotent PUT: if the object already exists, skip upload
|
||||||
|
const existing = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
|
||||||
|
if (existing) {
|
||||||
|
return { etag: `"${hash}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = key.split('/').pop() || 'file';
|
||||||
|
const signatureBuffer = body.subarray(0, 16);
|
||||||
|
const { fileName: finalFileName, mimeType } = ensureExtension(
|
||||||
|
fileName,
|
||||||
|
signatureBuffer,
|
||||||
|
contentType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
|
||||||
|
const { telegramChunkSizeBytes, compressChunkedUploads, chunkCompressionMinSizeBytes, storageChatId } = deps.config;
|
||||||
|
|
||||||
|
if (body.byteLength > telegramChunkSizeBytes) {
|
||||||
|
// Chunked upload path
|
||||||
|
const chunkResult = await uploadInChunks(
|
||||||
|
body,
|
||||||
|
partFileNamePrefix,
|
||||||
|
telegramChunkSizeBytes,
|
||||||
|
compressChunkedUploads,
|
||||||
|
chunkCompressionMinSizeBytes,
|
||||||
|
deps.telegramService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstPart = chunkResult.parts[0];
|
||||||
|
if (!firstPart) {
|
||||||
|
throw new Error('Chunked upload produced no parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = randomUUID();
|
||||||
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
await deps.fileRepo.create({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: firstPart.telegramFileId,
|
||||||
|
telegramFileUniqueId: firstPart.telegramFileUniqueId,
|
||||||
|
storageChatId,
|
||||||
|
storageMessageId: firstPart.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: chunkResult.totalSizeBytes,
|
||||||
|
fileType: 'document',
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: chunkResult.fileHash,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: bucket.id,
|
||||||
|
s3Key: key,
|
||||||
|
storageBackend: 'chunked',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: chunkResult.parts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileParts: NewFilePart[] = chunkResult.parts.map((part) => ({
|
||||||
|
fileId,
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
telegramFileId: part.telegramFileId,
|
||||||
|
telegramFileUniqueId: part.telegramFileUniqueId,
|
||||||
|
storageChatId,
|
||||||
|
storageMessageId: part.storageMessageId,
|
||||||
|
sizeBytes: part.sizeBytes,
|
||||||
|
storedSizeBytes: part.storedSizeBytes,
|
||||||
|
compressionAlgorithm: part.compressionAlgorithm,
|
||||||
|
etag: part.etag,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await deps.filePartRepo.insert(fileParts);
|
||||||
|
|
||||||
|
return { etag: `"${chunkResult.fileHash}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-message upload path
|
||||||
|
const forwardResult = await deps.telegramService.forwardToStorage(
|
||||||
|
body,
|
||||||
|
partFileNamePrefix,
|
||||||
|
'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
await deps.fileRepo.create({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageChatId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: body.byteLength,
|
||||||
|
fileType: 'document',
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: hash,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: bucket.id,
|
||||||
|
s3Key: key,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { etag: `"${hash}"` };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that copies an S3 object to a new key (PUT with
|
||||||
|
* x-amz-copy-source).
|
||||||
|
*
|
||||||
|
* Creates a new file record referencing the same Telegram-stored data.
|
||||||
|
* Chunked source objects are not supported for copy.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting source + destination identifiers and
|
||||||
|
* optional precondition headers, returning the copy result or
|
||||||
|
* `null` when a required bucket or file is not found.
|
||||||
|
*/
|
||||||
|
export function createCopyObjectUseCase(deps: S3ObjectDeps) {
|
||||||
|
return async (input: {
|
||||||
|
/** Source bucket name. */
|
||||||
|
sourceBucket: string;
|
||||||
|
/** Source object key. */
|
||||||
|
sourceKey: string;
|
||||||
|
/** Destination bucket UUID (must already exist). */
|
||||||
|
destBucketId: string;
|
||||||
|
/** Destination object key. */
|
||||||
|
destKey: string;
|
||||||
|
/** Optional if-match precondition (raw etag value, without surrounding quotes). */
|
||||||
|
ifMatch?: string | null;
|
||||||
|
/** Optional if-none-match precondition (raw etag value, without surrounding quotes). */
|
||||||
|
ifNoneMatch?: string | null;
|
||||||
|
}): Promise<CopyObjectResult | null> => {
|
||||||
|
const sourceBucket = await deps.bucketRepo.findByName(input.sourceBucket);
|
||||||
|
if (!sourceBucket) return null;
|
||||||
|
|
||||||
|
const sourceFile = await deps.fileRepo.findByBucketAndKey(sourceBucket.id, input.sourceKey);
|
||||||
|
if (!sourceFile) return null;
|
||||||
|
|
||||||
|
if (sourceFile.storageBackend === 'chunked') {
|
||||||
|
throw new ObjectError('NotImplemented', 'Copying chunked objects is not yet implemented.', 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional copy: if-match / if-none-match checks
|
||||||
|
const sourceEtag = sourceFile.fileHash;
|
||||||
|
if (input.ifMatch && sourceEtag && input.ifMatch !== sourceEtag) {
|
||||||
|
throw new ObjectError('PreconditionFailed', 'The preconditions you specified did not hold.', 412);
|
||||||
|
}
|
||||||
|
if (input.ifNoneMatch && sourceEtag && input.ifNoneMatch === sourceEtag) {
|
||||||
|
throw new ObjectError('PreconditionFailed', 'The preconditions you specified did not hold.', 412);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
await deps.fileRepo.create({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: sourceFile.telegramFileId,
|
||||||
|
telegramFileUniqueId: sourceFile.telegramFileUniqueId,
|
||||||
|
storageChatId: sourceFile.storageChatId,
|
||||||
|
storageMessageId: sourceFile.storageMessageId,
|
||||||
|
fileName: sourceFile.fileName,
|
||||||
|
mimeType: sourceFile.mimeType,
|
||||||
|
sizeBytes: sourceFile.sizeBytes,
|
||||||
|
fileType: sourceFile.fileType,
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: sourceFile.fileHash,
|
||||||
|
archiveTelegramFileId: sourceFile.archiveTelegramFileId,
|
||||||
|
archiveStorageMessageId: sourceFile.archiveStorageMessageId,
|
||||||
|
archiveFileName: sourceFile.archiveFileName,
|
||||||
|
archiveEntryName: sourceFile.archiveEntryName,
|
||||||
|
archiveMimeType: sourceFile.archiveMimeType,
|
||||||
|
archiveSizeBytes: sourceFile.archiveSizeBytes,
|
||||||
|
bucketId: input.destBucketId,
|
||||||
|
s3Key: input.destKey,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
etag: sourceEtag || nanoid(16),
|
||||||
|
lastModified: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error type for S3 object-level application errors.
|
||||||
|
*/
|
||||||
|
export class ObjectError extends Error {
|
||||||
|
/** S3-compatible error code. */
|
||||||
|
readonly code: string;
|
||||||
|
/** Suggested HTTP status code. */
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param code - The S3 error code.
|
||||||
|
* @param message - Human-readable error description.
|
||||||
|
* @param status - Suggested HTTP status.
|
||||||
|
*/
|
||||||
|
constructor(code: string, message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ObjectError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that soft-deletes an S3 object (DELETE).
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting bucket name and object key, returning
|
||||||
|
* `true` if a row was soft-deleted. Returns `null` when the bucket
|
||||||
|
* is not found.
|
||||||
|
*/
|
||||||
|
export function createDeleteObjectUseCase(deps: S3ObjectDeps) {
|
||||||
|
return async (bucketName: string, key: string): Promise<boolean | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
return deps.fileRepo.softDelete(bucket.id, key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that batch-deletes multiple S3 objects (POST with
|
||||||
|
* ?delete).
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting bucket name and an array of keys,
|
||||||
|
* returning the array of keys that were actually deleted. Returns
|
||||||
|
* `null` when the bucket is not found.
|
||||||
|
*/
|
||||||
|
export function createDeleteObjectsUseCase(deps: S3ObjectDeps) {
|
||||||
|
return async (bucketName: string, keys: string[]): Promise<string[] | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
const deletedKeys: string[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const ok = await deps.fileRepo.softDelete(bucket.id, key);
|
||||||
|
if (ok) deletedKeys.push(key);
|
||||||
|
}
|
||||||
|
return deletedKeys;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that lists objects within a bucket (ListObjectsV1/V2).
|
||||||
|
*
|
||||||
|
* Supports prefix filtering, delimiter-based pseudo-directory grouping, and
|
||||||
|
* pagination via marker/startAfter.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting query parameters and returning the
|
||||||
|
* listing result, or `null` when the bucket is not found.
|
||||||
|
*/
|
||||||
|
export function createListObjectsUseCase(deps: S3ObjectDeps) {
|
||||||
|
return async (input: {
|
||||||
|
/** Bucket name to list from. */
|
||||||
|
bucketName: string;
|
||||||
|
/** Key prefix to filter by (empty string for no filter). */
|
||||||
|
prefix: string;
|
||||||
|
/** Delimiter character (e.g. "/") or null for flat listing. */
|
||||||
|
delimiter: string | null;
|
||||||
|
/** Maximum number of object records to return (clamped to 1000). */
|
||||||
|
maxKeys: number;
|
||||||
|
/** Return only keys strictly greater than this value, or null. */
|
||||||
|
startAfter: string | null;
|
||||||
|
}): Promise<ListObjectsResult | null> => {
|
||||||
|
const bucket = await deps.bucketRepo.findByName(input.bucketName);
|
||||||
|
if (!bucket) return null;
|
||||||
|
|
||||||
|
const clampedMaxKeys = Math.min(input.maxKeys, 1000);
|
||||||
|
|
||||||
|
const { objects, prefixes } = await deps.fileRepo.listByPrefix(
|
||||||
|
bucket.id,
|
||||||
|
input.prefix,
|
||||||
|
input.delimiter,
|
||||||
|
clampedMaxKeys,
|
||||||
|
input.startAfter,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isTruncated = objects.length > clampedMaxKeys;
|
||||||
|
const displayObjects = objects.slice(0, clampedMaxKeys);
|
||||||
|
const nextMarker = isTruncated
|
||||||
|
? (displayObjects[displayObjects.length - 1]?.s3Key ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects: displayObjects.map((o: S3FileRecord) => ({
|
||||||
|
key: o.s3Key,
|
||||||
|
sizeBytes: o.sizeBytes,
|
||||||
|
etag: o.fileHash || nanoid(16),
|
||||||
|
lastModified: formatCreatedAt(o.createdAt),
|
||||||
|
mimeType: o.mimeType,
|
||||||
|
})),
|
||||||
|
prefixes,
|
||||||
|
isTruncated,
|
||||||
|
nextMarker,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a use case that checks whether an object exists and is accessible
|
||||||
|
* within a bucket.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting a bucket ID and object key,
|
||||||
|
* returning the file entity or null.
|
||||||
|
*/
|
||||||
|
export function createFindObjectUseCase(deps: Pick<S3ObjectDeps, 'fileRepo'>) {
|
||||||
|
return async (bucketId: string, key: string): Promise<File | null> => {
|
||||||
|
return deps.fileRepo.findByBucketAndKey(bucketId, key);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { open } from 'node:fs/promises';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { 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 type { UploadInput, UploadOutput } from '../dto/upload';
|
||||||
|
import { getFileType, checkFileSize, ensureExtension, computeHash, formatCreatedAt } from '../../shared/utils/file';
|
||||||
|
|
||||||
|
/** Compression algorithm string literal used in chunked storage. */
|
||||||
|
type ChunkCompressionAlgorithm = 'gzip' | null;
|
||||||
|
|
||||||
|
/** Metadata for a single uploaded chunk/part. */
|
||||||
|
interface UploadedPart {
|
||||||
|
/** 1-based part number. */
|
||||||
|
partNumber: number;
|
||||||
|
/** Telegram file identifier for this part. */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram unique file identifier (stable across bot tokens). */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** Message ID within the storage chat. */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Original size of the chunk in bytes before compression. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Stored (post-compression) size in bytes. */
|
||||||
|
storedSizeBytes: number;
|
||||||
|
/** Compression algorithm applied, or null if uncompressed. */
|
||||||
|
compressionAlgorithm: ChunkCompressionAlgorithm;
|
||||||
|
/** SHA-256 hash of the original chunk content. */
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of uploading a file in multiple Telegram chunks. */
|
||||||
|
interface ChunkedUploadResult {
|
||||||
|
/** Metadata for each uploaded part. */
|
||||||
|
parts: UploadedPart[];
|
||||||
|
/** SHA-256 hex digest of the complete file content. */
|
||||||
|
fileHash: string;
|
||||||
|
/** Total file size in bytes (sum of all original chunks). */
|
||||||
|
totalSizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subset of application configuration consumed by the upload-file use case. */
|
||||||
|
export interface UploadFileConfig {
|
||||||
|
/** Server base URL for constructing download links. */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Maximum chunk size in bytes for Telegram chunked uploads. */
|
||||||
|
telegramChunkSizeBytes: number;
|
||||||
|
/** Telegram chat ID where file parts are stored. */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Whether to attempt gzip compression on each chunk. */
|
||||||
|
compressChunkedUploads: boolean;
|
||||||
|
/** Minimum chunk size in bytes below which compression is skipped. */
|
||||||
|
chunkCompressionMinSizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies required by the upload-file use case factory. */
|
||||||
|
export interface UploadFileUseCaseDeps {
|
||||||
|
/** File repository for CRUD operations on file records. */
|
||||||
|
fileRepo: IFileRepository;
|
||||||
|
/** File-part repository for chunked file metadata. */
|
||||||
|
filePartRepo: IFilePartRepository;
|
||||||
|
/** Telegram service for forwarding file content to storage. */
|
||||||
|
telegramService: ITelegramService;
|
||||||
|
/** Application configuration subset. */
|
||||||
|
config: UploadFileConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the configured chunk size and returns it as a safe integer.
|
||||||
|
*
|
||||||
|
* @param chunkSizeBytes - The configured chunk size in bytes.
|
||||||
|
* @returns The same value if it is a positive safe integer.
|
||||||
|
*/
|
||||||
|
const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
||||||
|
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
||||||
|
throw new Error('Invalid Telegram chunk size');
|
||||||
|
}
|
||||||
|
return chunkSizeBytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally gzip-compresses a chunk if compression is enabled and the chunk
|
||||||
|
* is large enough to benefit from it.
|
||||||
|
*
|
||||||
|
* @param chunk - The raw chunk buffer.
|
||||||
|
* @param compress - Whether compression is enabled.
|
||||||
|
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
||||||
|
* @returns The (possibly compressed) buffer and the algorithm used.
|
||||||
|
*/
|
||||||
|
const maybeCompressChunk = (
|
||||||
|
chunk: Buffer,
|
||||||
|
compress: boolean,
|
||||||
|
compressionMinSizeBytes: number,
|
||||||
|
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
|
||||||
|
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gzipped = gzipSync(chunk);
|
||||||
|
if (gzipped.byteLength >= chunk.byteLength) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the first 16 bytes from a file on disk for magic-byte detection.
|
||||||
|
*
|
||||||
|
* @param tempPath - Absolute path to the temporary file.
|
||||||
|
* @returns A buffer containing up to 16 bytes.
|
||||||
|
*/
|
||||||
|
const readSignatureBuffer = async (tempPath: string): Promise<Buffer> => {
|
||||||
|
const handle = await open(tempPath, 'r');
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(16);
|
||||||
|
const { bytesRead } = await handle.read(buf, 0, 16, 0);
|
||||||
|
return buf.subarray(0, bytesRead);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a file from disk in chunks, forwards each chunk to Telegram storage,
|
||||||
|
* and returns metadata for all uploaded parts together with the total file
|
||||||
|
* hash.
|
||||||
|
*
|
||||||
|
* @param tempPath - Absolute path to the temporary file on disk.
|
||||||
|
* @param partFileNamePrefix - Prefix used for each chunk's file name in Telegram.
|
||||||
|
* @param chunkSizeBytes - Maximum size of each chunk in bytes.
|
||||||
|
* @param compress - Whether gzip compression is enabled.
|
||||||
|
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
||||||
|
* @param telegramService - The Telegram service to forward each chunk.
|
||||||
|
* @returns The aggregated chunked upload result.
|
||||||
|
*/
|
||||||
|
const uploadFileInTelegramChunks = async (
|
||||||
|
tempPath: string,
|
||||||
|
partFileNamePrefix: string,
|
||||||
|
chunkSizeBytes: number,
|
||||||
|
compress: boolean,
|
||||||
|
compressionMinSizeBytes: number,
|
||||||
|
telegramService: ITelegramService,
|
||||||
|
): Promise<ChunkedUploadResult> => {
|
||||||
|
const safeChunkSize = asSafeChunkSize(chunkSizeBytes);
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
const parts: UploadedPart[] = [];
|
||||||
|
let totalSizeBytes = 0;
|
||||||
|
let partNumber = 0;
|
||||||
|
|
||||||
|
const stream = createReadStream(tempPath, { highWaterMark: safeChunkSize });
|
||||||
|
|
||||||
|
for await (const data of stream) {
|
||||||
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
|
||||||
|
if (chunk.byteLength === 0) continue;
|
||||||
|
|
||||||
|
partNumber += 1;
|
||||||
|
totalSizeBytes += chunk.byteLength;
|
||||||
|
hasher.update(chunk);
|
||||||
|
|
||||||
|
const { bytes, compressionAlgorithm } = maybeCompressChunk(
|
||||||
|
chunk,
|
||||||
|
compress,
|
||||||
|
compressionMinSizeBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const forwardResult = await telegramService.forwardToStorage(
|
||||||
|
bytes,
|
||||||
|
`${partFileNamePrefix}.part-${partNumber}`,
|
||||||
|
'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
partNumber,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
sizeBytes: chunk.byteLength,
|
||||||
|
storedSizeBytes: bytes.byteLength,
|
||||||
|
compressionAlgorithm,
|
||||||
|
etag: computeHash(chunk),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parts,
|
||||||
|
fileHash: hasher.digest('hex'),
|
||||||
|
totalSizeBytes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a factory function for the upload-file use case.
|
||||||
|
*
|
||||||
|
* The returned use case:
|
||||||
|
* 1. Checks for an existing file with the same SHA-256 hash (deduplication).
|
||||||
|
* 2. Normalises the file name and MIME type based on magic bytes.
|
||||||
|
* 3. Validates the file size against Telegram type-specific limits.
|
||||||
|
* 4. Chooses a storage strategy — chunked (for files exceeding the chunk
|
||||||
|
* threshold) or single-message upload.
|
||||||
|
* 5. Persists the file record (and, for chunked uploads, part records).
|
||||||
|
* 6. Builds and returns the public `UploadOutput` DTO.
|
||||||
|
*
|
||||||
|
* @param deps - The injected dependencies.
|
||||||
|
* @returns An async function accepting `UploadInput` and returning `UploadOutput`.
|
||||||
|
*/
|
||||||
|
export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
|
||||||
|
return async (input: UploadInput): Promise<UploadOutput> => {
|
||||||
|
// 1. Check deduplication by content hash
|
||||||
|
const existing = await deps.fileRepo.findByHash(input.fileHash);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
publicId: existing.publicId,
|
||||||
|
fileName: existing.fileName,
|
||||||
|
mimeType: existing.mimeType,
|
||||||
|
sizeBytes: existing.sizeBytes,
|
||||||
|
fileType: existing.fileType,
|
||||||
|
createdAt: existing.createdAt instanceof Date ? existing.createdAt : new Date(existing.createdAt),
|
||||||
|
downloadUrl: `${deps.config.baseUrl}/f/${existing.publicId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read signature bytes for magic-byte-based extension detection
|
||||||
|
const signatureBuffer = await readSignatureBuffer(input.tempPath);
|
||||||
|
|
||||||
|
const { fileName: finalFileName, mimeType } = ensureExtension(
|
||||||
|
input.fileName,
|
||||||
|
signatureBuffer,
|
||||||
|
input.mimeType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Determine Telegram file type and validate size
|
||||||
|
const fileType = getFileType(mimeType, finalFileName);
|
||||||
|
|
||||||
|
if (!checkFileSize(input.sizeBytes, fileType)) {
|
||||||
|
throw new Error(`File size exceeds ${fileType} limit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Upload — chunked for files above the threshold, single otherwise
|
||||||
|
if (input.sizeBytes > deps.config.telegramChunkSizeBytes) {
|
||||||
|
// Chunked upload path
|
||||||
|
const chunkResult = await uploadFileInTelegramChunks(
|
||||||
|
input.tempPath,
|
||||||
|
`direct-${input.fileHash.slice(0, 16)}`,
|
||||||
|
deps.config.telegramChunkSizeBytes,
|
||||||
|
deps.config.compressChunkedUploads,
|
||||||
|
deps.config.chunkCompressionMinSizeBytes,
|
||||||
|
deps.telegramService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstPart = chunkResult.parts[0];
|
||||||
|
if (!firstPart) {
|
||||||
|
throw new Error('Chunked upload produced no parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = randomUUID();
|
||||||
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
const newFile = await deps.fileRepo.create({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: firstPart.telegramFileId,
|
||||||
|
telegramFileUniqueId: firstPart.telegramFileUniqueId,
|
||||||
|
storageChatId: deps.config.storageChatId,
|
||||||
|
storageMessageId: firstPart.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: chunkResult.totalSizeBytes,
|
||||||
|
fileType,
|
||||||
|
uploaderId: input.uploaderId ?? 0,
|
||||||
|
fileHash: chunkResult.fileHash,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: input.bucketId ?? null,
|
||||||
|
s3Key: input.s3Key ?? null,
|
||||||
|
storageBackend: 'chunked',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: chunkResult.parts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileParts: NewFilePart[] = chunkResult.parts.map((part) => ({
|
||||||
|
fileId,
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
telegramFileId: part.telegramFileId,
|
||||||
|
telegramFileUniqueId: part.telegramFileUniqueId,
|
||||||
|
storageChatId: deps.config.storageChatId,
|
||||||
|
storageMessageId: part.storageMessageId,
|
||||||
|
sizeBytes: part.sizeBytes,
|
||||||
|
storedSizeBytes: part.storedSizeBytes,
|
||||||
|
compressionAlgorithm: part.compressionAlgorithm,
|
||||||
|
etag: part.etag,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await deps.filePartRepo.insert(fileParts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicId: newFile.publicId,
|
||||||
|
fileName: newFile.fileName,
|
||||||
|
mimeType: newFile.mimeType,
|
||||||
|
sizeBytes: newFile.sizeBytes,
|
||||||
|
fileType: newFile.fileType,
|
||||||
|
createdAt: newFile.createdAt,
|
||||||
|
downloadUrl: `${deps.config.baseUrl}/f/${newFile.publicId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Single-message upload path
|
||||||
|
const forwardResult = await deps.telegramService.forwardToStorage(
|
||||||
|
createReadStream(input.tempPath),
|
||||||
|
finalFileName,
|
||||||
|
fileType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const singlePublicId = nanoid();
|
||||||
|
|
||||||
|
const createdFile = await deps.fileRepo.create({
|
||||||
|
publicId: singlePublicId,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageChatId: deps.config.storageChatId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: input.sizeBytes,
|
||||||
|
fileType,
|
||||||
|
uploaderId: input.uploaderId ?? 0,
|
||||||
|
fileHash: input.fileHash,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: input.bucketId ?? null,
|
||||||
|
s3Key: input.s3Key ?? null,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicId: createdFile.publicId,
|
||||||
|
fileName: createdFile.fileName,
|
||||||
|
mimeType: createdFile.mimeType,
|
||||||
|
sizeBytes: createdFile.sizeBytes,
|
||||||
|
fileType: createdFile.fileType,
|
||||||
|
createdAt: createdFile.createdAt,
|
||||||
|
downloadUrl: `${deps.config.baseUrl}/f/${createdFile.publicId}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { config } from '../env';
|
||||||
|
|
||||||
|
export { config };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Core domain entity representing an S3-compatible storage bucket.
|
||||||
|
* Buckets group objects for the S3-compatible API layer.
|
||||||
|
*/
|
||||||
|
export interface Bucket {
|
||||||
|
/** Primary key, UUID */
|
||||||
|
id: string;
|
||||||
|
/** Bucket name (unique, max 63 chars, S3 naming convention) */
|
||||||
|
name: string;
|
||||||
|
/** Record creation timestamp */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Record last-updated timestamp */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Supported compression algorithms for stored file parts.
|
||||||
|
* - `"gzip"`: Gzip compression was applied
|
||||||
|
* - `null`: No compression applied
|
||||||
|
*/
|
||||||
|
export type CompressionAlgorithm = 'gzip' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core domain entity representing a chunk (part) of a file stored in Telegram.
|
||||||
|
* Large files are split into multiple parts for Telegram-safe storage.
|
||||||
|
*/
|
||||||
|
export interface FilePart {
|
||||||
|
/** Primary key, auto-increment */
|
||||||
|
id: number;
|
||||||
|
/** Foreign key to the parent File record (UUID) */
|
||||||
|
fileId: string;
|
||||||
|
/** Sequential part number (1-based within the file) */
|
||||||
|
partNumber: number;
|
||||||
|
/** Telegram file_id for retrieving this part */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram unique file_id (stable across bot tokens) */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** Chat ID where this part is stored */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Message ID within the storage chat */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Original size of this part in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Stored (post-compression) size in bytes */
|
||||||
|
storedSizeBytes: number;
|
||||||
|
/** Compression algorithm applied, or null if uncompressed */
|
||||||
|
compressionAlgorithm: CompressionAlgorithm;
|
||||||
|
/** ETag for this part (hash of the stored content) */
|
||||||
|
etag: string;
|
||||||
|
/** Record creation timestamp */
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input type for creating a new FilePart record.
|
||||||
|
* Omits auto-generated fields (id, createdAt).
|
||||||
|
*/
|
||||||
|
export type NewFilePart = Omit<FilePart, 'id' | 'createdAt'>;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Core domain entity representing a file stored in Telegram.
|
||||||
|
* Contains both Telegram metadata and optional S3-compatible fields.
|
||||||
|
*/
|
||||||
|
export interface File {
|
||||||
|
/** Primary key, UUID */
|
||||||
|
id: string;
|
||||||
|
/** Public-facing unique identifier (short, URL-safe) */
|
||||||
|
publicId: string;
|
||||||
|
/** Telegram file_id for retrieving the file */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram unique file_id (stable across bot tokens) */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** Chat ID where the file is stored */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Message ID within the storage chat */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Original file name */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the file */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** File type classification (e.g. "photo", "document", "video") */
|
||||||
|
fileType: string;
|
||||||
|
/** Telegram user ID of the uploader */
|
||||||
|
uploaderId: number;
|
||||||
|
/** SHA-256 hash of file contents, or null */
|
||||||
|
fileHash: string | null;
|
||||||
|
/** Telegram file_id of the archive (zip) containing this file, or null */
|
||||||
|
archiveTelegramFileId: string | null;
|
||||||
|
/** Message ID of the archive message, or null */
|
||||||
|
archiveStorageMessageId: number | null;
|
||||||
|
/** File name within the archive, or null */
|
||||||
|
archiveFileName: string | null;
|
||||||
|
/** Entry name/path within the archive, or null */
|
||||||
|
archiveEntryName: string | null;
|
||||||
|
/** MIME type of the archive entry, or null */
|
||||||
|
archiveMimeType: string | null;
|
||||||
|
/** Size of the archive entry in bytes, or null */
|
||||||
|
archiveSizeBytes: number | null;
|
||||||
|
/** S3 bucket ID if stored via S3-compatible API, or null */
|
||||||
|
bucketId: string | null;
|
||||||
|
/** S3 object key if stored via S3-compatible API, or null */
|
||||||
|
s3Key: string | null;
|
||||||
|
/** Storage backend identifier, defaults to "telegram" */
|
||||||
|
storageBackend: string | null;
|
||||||
|
/** Soft-delete flag */
|
||||||
|
isDeleted: boolean | null;
|
||||||
|
/** S3 multipart upload ID if uploaded in parts, or null */
|
||||||
|
multipartUploadId: string | null;
|
||||||
|
/** Number of file_parts for chunked storage, or null */
|
||||||
|
partCount: number | null;
|
||||||
|
/** Record creation timestamp */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Record last-updated timestamp */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input type for creating a new File record.
|
||||||
|
* Omits auto-generated fields (id, createdAt, updatedAt).
|
||||||
|
*/
|
||||||
|
export type NewFile = Omit<File, 'id' | 'createdAt' | 'updatedAt'>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Core domain entity representing an S3 multipart upload session.
|
||||||
|
* Tracks in-progress multipart uploads within a bucket.
|
||||||
|
*/
|
||||||
|
export interface MultipartUpload {
|
||||||
|
/** Unique upload identifier (nanoid) */
|
||||||
|
uploadId: string;
|
||||||
|
/** Foreign key to the parent Bucket (UUID) */
|
||||||
|
bucketId: string;
|
||||||
|
/** S3 object key being uploaded */
|
||||||
|
s3Key: string;
|
||||||
|
/** Timestamp when the upload was initiated */
|
||||||
|
initiatedAt: Date;
|
||||||
|
/** Upload status: "in_progress", "completed", or "aborted" */
|
||||||
|
status: string;
|
||||||
|
/** Identifier of the entity that initiated the upload */
|
||||||
|
initiatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core domain entity representing an individual part of an S3 multipart upload.
|
||||||
|
* Each part is stored as a separate Telegram message.
|
||||||
|
*/
|
||||||
|
export interface MultipartPart {
|
||||||
|
/** Primary key, auto-increment */
|
||||||
|
id: number;
|
||||||
|
/** Foreign key to the parent MultipartUpload */
|
||||||
|
uploadId: string;
|
||||||
|
/** Sequential part number (1-based within the upload) */
|
||||||
|
partNumber: number;
|
||||||
|
/** Telegram file_id for retrieving this part */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram unique file_id (stable across bot tokens) */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** Message ID within the storage chat */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Part size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** ETag for this part */
|
||||||
|
etag: string;
|
||||||
|
/** Record creation timestamp */
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Bucket } from '../entities/bucket';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for Bucket entity persistence.
|
||||||
|
*
|
||||||
|
* Abstracts the bucket CRUD operations currently in `src/db/buckets.ts`.
|
||||||
|
*/
|
||||||
|
export interface IBucketRepository {
|
||||||
|
/**
|
||||||
|
* Create a new bucket with the given name.
|
||||||
|
* @param name - The unique bucket name (S3 naming convention).
|
||||||
|
* @returns The newly created bucket record.
|
||||||
|
*/
|
||||||
|
create(name: string): Promise<Bucket>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a bucket by its unique name.
|
||||||
|
* @param name - The bucket name to look up.
|
||||||
|
* @returns The matching bucket, or `null` when not found.
|
||||||
|
*/
|
||||||
|
findByName(name: string): Promise<Bucket | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all buckets, ordered alphabetically by name.
|
||||||
|
* @returns An array of all bucket records.
|
||||||
|
*/
|
||||||
|
list(): Promise<Bucket[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a bucket and cascade-delete all associated files and multipart data.
|
||||||
|
* @param name - The name of the bucket to delete.
|
||||||
|
* @returns `true` if the bucket was deleted, `false` if it did not exist.
|
||||||
|
*/
|
||||||
|
delete(name: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a bucket with the given name exists.
|
||||||
|
* @param name - The bucket name to check.
|
||||||
|
* @returns `true` if the bucket exists, `false` otherwise.
|
||||||
|
*/
|
||||||
|
exists(name: string): Promise<boolean>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FilePart, NewFilePart } from '../entities/file-part';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for FilePart entity persistence.
|
||||||
|
*
|
||||||
|
* Abstracts the file-part operations currently in `src/db/file-parts.ts`.
|
||||||
|
* File parts represent the chunks of a large file stored across multiple
|
||||||
|
* Telegram messages for Telegram-safe storage.
|
||||||
|
*/
|
||||||
|
export interface IFilePartRepository {
|
||||||
|
/**
|
||||||
|
* Insert multiple file parts in a single operation.
|
||||||
|
* @param parts - An array of new file part records (auto-generated fields omitted).
|
||||||
|
*/
|
||||||
|
insert(parts: NewFilePart[]): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all file parts for a given file, ordered by part number.
|
||||||
|
* @param fileId - The UUID of the parent file record.
|
||||||
|
* @returns An array of file parts.
|
||||||
|
*/
|
||||||
|
listByFileId(fileId: string): Promise<FilePart[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of file parts associated with a file.
|
||||||
|
* @param fileId - The UUID of the parent file record.
|
||||||
|
* @returns The part count.
|
||||||
|
*/
|
||||||
|
countByFileId(fileId: string): Promise<number>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { File, NewFile } from '../entities/file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An S3-synced file record: a File entity guaranteed to have non-null
|
||||||
|
* bucketId and s3Key values.
|
||||||
|
*/
|
||||||
|
export interface S3FileRecord extends File {
|
||||||
|
/** S3 bucket UUID (non-null refinement) */
|
||||||
|
bucketId: string;
|
||||||
|
/** S3 object key (non-null refinement) */
|
||||||
|
s3Key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for File entity persistence.
|
||||||
|
*
|
||||||
|
* Abstracts all file CRUD operations currently spread across
|
||||||
|
* `src/db/files.ts` and `src/db/files-ext.ts`.
|
||||||
|
*/
|
||||||
|
export interface IFileRepository {
|
||||||
|
/**
|
||||||
|
* Find a single file by its SHA-256 content hash.
|
||||||
|
* @param hash - The SHA-256 hash to search for.
|
||||||
|
* @returns The matching file, or `null` when not found.
|
||||||
|
*/
|
||||||
|
findByHash(hash: string): Promise<File | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a single file by its public-facing short identifier.
|
||||||
|
* @param publicId - The public ID to look up.
|
||||||
|
* @returns The matching file, or `null` when not found.
|
||||||
|
*/
|
||||||
|
findByPublicId(publicId: string): Promise<File | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a single file by its Telegram file unique ID (stable across bot tokens).
|
||||||
|
* @param telegramFileUniqueId - The Telegram unique file ID.
|
||||||
|
* @returns The matching file, or `null` when not found.
|
||||||
|
*/
|
||||||
|
findByUniqueId(telegramFileUniqueId: string): Promise<File | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a single file by its S3 bucket and object key.
|
||||||
|
* @param bucketId - The bucket UUID.
|
||||||
|
* @param s3Key - The S3 object key.
|
||||||
|
* @returns The matching file, or `null` when not found.
|
||||||
|
*/
|
||||||
|
findByBucketAndKey(bucketId: string, s3Key: string): Promise<File | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new file record.
|
||||||
|
* @param file - The file data (auto-generated fields omitted).
|
||||||
|
* @returns The newly created file record with all fields populated.
|
||||||
|
*/
|
||||||
|
create(file: NewFile): Promise<File>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List objects within a bucket, optionally filtered by prefix and delimiter.
|
||||||
|
*
|
||||||
|
* When `delimiter` is `"/"`, common prefixes (pseudo-directories) are
|
||||||
|
* returned separately and objects whose key continues past the delimiter
|
||||||
|
* are omitted from the `objects` array.
|
||||||
|
*
|
||||||
|
* @param bucketId - The bucket UUID to list from.
|
||||||
|
* @param prefix - Key prefix to filter by.
|
||||||
|
* @param delimiter - Delimiter character (e.g. `"/"`) or `null` for flat listing.
|
||||||
|
* @param maxKeys - Maximum number of object records to return.
|
||||||
|
* @param startAfter - Return only keys strictly greater than this value, or `null`.
|
||||||
|
* @returns A list of matching S3 file records and discovered common prefixes.
|
||||||
|
*/
|
||||||
|
listByPrefix(
|
||||||
|
bucketId: string,
|
||||||
|
prefix: string,
|
||||||
|
delimiter: string | null,
|
||||||
|
maxKeys: number,
|
||||||
|
startAfter: string | null,
|
||||||
|
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a single file by bucket and key.
|
||||||
|
* @param bucketId - The bucket UUID.
|
||||||
|
* @param s3Key - The S3 object key.
|
||||||
|
* @returns `true` if a row was soft-deleted, `false` otherwise.
|
||||||
|
*/
|
||||||
|
softDelete(bucketId: string, s3Key: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete multiple files within a bucket in batch.
|
||||||
|
* @param bucketId - The bucket UUID.
|
||||||
|
* @param keys - Array of S3 object keys to delete.
|
||||||
|
* @returns The number of rows actually soft-deleted.
|
||||||
|
*/
|
||||||
|
softDeleteBatch(bucketId: string, keys: string[]): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count non-deleted objects in a bucket.
|
||||||
|
* @param bucketId - The bucket UUID.
|
||||||
|
* @returns The object count.
|
||||||
|
*/
|
||||||
|
countByBucket(bucketId: string): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find soft-deleted (orphaned) file records in a bucket.
|
||||||
|
* @param bucketId - The bucket UUID.
|
||||||
|
* @returns An array of orphaned file records.
|
||||||
|
*/
|
||||||
|
findOrphansByBucket(bucketId: string): Promise<File[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { MultipartUpload, MultipartPart } from '../entities/multipart';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for S3 multipart upload persistence.
|
||||||
|
*
|
||||||
|
* Abstracts the multipart upload operations currently in `src/db/multipart.ts`.
|
||||||
|
* Manages both multipart upload sessions and their individual parts.
|
||||||
|
*/
|
||||||
|
export interface IMultipartRepository {
|
||||||
|
/**
|
||||||
|
* Initiate a new multipart upload session.
|
||||||
|
* @param bucketId - The UUID of the target bucket.
|
||||||
|
* @param s3Key - The S3 object key being uploaded.
|
||||||
|
* @param initiatedBy - Identifier of the entity that initiated the upload.
|
||||||
|
* @returns The newly generated upload ID (nanoid).
|
||||||
|
*/
|
||||||
|
create(bucketId: string, s3Key: string, initiatedBy: string): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an in-progress multipart upload by its upload ID.
|
||||||
|
* @param uploadId - The upload identifier.
|
||||||
|
* @returns The matching upload, or `null` if not found or not in progress.
|
||||||
|
*/
|
||||||
|
findById(uploadId: string): Promise<MultipartUpload | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a multipart upload as completed.
|
||||||
|
* @param uploadId - The upload identifier to complete.
|
||||||
|
*/
|
||||||
|
complete(uploadId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a multipart upload as aborted.
|
||||||
|
* @param uploadId - The upload identifier to abort.
|
||||||
|
*/
|
||||||
|
abort(uploadId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a single part record for a multipart upload.
|
||||||
|
* @param part - The part data (auto-generated fields omitted).
|
||||||
|
*/
|
||||||
|
insertPart(part: Omit<MultipartPart, 'id' | 'createdAt'>): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all parts for a multipart upload, ordered by part number.
|
||||||
|
* @param uploadId - The upload identifier.
|
||||||
|
* @returns An array of multipart parts.
|
||||||
|
*/
|
||||||
|
listParts(uploadId: string): Promise<MultipartPart[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List in-progress multipart uploads within a bucket, with pagination.
|
||||||
|
*
|
||||||
|
* Results are ordered by S3 key and initiation timestamp.
|
||||||
|
*
|
||||||
|
* @param bucketId - The UUID of the bucket.
|
||||||
|
* @param maxUploads - Maximum number of uploads to return (clamped 1-1000).
|
||||||
|
* @param keyMarker - Return only uploads whose S3 key is strictly greater than this, or `null`.
|
||||||
|
* @returns A list of uploads and pagination metadata.
|
||||||
|
*/
|
||||||
|
listByBucket(
|
||||||
|
bucketId: string,
|
||||||
|
maxUploads: number,
|
||||||
|
keyMarker: string | null,
|
||||||
|
): Promise<{
|
||||||
|
uploads: MultipartUpload[];
|
||||||
|
isTruncated: boolean;
|
||||||
|
nextKeyMarker: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Result of forwarding a file to Telegram storage.
|
||||||
|
*/
|
||||||
|
export interface ForwardResult {
|
||||||
|
/** The Telegram file_id for retrieving the file */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** The Telegram unique file_id (stable across bot tokens) */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** The message ID within the storage chat */
|
||||||
|
storageMessageId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File information returned by Telegram's getFile API.
|
||||||
|
*/
|
||||||
|
export interface TelegramFileInfo {
|
||||||
|
/** File size in bytes */
|
||||||
|
file_size: number;
|
||||||
|
/** MIME type of the file */
|
||||||
|
mime_type: string;
|
||||||
|
/** Path on Telegram's file server for downloading */
|
||||||
|
file_path: string;
|
||||||
|
/** Bot token that owns the retrieved file */
|
||||||
|
bot_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction over Telegram bot API operations.
|
||||||
|
*
|
||||||
|
* Defines the contract for forwarding files to Telegram storage,
|
||||||
|
* retrieving file metadata, and managing concurrent uploads.
|
||||||
|
*/
|
||||||
|
export interface ITelegramService {
|
||||||
|
/**
|
||||||
|
* Forward a file chunk to the configured Telegram storage chat.
|
||||||
|
*
|
||||||
|
* @param fileChunk - The file data (ReadStream, Buffer, or file path).
|
||||||
|
* @param fileName - The original file name.
|
||||||
|
* @param fileType - The file type classification (e.g. "photo", "document").
|
||||||
|
* @returns The Telegram identifiers of the stored file.
|
||||||
|
*/
|
||||||
|
forwardToStorage(
|
||||||
|
fileChunk: unknown,
|
||||||
|
fileName: string,
|
||||||
|
fileType: string,
|
||||||
|
): Promise<ForwardResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve file metadata from Telegram by file ID.
|
||||||
|
*
|
||||||
|
* Tries all configured bots; returns info from the first that owns the file.
|
||||||
|
*
|
||||||
|
* @param telegramFileId - The Telegram file_id to look up.
|
||||||
|
* @returns Metadata including size, MIME type, download path, and bot token.
|
||||||
|
*/
|
||||||
|
getFileInfo(telegramFileId: string): Promise<TelegramFileInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a task for sequential upload execution.
|
||||||
|
*
|
||||||
|
* Ensures only one Telegram upload runs at a time to avoid
|
||||||
|
* rate limits and resource contention.
|
||||||
|
*
|
||||||
|
* @param task - An async function performing the upload.
|
||||||
|
* @returns The result of the task.
|
||||||
|
*/
|
||||||
|
enqueueUpload<T>(task: () => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
+12
-67
@@ -1,21 +1,14 @@
|
|||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { startBot } from './bot';
|
import { config } from './config/index';
|
||||||
import { config } from './env';
|
import { startBot } from './interfaces/bot/handler';
|
||||||
import { handleLogin, handleLogout, handleMe } from './routes/auth';
|
import { routes } from './interfaces/http/routes/index';
|
||||||
import { handleFileInfo, handleFileRedirect } from './routes/files';
|
import { isS3Request } from './interfaces/s3/auth';
|
||||||
import { handleHealth } from './routes/health';
|
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
||||||
import { handleHome } from './routes/home';
|
import { extractS3BucketFromHost } from './interfaces/s3/virtual-host';
|
||||||
import { handleS3Request } from './routes/s3';
|
import { fileInfoCache } from './infrastructure/cache/index';
|
||||||
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
|
||||||
import { handleUpload } from './routes/upload';
|
import { logger } from './shared/logger/index';
|
||||||
import { handleWebApiV1 } from './routes/web-api';
|
import { metricsCollector } from './shared/metrics/index';
|
||||||
import { requireAuth } from './utils/auth';
|
|
||||||
import { fileInfoCache } from './utils/cache';
|
|
||||||
import logger from './utils/logger';
|
|
||||||
import { metricsCollector } from './utils/metrics';
|
|
||||||
import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit';
|
|
||||||
import { isS3Request } from './utils/s3/auth';
|
|
||||||
import { extractS3BucketFromHost } from './utils/s3/virtual-host';
|
|
||||||
|
|
||||||
// ─── Auto-run migration at startup ──────────────────────────────────────────
|
// ─── Auto-run migration at startup ──────────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
@@ -58,55 +51,7 @@ const handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
|||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
routes: {
|
routes,
|
||||||
'/api/upload': {
|
|
||||||
POST: withRateLimit(handleUpload),
|
|
||||||
},
|
|
||||||
'/f/:public_id': {
|
|
||||||
GET: withRateLimit(handleFileRedirect),
|
|
||||||
},
|
|
||||||
'/file/:public_id/info': {
|
|
||||||
GET: withRateLimit(handleFileInfo),
|
|
||||||
},
|
|
||||||
'/health': {
|
|
||||||
GET: handleHealth,
|
|
||||||
},
|
|
||||||
'/docs': {
|
|
||||||
GET: handleSwaggerHtml,
|
|
||||||
},
|
|
||||||
'/swagger.json': {
|
|
||||||
GET: handleSwaggerJson,
|
|
||||||
},
|
|
||||||
'/': {
|
|
||||||
GET: (req: Request) => {
|
|
||||||
const headers = Object.fromEntries(req.headers);
|
|
||||||
if (shouldHandleS3(req, headers)) {
|
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
|
||||||
}
|
|
||||||
return handleHome();
|
|
||||||
},
|
|
||||||
PUT: handleMaybeS3Root,
|
|
||||||
HEAD: handleMaybeS3Root,
|
|
||||||
DELETE: handleMaybeS3Root,
|
|
||||||
POST: handleMaybeS3Root,
|
|
||||||
OPTIONS: handleMaybeS3Root,
|
|
||||||
},
|
|
||||||
'/api/v1/auth/login': {
|
|
||||||
POST: withRateLimit(handleLogin),
|
|
||||||
},
|
|
||||||
'/api/v1/auth/logout': {
|
|
||||||
POST: handleLogout,
|
|
||||||
},
|
|
||||||
'/api/v1/auth/me': {
|
|
||||||
GET: handleMe,
|
|
||||||
},
|
|
||||||
'/api/v1/*': {
|
|
||||||
GET: requireAuth(handleWebApiV1),
|
|
||||||
POST: requireAuth(handleWebApiV1),
|
|
||||||
DELETE: requireAuth(handleWebApiV1),
|
|
||||||
PUT: requireAuth(handleWebApiV1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fetch: async (req: Request) => {
|
fetch: async (req: Request) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
return handleS3Request(req, getS3RouteBucket(req));
|
||||||
@@ -163,4 +108,4 @@ setInterval(
|
|||||||
5 * 60 * 1000,
|
5 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info('Application running successfully');
|
logger.info('Application running successfully');
|
||||||
Vendored
+15
-96
@@ -1,129 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* Represents an entry in the cache with a value and expiration timestamp.
|
* Generic in-memory cache with TTL (time-to-live) support.
|
||||||
* @template T - The type of the cached value.
|
* Entries expire after a configurable duration and are lazily evicted on access.
|
||||||
*/
|
|
||||||
interface CacheEntry<T> {
|
|
||||||
value: T;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A generic in-memory cache with configurable TTL (time-to-live) support.
|
|
||||||
*
|
*
|
||||||
* Provides simple get/set/delete operations with automatic expiration.
|
* @typeParam T - The type of values stored in the cache
|
||||||
* Expired entries are lazily evicted on read and can be proactively cleaned up
|
|
||||||
* via the {@link cleanup} method.
|
|
||||||
*
|
|
||||||
* @template T - The type of values stored in the cache.
|
|
||||||
*/
|
*/
|
||||||
class Cache<T> {
|
export class Cache<T> {
|
||||||
private store = new Map<string, CacheEntry<T>>();
|
private store = new Map<string, CacheEntry<T>>();
|
||||||
private ttlMs: number;
|
private ttlMs: number;
|
||||||
|
|
||||||
/**
|
constructor(ttlSeconds = 3600) {
|
||||||
* Creates a new cache instance.
|
|
||||||
* @param ttlSeconds - Default TTL in seconds for cached entries (default 3600).
|
|
||||||
*/
|
|
||||||
constructor(ttlSeconds: number = 3600) {
|
|
||||||
this.ttlMs = ttlSeconds * 1000;
|
this.ttlMs = ttlSeconds * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores a value in the cache under the given key.
|
|
||||||
* @param key - The cache key.
|
|
||||||
* @param value - The value to cache.
|
|
||||||
*/
|
|
||||||
set(key: string, value: T): void {
|
set(key: string, value: T): void {
|
||||||
this.store.set(key, {
|
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
||||||
value,
|
|
||||||
expiresAt: Date.now() + this.ttlMs,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a value from the cache by key.
|
|
||||||
* Returns `null` if the key does not exist or the entry has expired.
|
|
||||||
* Expired entries are automatically deleted on access.
|
|
||||||
* @param key - The cache key.
|
|
||||||
* @returns The cached value, or `null` if not found or expired.
|
|
||||||
*/
|
|
||||||
get(key: string): T | null {
|
get(key: string): T | null {
|
||||||
const entry = this.store.get(key);
|
const entry = this.store.get(key);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
if (Date.now() > entry.expiresAt) {
|
||||||
this.store.delete(key);
|
this.store.delete(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.value;
|
return entry.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
has(key: string): boolean { return this.get(key) !== null; }
|
||||||
* Checks whether a key exists in the cache and has not expired.
|
delete(key: string): void { this.store.delete(key); }
|
||||||
* @param key - The cache key.
|
clear(): void { this.store.clear(); }
|
||||||
* @returns `true` if the key exists and is still valid, `false` otherwise.
|
size(): number { return this.store.size; }
|
||||||
*/
|
|
||||||
has(key: string): boolean {
|
|
||||||
return this.get(key) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a key from the cache.
|
|
||||||
* @param key - The cache key to remove.
|
|
||||||
*/
|
|
||||||
delete(key: string): void {
|
|
||||||
this.store.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all entries from the cache.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
this.store.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the total number of entries currently in the cache (including expired ones).
|
|
||||||
* @returns The number of entries in the internal store.
|
|
||||||
*/
|
|
||||||
size(): number {
|
|
||||||
return this.store.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes all expired entries from the cache.
|
|
||||||
* @returns The number of entries removed.
|
|
||||||
*/
|
|
||||||
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) {
|
if (now > entry.expiresAt) { this.store.delete(key); removed++; }
|
||||||
this.store.delete(key);
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface CacheEntry<T> { value: T; expiresAt: number }
|
||||||
* Metadata shape stored in the file info cache.
|
|
||||||
*/
|
|
||||||
interface FileInfoCacheValue {
|
|
||||||
file_size: number;
|
|
||||||
mime_type: string;
|
|
||||||
file_path: string;
|
|
||||||
bot_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export const fileInfoCache = new Cache<{
|
||||||
* Singleton cache for file information with a 1-hour TTL.
|
file_size: number; mime_type: string; file_path: string; bot_token: string;
|
||||||
* Used to store metadata about files previously uploaded to Telegram.
|
}>(3600);
|
||||||
*/
|
|
||||||
export const fileInfoCache = new Cache<FileInfoCacheValue>(3600);
|
|
||||||
|
|
||||||
export { Cache };
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { fileParts, files } from './schema';
|
||||||
|
|
||||||
|
const client = postgres(process.env.DATABASE_URL!, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Drizzle ORM database client initialized with the files and fileParts schema. */
|
||||||
|
export const db = drizzle(client, { schema: { fileParts, files } });
|
||||||
|
|
||||||
|
export { fileParts, files };
|
||||||
|
|
||||||
|
export default db;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { config } from '../../../env';
|
||||||
|
import { getErrorMessage } from '../../../shared/utils/file';
|
||||||
|
import logger from '../../../shared/logger/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run raw SQL migration from schema.sql.
|
||||||
|
* Safe to call multiple times — all statements use IF NOT EXISTS.
|
||||||
|
* Searches multiple relative paths to support execution from compiled dist,
|
||||||
|
* bun --hot, or direct script invocation.
|
||||||
|
*/
|
||||||
|
export const runMigration = async (): Promise<void> => {
|
||||||
|
// In compiled dist: import.meta.dir = .../dist/infrastructure/persistence/drizzle/
|
||||||
|
// In source via bun --hot: import.meta.dir = .../src/infrastructure/persistence/drizzle/
|
||||||
|
const dir = import.meta.dir || '';
|
||||||
|
const candidates = [
|
||||||
|
`${dir}/../../../../schema.sql`, // from dist/
|
||||||
|
`${dir}/../../../schema.sql`, // from src/infrastructure/persistence/
|
||||||
|
`${dir}/../../schema.sql`, // from src/infrastructure/
|
||||||
|
`${dir}/../schema.sql`, // from src/infrastructure/persistence/drizzle/
|
||||||
|
`${dir}/schema.sql`, // from next to file (bun run directly)
|
||||||
|
];
|
||||||
|
|
||||||
|
let schemaSql: string | null = null;
|
||||||
|
for (const p of candidates) {
|
||||||
|
const file = Bun.file(p);
|
||||||
|
const exists = await file.exists();
|
||||||
|
if (exists) {
|
||||||
|
schemaSql = await file.text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schemaSql) {
|
||||||
|
logger.error(`Migration failed: schema.sql not found (tried ${candidates.join(', ')})`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = postgres(config.databaseUrl, { max: 1 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql.unsafe(schemaSql);
|
||||||
|
logger.info('Database migration completed');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error('Database migration failed', { error: getErrorMessage(error) });
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When run directly: `bun src/infrastructure/persistence/drizzle/migrate.ts`
|
||||||
|
if (import.meta.path === Bun.main) {
|
||||||
|
await runMigration();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
serial,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files table definition.
|
||||||
|
* Stores metadata about uploaded files including Telegram storage references,
|
||||||
|
* S3 bucket information, multipart upload tracking, and archive entries.
|
||||||
|
*/
|
||||||
|
export const files = pgTable('files', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
publicId: text('public_id').unique().notNull(),
|
||||||
|
telegramFileId: text('telegram_file_id').notNull(),
|
||||||
|
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
|
||||||
|
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
|
||||||
|
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
|
||||||
|
fileName: text('file_name').notNull(),
|
||||||
|
mimeType: text('mime_type').notNull(),
|
||||||
|
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
|
||||||
|
fileType: text('file_type').notNull(),
|
||||||
|
uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(),
|
||||||
|
fileHash: text('file_hash'),
|
||||||
|
archiveTelegramFileId: text('archive_telegram_file_id'),
|
||||||
|
archiveStorageMessageId: bigint('archive_storage_message_id', { mode: 'number' }),
|
||||||
|
archiveFileName: text('archive_file_name'),
|
||||||
|
archiveEntryName: text('archive_entry_name'),
|
||||||
|
archiveMimeType: text('archive_mime_type'),
|
||||||
|
archiveSizeBytes: bigint('archive_size_bytes', { mode: 'number' }),
|
||||||
|
bucketId: text('bucket_id'),
|
||||||
|
s3Key: text('s3_key'),
|
||||||
|
storageBackend: text('storage_backend').default('telegram'),
|
||||||
|
isDeleted: boolean('is_deleted').default(false),
|
||||||
|
multipartUploadId: text('multipart_upload_id'),
|
||||||
|
partCount: integer('part_count'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File parts table definition.
|
||||||
|
* Stores chunks of multipart uploads with per-part Telegram storage references
|
||||||
|
* and compression metadata.
|
||||||
|
*/
|
||||||
|
export const fileParts = pgTable('file_parts', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
fileId: uuid('file_id').notNull(),
|
||||||
|
partNumber: integer('part_number').notNull(),
|
||||||
|
telegramFileId: text('telegram_file_id').notNull(),
|
||||||
|
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
|
||||||
|
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
|
||||||
|
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
|
||||||
|
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
|
||||||
|
storedSizeBytes: bigint('stored_size_bytes', { mode: 'number' }).notNull(),
|
||||||
|
compressionAlgorithm: text('compression_algorithm'),
|
||||||
|
etag: text('etag').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Type representing a file row selected from the database. */
|
||||||
|
export type File = InferSelectModel<typeof files>;
|
||||||
|
|
||||||
|
/** Type representing a file row being inserted into the database. */
|
||||||
|
export type NewFile = InferInsertModel<typeof files>;
|
||||||
|
|
||||||
|
/** Type representing a file part row selected from the database. */
|
||||||
|
export type FilePart = InferSelectModel<typeof fileParts>;
|
||||||
|
|
||||||
|
/** Type representing a file part row being inserted into the database. */
|
||||||
|
export type NewFilePart = InferInsertModel<typeof fileParts>;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../drizzle/index';
|
||||||
|
import type { Bucket } from '../../../domain/entities/bucket';
|
||||||
|
import type { IBucketRepository } from '../../../domain/ports/bucket-repository';
|
||||||
|
|
||||||
|
/** Raw result row from `db.execute()`. */
|
||||||
|
type QueryRow = Record<string, unknown>;
|
||||||
|
/** Array of raw result rows. */
|
||||||
|
type QueryResult = QueryRow[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw database row to a {@link Bucket} domain entity.
|
||||||
|
*/
|
||||||
|
const mapRowToBucket = (row: Record<string, unknown>): Bucket => ({
|
||||||
|
id: row.id as string,
|
||||||
|
name: row.name as string,
|
||||||
|
createdAt: new Date(row.created_at as string),
|
||||||
|
updatedAt: new Date(row.updated_at as string),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle-backed implementation of {@link IBucketRepository}.
|
||||||
|
*
|
||||||
|
* Delegates to the same SQL queries as the original `src/db/buckets.ts`
|
||||||
|
* module, using raw SQL for drizzle tables that are not part of the
|
||||||
|
* typed schema.
|
||||||
|
*/
|
||||||
|
export class DrizzleBucketRepository implements IBucketRepository {
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IBucketRepository.create}
|
||||||
|
*/
|
||||||
|
async create(name: string): Promise<Bucket> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`,
|
||||||
|
)) as unknown as QueryResult;
|
||||||
|
return mapRowToBucket(result[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IBucketRepository.findByName}
|
||||||
|
*/
|
||||||
|
async findByName(name: string): Promise<Bucket | null> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`,
|
||||||
|
)) as unknown as QueryResult;
|
||||||
|
if (result.length === 0) return null;
|
||||||
|
return mapRowToBucket(result[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IBucketRepository.list}
|
||||||
|
*/
|
||||||
|
async list(): Promise<Bucket[]> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`,
|
||||||
|
)) as unknown as QueryResult;
|
||||||
|
return result.map(mapRowToBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IBucketRepository.delete}
|
||||||
|
*
|
||||||
|
* Cascade-deletes multipart and file rows that hold foreign-key
|
||||||
|
* references to the bucket before deleting the bucket itself.
|
||||||
|
* Failures during cascade are silently caught to match the original
|
||||||
|
* defensive-cleanup behaviour.
|
||||||
|
*/
|
||||||
|
async delete(name: string): Promise<boolean> {
|
||||||
|
// Cascade-delete rows that hold FK references to the bucket
|
||||||
|
await db
|
||||||
|
.execute(
|
||||||
|
sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.execute(
|
||||||
|
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.execute(
|
||||||
|
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`DELETE FROM buckets WHERE name = ${name}`,
|
||||||
|
)) as unknown as QueryResult;
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IBucketRepository.exists}
|
||||||
|
*/
|
||||||
|
async exists(name: string): Promise<boolean> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT 1 FROM buckets WHERE name = ${name}`,
|
||||||
|
)) as unknown as QueryResult;
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../drizzle/index';
|
||||||
|
import type { FilePart, NewFilePart } from '../../../domain/entities/file-part';
|
||||||
|
import type { IFilePartRepository } from '../../../domain/ports/file-part-repository';
|
||||||
|
|
||||||
|
/** Compression algorithm type matching the domain entity. */
|
||||||
|
type CompressionAlgorithm = 'gzip' | null;
|
||||||
|
|
||||||
|
/** Safely converts a raw value to a number, defaulting to 0. */
|
||||||
|
const toNumber = (value: unknown): number => Number(value ?? 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw database row (snake_case keys) to a {@link FilePart}
|
||||||
|
* domain entity.
|
||||||
|
*/
|
||||||
|
const mapRowToFilePart = (row: Record<string, unknown>): FilePart => ({
|
||||||
|
id: toNumber(row.id),
|
||||||
|
fileId: row.file_id as string,
|
||||||
|
partNumber: toNumber(row.part_number),
|
||||||
|
telegramFileId: row.telegram_file_id as string,
|
||||||
|
telegramFileUniqueId: row.telegram_file_unique_id as string,
|
||||||
|
storageChatId: toNumber(row.storage_chat_id),
|
||||||
|
storageMessageId: toNumber(row.storage_message_id),
|
||||||
|
sizeBytes: toNumber(row.size_bytes),
|
||||||
|
storedSizeBytes: toNumber(row.stored_size_bytes),
|
||||||
|
compressionAlgorithm:
|
||||||
|
(row.compression_algorithm as CompressionAlgorithm) || null,
|
||||||
|
etag: row.etag as string,
|
||||||
|
createdAt: new Date(row.created_at as string),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle-backed implementation of {@link IFilePartRepository}.
|
||||||
|
*
|
||||||
|
* Delegates to the same SQL queries as the original `src/db/file-parts.ts`
|
||||||
|
* module, using raw SQL for all operations.
|
||||||
|
*/
|
||||||
|
export class DrizzleFilePartRepository implements IFilePartRepository {
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFilePartRepository.insert}
|
||||||
|
*/
|
||||||
|
async insert(parts: NewFilePart[]): Promise<void> {
|
||||||
|
for (const part of parts) {
|
||||||
|
await db.execute(
|
||||||
|
sql`INSERT INTO file_parts (
|
||||||
|
file_id,
|
||||||
|
part_number,
|
||||||
|
telegram_file_id,
|
||||||
|
telegram_file_unique_id,
|
||||||
|
storage_chat_id,
|
||||||
|
storage_message_id,
|
||||||
|
size_bytes,
|
||||||
|
stored_size_bytes,
|
||||||
|
compression_algorithm,
|
||||||
|
etag
|
||||||
|
) VALUES (
|
||||||
|
${part.fileId}::uuid,
|
||||||
|
${part.partNumber},
|
||||||
|
${part.telegramFileId},
|
||||||
|
${part.telegramFileUniqueId},
|
||||||
|
${part.storageChatId},
|
||||||
|
${part.storageMessageId},
|
||||||
|
${part.sizeBytes},
|
||||||
|
${part.storedSizeBytes},
|
||||||
|
${part.compressionAlgorithm},
|
||||||
|
${part.etag}
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFilePartRepository.listByFileId}
|
||||||
|
*/
|
||||||
|
async listByFileId(fileId: string): Promise<FilePart[]> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT id,
|
||||||
|
file_id,
|
||||||
|
part_number,
|
||||||
|
telegram_file_id,
|
||||||
|
telegram_file_unique_id,
|
||||||
|
storage_chat_id,
|
||||||
|
storage_message_id,
|
||||||
|
size_bytes,
|
||||||
|
stored_size_bytes,
|
||||||
|
compression_algorithm,
|
||||||
|
etag,
|
||||||
|
created_at
|
||||||
|
FROM file_parts
|
||||||
|
WHERE file_id = ${fileId}::uuid
|
||||||
|
ORDER BY part_number`,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
|
||||||
|
return result.map(mapRowToFilePart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFilePartRepository.countByFileId}
|
||||||
|
*/
|
||||||
|
async countByFileId(fileId: string): Promise<number> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT COUNT(*) AS count FROM file_parts WHERE file_id = ${fileId}::uuid`,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
return toNumber(result[0]?.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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 {
|
||||||
|
IFileRepository,
|
||||||
|
S3FileRecord,
|
||||||
|
} from '../../../domain/ports/file-repository';
|
||||||
|
|
||||||
|
/** Safely converts a raw value to a number, defaulting to 0. */
|
||||||
|
const toNumber = (value: unknown): number => Number(value ?? 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special LIKE wildcard characters (`%`, `_`, `\`) so that
|
||||||
|
* a user-supplied prefix can be safely used in a LIKE expression.
|
||||||
|
*/
|
||||||
|
const escapeLike = (s: string): string => s.replace(/[%_\\]/g, '\\$&');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw database row (snake_case keys) to an {@link S3FileRecord}
|
||||||
|
* domain entity. Used only when raw SQL via `db.execute()` returns
|
||||||
|
* un-typed result sets.
|
||||||
|
*/
|
||||||
|
const mapDbRowToS3Record = (row: Record<string, unknown>): S3FileRecord => ({
|
||||||
|
id: row.id as string,
|
||||||
|
publicId: row.public_id as string,
|
||||||
|
telegramFileId: row.telegram_file_id as string,
|
||||||
|
telegramFileUniqueId: row.telegram_file_unique_id as string,
|
||||||
|
storageChatId: toNumber(row.storage_chat_id),
|
||||||
|
storageMessageId: toNumber(row.storage_message_id),
|
||||||
|
fileName: row.file_name as string,
|
||||||
|
mimeType: row.mime_type as string,
|
||||||
|
sizeBytes: toNumber(row.size_bytes),
|
||||||
|
fileType: row.file_type as string,
|
||||||
|
uploaderId: toNumber(row.uploader_id),
|
||||||
|
fileHash: row.file_hash as string | null,
|
||||||
|
archiveTelegramFileId: row.archive_telegram_file_id as string | null,
|
||||||
|
archiveStorageMessageId:
|
||||||
|
row.archive_storage_message_id === null
|
||||||
|
? null
|
||||||
|
: toNumber(row.archive_storage_message_id),
|
||||||
|
archiveFileName: row.archive_file_name as string | null,
|
||||||
|
archiveEntryName: row.archive_entry_name as string | null,
|
||||||
|
archiveMimeType: row.archive_mime_type as string | null,
|
||||||
|
archiveSizeBytes:
|
||||||
|
row.archive_size_bytes === null
|
||||||
|
? null
|
||||||
|
: toNumber(row.archive_size_bytes),
|
||||||
|
bucketId: row.bucket_id as string,
|
||||||
|
s3Key: row.s3_key as string,
|
||||||
|
storageBackend: (row.storage_backend as string) || 'telegram',
|
||||||
|
isDeleted: row.is_deleted as boolean,
|
||||||
|
multipartUploadId: row.multipart_upload_id as string | null,
|
||||||
|
partCount:
|
||||||
|
row.part_count === null || row.part_count === undefined
|
||||||
|
? null
|
||||||
|
: toNumber(row.part_count),
|
||||||
|
createdAt: new Date(row.created_at as string),
|
||||||
|
updatedAt: new Date(row.updated_at as string),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle-backed implementation of {@link IFileRepository}.
|
||||||
|
*
|
||||||
|
* Delegates to the same SQL queries as the original `src/db/files.ts` and
|
||||||
|
* `src/db/files-ext.ts` modules while presenting a clean domain interface.
|
||||||
|
*/
|
||||||
|
export class DrizzleFileRepository implements IFileRepository {
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.findByHash}
|
||||||
|
*/
|
||||||
|
async findByHash(hash: string): Promise<File | null> {
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(fileSchema)
|
||||||
|
.where(eq(fileSchema.fileHash, hash))
|
||||||
|
.limit(1);
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.findByPublicId}
|
||||||
|
*/
|
||||||
|
async findByPublicId(publicId: string): Promise<File | null> {
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(fileSchema)
|
||||||
|
.where(eq(fileSchema.publicId, publicId))
|
||||||
|
.limit(1);
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.findByUniqueId}
|
||||||
|
*/
|
||||||
|
async findByUniqueId(telegramFileUniqueId: string): Promise<File | null> {
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(fileSchema)
|
||||||
|
.where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId))
|
||||||
|
.limit(1);
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.findByBucketAndKey}
|
||||||
|
*/
|
||||||
|
async findByBucketAndKey(
|
||||||
|
bucketId: string,
|
||||||
|
s3Key: string,
|
||||||
|
): Promise<File | null> {
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(fileSchema)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(fileSchema.bucketId, bucketId),
|
||||||
|
eq(fileSchema.s3Key, s3Key),
|
||||||
|
eq(fileSchema.isDeleted, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.create}
|
||||||
|
*/
|
||||||
|
async create(file: NewFile): Promise<File> {
|
||||||
|
const result = await db
|
||||||
|
.insert(fileSchema)
|
||||||
|
.values(file)
|
||||||
|
.returning();
|
||||||
|
return result[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.listByPrefix}
|
||||||
|
*/
|
||||||
|
async listByPrefix(
|
||||||
|
bucketId: string,
|
||||||
|
prefix: string,
|
||||||
|
delimiter: string | null,
|
||||||
|
maxKeys: number,
|
||||||
|
startAfter: string | null,
|
||||||
|
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> {
|
||||||
|
let query = prefix
|
||||||
|
? sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${`${escapeLike(prefix)}%`}`
|
||||||
|
: sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`;
|
||||||
|
|
||||||
|
if (startAfter) {
|
||||||
|
query = sql`${query} AND s3_key > ${startAfter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
|
||||||
|
|
||||||
|
const rawResult = (await db.execute(
|
||||||
|
query,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
|
||||||
|
if (delimiter === '/') {
|
||||||
|
const prefixSet = new Set<string>();
|
||||||
|
const objects: S3FileRecord[] = [];
|
||||||
|
|
||||||
|
for (const row of rawResult) {
|
||||||
|
const s3Key = row.s3_key as string;
|
||||||
|
const relativeKey = s3Key.substring(prefix.length);
|
||||||
|
const slashIndex = relativeKey.indexOf('/');
|
||||||
|
if (slashIndex >= 0) {
|
||||||
|
const folderPrefix =
|
||||||
|
prefix + relativeKey.substring(0, slashIndex + 1);
|
||||||
|
if (folderPrefix !== prefix) {
|
||||||
|
prefixSet.add(folderPrefix);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
objects.push(mapDbRowToS3Record(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects: objects.slice(0, maxKeys),
|
||||||
|
prefixes: Array.from(prefixSet).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects: rawResult.slice(0, maxKeys).map(mapDbRowToS3Record),
|
||||||
|
prefixes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.softDelete}
|
||||||
|
*/
|
||||||
|
async softDelete(bucketId: string, s3Key: string): Promise<boolean> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`UPDATE files SET is_deleted = true WHERE bucket_id = ${bucketId}::uuid AND s3_key = ${s3Key} RETURNING id`,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.softDeleteBatch}
|
||||||
|
*/
|
||||||
|
async softDeleteBatch(
|
||||||
|
bucketId: string,
|
||||||
|
keys: string[],
|
||||||
|
): Promise<number> {
|
||||||
|
let deleted = 0;
|
||||||
|
for (const key of keys) {
|
||||||
|
const ok = await this.softDelete(bucketId, key);
|
||||||
|
if (ok) deleted++;
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.countByBucket}
|
||||||
|
*/
|
||||||
|
async countByBucket(bucketId: string): Promise<number> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT count(*) as count FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
return Number(result[0]?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IFileRepository.findOrphansByBucket}
|
||||||
|
*/
|
||||||
|
async findOrphansByBucket(bucketId: string): Promise<File[]> {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(fileSchema)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(fileSchema.bucketId, bucketId),
|
||||||
|
eq(fileSchema.isDeleted, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { db } from '../drizzle/index';
|
||||||
|
import type { MultipartUpload, MultipartPart } from '../../../domain/entities/multipart';
|
||||||
|
import type { IMultipartRepository } from '../../../domain/ports/multipart-repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw database row to a {@link MultipartUpload} domain entity.
|
||||||
|
*/
|
||||||
|
const mapRowToMultipartUpload = (
|
||||||
|
r: Record<string, unknown>,
|
||||||
|
): MultipartUpload => ({
|
||||||
|
uploadId: r.upload_id as string,
|
||||||
|
bucketId: r.bucket_id as string,
|
||||||
|
s3Key: r.s3_key as string,
|
||||||
|
initiatedAt: new Date(r.initiated_at as string),
|
||||||
|
status: r.status as string,
|
||||||
|
initiatedBy: (r.initiated_by as string | null) || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle-backed implementation of {@link IMultipartRepository}.
|
||||||
|
*
|
||||||
|
* Delegates to the same SQL queries as the original `src/db/multipart.ts`
|
||||||
|
* module, using raw SQL for all operations on the un-typed
|
||||||
|
* `multipart_uploads` and `multipart_parts` tables.
|
||||||
|
*/
|
||||||
|
export class DrizzleMultipartRepository implements IMultipartRepository {
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IMultipartRepository.create}
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
bucketId: string,
|
||||||
|
s3Key: string,
|
||||||
|
initiatedBy: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const uploadId = nanoid(32);
|
||||||
|
await db.execute(
|
||||||
|
sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
|
||||||
|
);
|
||||||
|
return uploadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IMultipartRepository.findById}
|
||||||
|
*/
|
||||||
|
async findById(uploadId: string): Promise<MultipartUpload | null> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
if (result.length === 0) return null;
|
||||||
|
const r = result[0]!;
|
||||||
|
return {
|
||||||
|
uploadId: r.upload_id as string,
|
||||||
|
bucketId: r.bucket_id as string,
|
||||||
|
s3Key: r.s3_key as string,
|
||||||
|
initiatedAt: new Date(r.initiated_at as string),
|
||||||
|
status: r.status as string,
|
||||||
|
initiatedBy: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IMultipartRepository.complete}
|
||||||
|
*/
|
||||||
|
async complete(uploadId: string): Promise<void> {
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE multipart_uploads SET status = 'completed' WHERE upload_id = ${uploadId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IMultipartRepository.abort}
|
||||||
|
*/
|
||||||
|
async abort(uploadId: string): Promise<void> {
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE multipart_uploads SET status = 'aborted' WHERE upload_id = ${uploadId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IMultipartRepository.insertPart}
|
||||||
|
*/
|
||||||
|
async insertPart(
|
||||||
|
part: Omit<MultipartPart, 'id' | 'createdAt'>,
|
||||||
|
): Promise<void> {
|
||||||
|
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)
|
||||||
|
VALUES (${part.uploadId}, ${part.partNumber}, ${part.telegramFileId}, ${part.telegramFileUniqueId}, ${part.storageMessageId}, ${part.sizeBytes}, ${part.etag})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IMultipartRepository.listParts}
|
||||||
|
*/
|
||||||
|
async listParts(uploadId: string): Promise<MultipartPart[]> {
|
||||||
|
const result = (await db.execute(
|
||||||
|
sql`SELECT id, upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag, created_at
|
||||||
|
FROM multipart_parts WHERE upload_id = ${uploadId} ORDER BY part_number`,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
return result.map((r) => ({
|
||||||
|
id: r.id as number,
|
||||||
|
uploadId: r.upload_id as string,
|
||||||
|
partNumber: r.part_number as number,
|
||||||
|
telegramFileId: r.telegram_file_id as string,
|
||||||
|
telegramFileUniqueId: r.telegram_file_unique_id as string,
|
||||||
|
storageMessageId: r.storage_message_id as number,
|
||||||
|
sizeBytes: Number(r.size_bytes),
|
||||||
|
etag: r.etag as string,
|
||||||
|
createdAt: new Date(r.created_at as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc IMultipartRepository.listByBucket}
|
||||||
|
*/
|
||||||
|
async listByBucket(
|
||||||
|
bucketId: string,
|
||||||
|
maxUploads: number,
|
||||||
|
keyMarker: string | null,
|
||||||
|
): Promise<{
|
||||||
|
uploads: MultipartUpload[];
|
||||||
|
isTruncated: boolean;
|
||||||
|
nextKeyMarker: string | null;
|
||||||
|
}> {
|
||||||
|
const limit = Math.min(Math.max(maxUploads || 1000, 1), 1000);
|
||||||
|
const result = (await db.execute(
|
||||||
|
keyMarker
|
||||||
|
? sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
|
||||||
|
FROM multipart_uploads
|
||||||
|
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress' AND s3_key > ${keyMarker}
|
||||||
|
ORDER BY s3_key, initiated_at
|
||||||
|
LIMIT ${limit + 1}`
|
||||||
|
: sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
|
||||||
|
FROM multipart_uploads
|
||||||
|
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress'
|
||||||
|
ORDER BY s3_key, initiated_at
|
||||||
|
LIMIT ${limit + 1}`,
|
||||||
|
)) as unknown as Record<string, unknown>[];
|
||||||
|
|
||||||
|
const uploads = result.slice(0, limit).map(mapRowToMultipartUpload);
|
||||||
|
return {
|
||||||
|
uploads,
|
||||||
|
isTruncated: result.length > limit,
|
||||||
|
nextKeyMarker:
|
||||||
|
result.length > limit ? uploads.at(-1)?.s3Key || null : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Telegraf } from 'telegraf';
|
||||||
|
import { config } from '../../env';
|
||||||
|
import logger from '../../shared/logger/index';
|
||||||
|
import type { ITelegramService, ForwardResult, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
||||||
|
import { enqueueUpload } from './upload-queue';
|
||||||
|
import {
|
||||||
|
sendMethodMap,
|
||||||
|
extractUploadedFile,
|
||||||
|
buildSendPayload,
|
||||||
|
type TelegramMessageResult,
|
||||||
|
type SendMethod,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for a given number of seconds.
|
||||||
|
*
|
||||||
|
* Used as a backoff mechanism when all bots in the pool are rate-limited.
|
||||||
|
*
|
||||||
|
* @param seconds - Number of seconds to sleep.
|
||||||
|
* @returns A promise that resolves after the specified delay.
|
||||||
|
*/
|
||||||
|
const sleep = (seconds: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a pool of Telegram bots with automatic rotation and rate-limit handling.
|
||||||
|
*
|
||||||
|
* Distributes uploads across multiple bot tokens to maximise throughput.
|
||||||
|
* When a bot receives a 429 (rate-limit) error, the pool instantly rotates
|
||||||
|
* to the next available bot. If all bots are rate-limited, a coordinated
|
||||||
|
* sleep is performed before retrying.
|
||||||
|
*
|
||||||
|
* Implements the {@link ITelegramService} contract.
|
||||||
|
*/
|
||||||
|
export class BotPool implements ITelegramService {
|
||||||
|
private readonly bots: Telegraf[];
|
||||||
|
private readonly botTokens: string[];
|
||||||
|
private nextBotIndex = 0;
|
||||||
|
|
||||||
|
/** Create a new BotPool from the application configuration. */
|
||||||
|
constructor() {
|
||||||
|
this.botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
|
||||||
|
this.bots = this.botTokens.map((token) => new Telegraf(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim the next bot index using round-robin rotation.
|
||||||
|
*
|
||||||
|
* @returns The index of the selected bot.
|
||||||
|
*/
|
||||||
|
private claimBotIndex(): number {
|
||||||
|
const botIndex = this.nextBotIndex;
|
||||||
|
this.nextBotIndex = (this.nextBotIndex + 1) % this.bots.length;
|
||||||
|
return botIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Telegram API action with automatic retry and bot rotation.
|
||||||
|
*
|
||||||
|
* On 429 errors the pool either:
|
||||||
|
* 1. Rotates to the next bot immediately (if another bot is available), or
|
||||||
|
* 2. Sleeps for the required duration after all bots are exhausted, then retries.
|
||||||
|
*
|
||||||
|
* @param action - The action to execute on a bot instance.
|
||||||
|
* @param retries - Number of full-pool retry cycles remaining.
|
||||||
|
* @param attemptedBots - Number of bots attempted in the current cycle.
|
||||||
|
* @returns The result of the action.
|
||||||
|
*/
|
||||||
|
private async executeWithBotRetry<T>(
|
||||||
|
action: (botInstance: Telegraf, botToken: string) => Promise<T>,
|
||||||
|
retries = 5,
|
||||||
|
attemptedBots = 0,
|
||||||
|
): Promise<T> {
|
||||||
|
const botIndex = this.claimBotIndex();
|
||||||
|
const currentBot = this.bots[botIndex];
|
||||||
|
const currentToken = this.botTokens[botIndex];
|
||||||
|
try {
|
||||||
|
return await action(currentBot, currentToken);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorStr = error instanceof Error ? error.message : String(error);
|
||||||
|
const match = errorStr.match(/retry after (\d+)/i);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const nextIndex = this.nextBotIndex;
|
||||||
|
const nextAttemptedBots = attemptedBots + 1;
|
||||||
|
|
||||||
|
if (nextAttemptedBots < this.bots.length) {
|
||||||
|
logger.info(
|
||||||
|
`Bot Index ${botIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
|
||||||
|
);
|
||||||
|
return this.executeWithBotRetry(action, retries, nextAttemptedBots);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retries > 0) {
|
||||||
|
const seconds = parseInt(match[1], 10);
|
||||||
|
logger.warn(
|
||||||
|
`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`,
|
||||||
|
{ error: errorStr },
|
||||||
|
);
|
||||||
|
await sleep(seconds);
|
||||||
|
return this.executeWithBotRetry(action, retries - 1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward a file chunk to the configured Telegram storage chat.
|
||||||
|
*
|
||||||
|
* The upload is queued (via {@link enqueueUpload}) and executed with
|
||||||
|
* automatic bot rotation on rate-limit errors.
|
||||||
|
*
|
||||||
|
* @param fileChunk - The file data (ReadStream, Buffer, or file path).
|
||||||
|
* @param fileName - The original file name.
|
||||||
|
* @param fileType - The file type classification (e.g. "document", "photo").
|
||||||
|
* @returns The Telegram identifiers of the stored file.
|
||||||
|
*/
|
||||||
|
async forwardToStorage(
|
||||||
|
fileChunk: unknown,
|
||||||
|
fileName: string,
|
||||||
|
fileType: string,
|
||||||
|
): Promise<ForwardResult> {
|
||||||
|
try {
|
||||||
|
const result = await this.enqueueUpload<TelegramMessageResult>(async () => {
|
||||||
|
const filePayload = { source: fileChunk, filename: fileName };
|
||||||
|
const sendMethodName = sendMethodMap[fileType] || 'sendDocument';
|
||||||
|
const payload = buildSendPayload(fileType, fileName);
|
||||||
|
|
||||||
|
return this.executeWithBotRetry<TelegramMessageResult>((activeBot) => {
|
||||||
|
const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
|
||||||
|
return telegram[sendMethodName](config.storageChatId, filePayload, payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedFile = extractUploadedFile(result, fileType);
|
||||||
|
logger.info('File forwarded to storage', { fileName, message: result.message_id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
telegramFileId: uploadedFile?.file_id || '',
|
||||||
|
telegramFileUniqueId: uploadedFile?.file_unique_id || '',
|
||||||
|
storageMessageId: result.message_id,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error('Failed to forward file to storage', {
|
||||||
|
fileName,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve file metadata from Telegram by file ID.
|
||||||
|
*
|
||||||
|
* Tries all configured bots sequentially; returns info from the first
|
||||||
|
* bot that can retrieve the file. Errors indicating the file belongs
|
||||||
|
* to a different bot are silently skipped.
|
||||||
|
*
|
||||||
|
* @param telegramFileId - The Telegram file_id to look up.
|
||||||
|
* @returns Metadata including size, MIME type, download path, and bot token.
|
||||||
|
*/
|
||||||
|
async getFileInfo(telegramFileId: string): Promise<TelegramFileInfo> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const activeBot of this.bots) {
|
||||||
|
try {
|
||||||
|
const result = await activeBot.telegram.getFile(telegramFileId);
|
||||||
|
const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
|
||||||
|
return {
|
||||||
|
file_size: fileData.file_size || 0,
|
||||||
|
mime_type: fileData.mime_type || 'application/octet-stream',
|
||||||
|
file_path: fileData.file_path || '',
|
||||||
|
bot_token: activeBot.telegram.token,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
lastError = error;
|
||||||
|
const errorStr = error instanceof Error ? error.message : String(error);
|
||||||
|
if (
|
||||||
|
errorStr.includes('wrong file_id') ||
|
||||||
|
errorStr.includes('file is temporarily unavailable') ||
|
||||||
|
errorStr.includes('retry after')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Failed to get file info from any bot', {
|
||||||
|
error: lastError instanceof Error ? lastError.message : String(lastError),
|
||||||
|
});
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a task for sequential upload execution.
|
||||||
|
*
|
||||||
|
* Delegates to the shared upload queue to ensure only a limited number
|
||||||
|
* of Telegram uploads run concurrently.
|
||||||
|
*
|
||||||
|
* @param task - An async function performing the upload.
|
||||||
|
* @returns The result of the task.
|
||||||
|
*/
|
||||||
|
enqueueUpload<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
return enqueueUpload(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton BotPool instance initialised from application configuration.
|
||||||
|
*/
|
||||||
|
export const botPool = new BotPool();
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { config } from '../../env';
|
||||||
|
import { computeHash } from '../../shared/utils/file';
|
||||||
|
import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream';
|
||||||
|
import type { RangeParseResult } from '../../interfaces/s3/range';
|
||||||
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
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.
|
||||||
|
* `"gzip"` if gzip compression was applied, `null` for uncompressed.
|
||||||
|
*/
|
||||||
|
export type ChunkCompressionAlgorithm = CompressionAlgorithm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata about a single uploaded chunk (part) stored in Telegram.
|
||||||
|
*/
|
||||||
|
export interface ChunkedUploadPart {
|
||||||
|
/** 1-based part number within the file */
|
||||||
|
partNumber: number;
|
||||||
|
/** Telegram file_id for retrieving this part */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram unique file_id (stable across bot tokens) */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** Message ID within the storage chat */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Original (pre-compression) size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Stored (post-compression) size in bytes */
|
||||||
|
storedSizeBytes: number;
|
||||||
|
/** Compression algorithm applied, or null */
|
||||||
|
compressionAlgorithm: ChunkCompressionAlgorithm;
|
||||||
|
/** ETag (SHA-256 hash) of the original chunk */
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of uploading a file in Telegram chunks.
|
||||||
|
*/
|
||||||
|
export interface ChunkedUploadResult {
|
||||||
|
/** Ordered list of uploaded parts */
|
||||||
|
parts: ChunkedUploadPart[];
|
||||||
|
/** SHA-256 hash of the complete file content */
|
||||||
|
fileHash: string;
|
||||||
|
/** Total file size in bytes */
|
||||||
|
totalSizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input parameters for storing a file via chunked Telegram uploads.
|
||||||
|
*/
|
||||||
|
export interface ChunkedFileInput {
|
||||||
|
/** Path to the temporary file on disk */
|
||||||
|
tempPath: string;
|
||||||
|
/** Prefix for generated part file names */
|
||||||
|
partFileNamePrefix: string;
|
||||||
|
/** Original file name */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the file */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** File type classification (e.g. "document", "video") */
|
||||||
|
fileType: string;
|
||||||
|
/** Telegram user ID of the uploader */
|
||||||
|
uploaderId: number;
|
||||||
|
/** S3 bucket ID if the file is also tracked in S3, or null */
|
||||||
|
bucketId?: string | null;
|
||||||
|
/** S3 object key if the file is also tracked in S3, or null */
|
||||||
|
s3Key?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitise the Telegram chunk size.
|
||||||
|
*
|
||||||
|
* @param chunkSizeBytes - The desired chunk size in bytes.
|
||||||
|
* @returns The validated chunk size.
|
||||||
|
* @throws {Error} If the chunk size is not a safe positive integer.
|
||||||
|
*/
|
||||||
|
const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
||||||
|
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
||||||
|
throw new Error('Invalid Telegram chunk size');
|
||||||
|
}
|
||||||
|
return chunkSizeBytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally compress a chunk with gzip.
|
||||||
|
*
|
||||||
|
* Compression is skipped if:
|
||||||
|
* - The `compress` flag is false.
|
||||||
|
* - The chunk is smaller than `compressionMinSizeBytes`.
|
||||||
|
* - The compressed result is larger than the original.
|
||||||
|
*
|
||||||
|
* @param chunk - The raw chunk buffer.
|
||||||
|
* @param compress - Whether compression is enabled.
|
||||||
|
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
||||||
|
* @returns The (possibly compressed) bytes and the algorithm used.
|
||||||
|
*/
|
||||||
|
const maybeCompressChunk = (
|
||||||
|
chunk: Buffer,
|
||||||
|
compress: boolean,
|
||||||
|
compressionMinSizeBytes: number,
|
||||||
|
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
|
||||||
|
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gzipped = gzipSync(chunk);
|
||||||
|
if (gzipped.byteLength >= chunk.byteLength) {
|
||||||
|
return { bytes: chunk, compressionAlgorithm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages chunked storage of large files in Telegram.
|
||||||
|
*
|
||||||
|
* Large files are split into smaller chunks, each uploaded as a separate
|
||||||
|
* Telegram document. File and part metadata is persisted through the
|
||||||
|
* provided repository interfaces.
|
||||||
|
*
|
||||||
|
* Injects dependencies via constructor — can be used with any
|
||||||
|
* {@link IFileRepository}, {@link IFilePartRepository}, and
|
||||||
|
* {@link ITelegramService} implementation.
|
||||||
|
*/
|
||||||
|
export class ChunkedStorage {
|
||||||
|
/**
|
||||||
|
* @param fileRepository - Repository for File entity persistence.
|
||||||
|
* @param filePartRepository - Repository for FilePart entity persistence.
|
||||||
|
* @param telegramService - Service for Telegram API interactions.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly fileRepository: IFileRepository,
|
||||||
|
private readonly filePartRepository: IFilePartRepository,
|
||||||
|
private readonly telegramService: ITelegramService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to Telegram in chunks and return chunk metadata.
|
||||||
|
*
|
||||||
|
* Reads the file from disk in fixed-size chunks, compresses each chunk
|
||||||
|
* if beneficial, and forwards each chunk to Telegram storage.
|
||||||
|
*
|
||||||
|
* @param input - Upload parameters including temp path, chunk size, and compression settings.
|
||||||
|
* @returns Metadata about all uploaded chunks and the file hash.
|
||||||
|
*/
|
||||||
|
async uploadFileInTelegramChunks(input: {
|
||||||
|
tempPath: string;
|
||||||
|
partFileNamePrefix: string;
|
||||||
|
chunkSizeBytes: number;
|
||||||
|
compress: boolean;
|
||||||
|
compressionMinSizeBytes: number;
|
||||||
|
}): Promise<ChunkedUploadResult> {
|
||||||
|
const chunkSizeBytes = asSafeChunkSize(input.chunkSizeBytes);
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
const parts: ChunkedUploadPart[] = [];
|
||||||
|
let totalSizeBytes = 0;
|
||||||
|
let partNumber = 0;
|
||||||
|
|
||||||
|
const stream = createReadStream(input.tempPath, { highWaterMark: chunkSizeBytes });
|
||||||
|
|
||||||
|
for await (const data of stream) {
|
||||||
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
|
||||||
|
if (chunk.byteLength === 0) continue;
|
||||||
|
|
||||||
|
partNumber += 1;
|
||||||
|
totalSizeBytes += chunk.byteLength;
|
||||||
|
hasher.update(chunk);
|
||||||
|
|
||||||
|
const { bytes, compressionAlgorithm } = maybeCompressChunk(
|
||||||
|
chunk,
|
||||||
|
input.compress,
|
||||||
|
input.compressionMinSizeBytes,
|
||||||
|
);
|
||||||
|
const forwardResult = await this.telegramService.forwardToStorage(
|
||||||
|
bytes,
|
||||||
|
`${input.partFileNamePrefix}.part-${partNumber}`,
|
||||||
|
'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
partNumber,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
sizeBytes: chunk.byteLength,
|
||||||
|
storedSizeBytes: bytes.byteLength,
|
||||||
|
compressionAlgorithm,
|
||||||
|
etag: computeHash(chunk),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parts,
|
||||||
|
fileHash: hasher.digest('hex'),
|
||||||
|
totalSizeBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to Telegram in chunks and persist file + part records.
|
||||||
|
*
|
||||||
|
* Combines chunk upload ({@link uploadFileInTelegramChunks}) with
|
||||||
|
* repository persistence for both the File and FilePart entities.
|
||||||
|
*
|
||||||
|
* @param input - The file metadata and upload parameters.
|
||||||
|
* @returns The persisted File entity.
|
||||||
|
*/
|
||||||
|
async storeFileInTelegramChunks(input: ChunkedFileInput): Promise<FileEntity> {
|
||||||
|
const upload = await this.uploadFileInTelegramChunks({
|
||||||
|
tempPath: input.tempPath,
|
||||||
|
partFileNamePrefix: input.partFileNamePrefix,
|
||||||
|
chunkSizeBytes: config.telegramChunkSizeBytes,
|
||||||
|
compress: config.compressChunkedUploads,
|
||||||
|
compressionMinSizeBytes: config.chunkCompressionMinSizeBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstPart = upload.parts[0];
|
||||||
|
if (!firstPart) {
|
||||||
|
throw new Error('Chunked upload produced no parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
const file = await this.fileRepository.create({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: firstPart.telegramFileId,
|
||||||
|
telegramFileUniqueId: firstPart.telegramFileUniqueId,
|
||||||
|
storageChatId: config.storageChatId,
|
||||||
|
storageMessageId: firstPart.storageMessageId,
|
||||||
|
fileName: input.fileName,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
sizeBytes: upload.totalSizeBytes,
|
||||||
|
fileType: input.fileType,
|
||||||
|
uploaderId: input.uploaderId,
|
||||||
|
fileHash: upload.fileHash,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: input.bucketId ?? null,
|
||||||
|
s3Key: input.s3Key ?? null,
|
||||||
|
storageBackend: 'chunked',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: upload.parts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileParts: NewFilePart[] = upload.parts.map((part) => ({
|
||||||
|
fileId: file.id,
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
telegramFileId: part.telegramFileId,
|
||||||
|
telegramFileUniqueId: part.telegramFileUniqueId,
|
||||||
|
storageChatId: config.storageChatId,
|
||||||
|
storageMessageId: part.storageMessageId,
|
||||||
|
sizeBytes: part.sizeBytes,
|
||||||
|
storedSizeBytes: part.storedSizeBytes,
|
||||||
|
compressionAlgorithm: part.compressionAlgorithm,
|
||||||
|
etag: part.etag,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.filePartRepository.insert(fileParts);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a list of object-part sources for reconstructing a chunked file.
|
||||||
|
*
|
||||||
|
* Queries the file-part repository and enriches each part with
|
||||||
|
* the Telegram download URL by calling {@link ITelegramService.getFileInfo}.
|
||||||
|
*
|
||||||
|
* @param file - The File entity whose parts should be resolved.
|
||||||
|
* @returns An ordered list of object part sources ready for streaming.
|
||||||
|
*/
|
||||||
|
async buildChunkedObjectSources(file: FileEntity): Promise<ObjectPartSource[]> {
|
||||||
|
const parts = await this.filePartRepository.listByFileId(file.id);
|
||||||
|
const sources: ObjectPartSource[] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const fileInfo = await this.telegramService.getFileInfo(part.telegramFileId);
|
||||||
|
sources.push({
|
||||||
|
telegramFileId: part.telegramFileId,
|
||||||
|
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
|
||||||
|
sizeBytes: part.sizeBytes,
|
||||||
|
storedSizeBytes: part.storedSizeBytes,
|
||||||
|
compressionAlgorithm: part.compressionAlgorithm,
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an HTTP Response that streams a chunked file's content.
|
||||||
|
*
|
||||||
|
* Supports HTTP range requests for partial content delivery.
|
||||||
|
* The response is constructed by reassembling parts in order and
|
||||||
|
* optionally decompressing gzip-compressed parts.
|
||||||
|
*
|
||||||
|
* @param input - Parameters including the file entity, range, and request ID.
|
||||||
|
* @returns A Response object streaming the requested byte range.
|
||||||
|
*/
|
||||||
|
async createChunkedObjectResponse(input: {
|
||||||
|
file: FileEntity;
|
||||||
|
range: RangeParseResult;
|
||||||
|
reqId: string;
|
||||||
|
}): Promise<Response> {
|
||||||
|
const parts = await this.buildChunkedObjectSources(input.file);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
throw new Error('Chunked object has no parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
return createGetObjectResponse({
|
||||||
|
reqId: input.reqId,
|
||||||
|
contentType: input.file.mimeType,
|
||||||
|
etag: input.file.fileHash || parts.map((p) => p.telegramFileId).join('-'),
|
||||||
|
lastModified:
|
||||||
|
input.file.createdAt instanceof Date
|
||||||
|
? input.file.createdAt
|
||||||
|
: new Date(input.file.createdAt),
|
||||||
|
totalSize: Number(input.file.sizeBytes),
|
||||||
|
parts,
|
||||||
|
range: input.range,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Telegram-specific types used internally by the infrastructure layer.
|
||||||
|
*
|
||||||
|
* These types represent the raw Telegram Bot API response shapes and
|
||||||
|
* the internal abstractions built on top of them. The higher-level domain
|
||||||
|
* types (ForwardResult, TelegramFileInfo) are defined in
|
||||||
|
* src/domain/ports/telegram-service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File reference within a Telegram message result.
|
||||||
|
* Contains identifiers returned by the Telegram API for uploaded media.
|
||||||
|
*/
|
||||||
|
export interface UploadedTelegramFile {
|
||||||
|
/** Telegram file_id for retrieving the file */
|
||||||
|
file_id?: string;
|
||||||
|
/** Telegram unique file_id (stable across bot tokens) */
|
||||||
|
file_unique_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result structure returned by Telegram send* API methods.
|
||||||
|
* Covers all media types a Telegram message can carry.
|
||||||
|
*/
|
||||||
|
export interface TelegramMessageResult {
|
||||||
|
/** Unique message identifier inside the chat */
|
||||||
|
message_id: number;
|
||||||
|
/** Sent document, if applicable */
|
||||||
|
document?: UploadedTelegramFile;
|
||||||
|
/** Sent photo (array of sizes, last element is largest), if applicable */
|
||||||
|
photo?: UploadedTelegramFile[];
|
||||||
|
/** Sent video, if applicable */
|
||||||
|
video?: UploadedTelegramFile;
|
||||||
|
/** Sent audio, if applicable */
|
||||||
|
audio?: UploadedTelegramFile;
|
||||||
|
/** Sent voice message, if applicable */
|
||||||
|
voice?: UploadedTelegramFile;
|
||||||
|
/** Sent animation (GIF), if applicable */
|
||||||
|
animation?: UploadedTelegramFile;
|
||||||
|
/** Sent sticker, if applicable */
|
||||||
|
sticker?: UploadedTelegramFile;
|
||||||
|
/** Sent video note, if applicable */
|
||||||
|
video_note?: UploadedTelegramFile;
|
||||||
|
/** Catch-all for any additional Telegram response fields */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload structure for sending a file via the Telegram Bot API.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type FilePayload = { source: unknown; filename: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional optional payload for Telegram send method calls.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type SendPayload = { caption?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function signature for Telegram send* method calls on a bot instance.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type SendMethod = (
|
||||||
|
chatId: number,
|
||||||
|
filePayload: FilePayload,
|
||||||
|
payload?: SendPayload,
|
||||||
|
) => Promise<TelegramMessageResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from file type identifier to Telegram Bot API method name.
|
||||||
|
*
|
||||||
|
* Each key corresponds to a Telegram media type; the value is the
|
||||||
|
* method name to call on `bot.telegram`.
|
||||||
|
*/
|
||||||
|
export const sendMethodMap: Record<string, string> = {
|
||||||
|
photo: 'sendPhoto',
|
||||||
|
audio: 'sendAudio',
|
||||||
|
video: 'sendVideo',
|
||||||
|
voice: 'sendVoice',
|
||||||
|
animation: 'sendAnimation',
|
||||||
|
sticker: 'sendSticker',
|
||||||
|
document: 'sendDocument',
|
||||||
|
video_note: 'sendDocument',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the uploaded file reference from a Telegram message result
|
||||||
|
* based on the media type present in the result.
|
||||||
|
*
|
||||||
|
* Falls back to looking up the file type key directly on the result object.
|
||||||
|
*
|
||||||
|
* @param result - The message result from a Telegram send* call.
|
||||||
|
* @param fileType - The file type classification (e.g. "document", "photo").
|
||||||
|
* @returns The uploaded file reference, or `undefined` if none was found.
|
||||||
|
*/
|
||||||
|
export const extractUploadedFile = (
|
||||||
|
result: TelegramMessageResult,
|
||||||
|
fileType: string,
|
||||||
|
): UploadedTelegramFile | undefined => {
|
||||||
|
if (result.document) return result.document;
|
||||||
|
if (result.photo) return result.photo?.slice(-1)[0];
|
||||||
|
if (result.video) return result.video;
|
||||||
|
if (result.audio) return result.audio;
|
||||||
|
if (result.voice) return result.voice;
|
||||||
|
if (result.animation) return result.animation;
|
||||||
|
if (result.sticker) return result.sticker;
|
||||||
|
if (result.video_note) return result.video_note;
|
||||||
|
return result[fileType] as UploadedTelegramFile | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the send payload (caption, etc.) for a Telegram send* method call.
|
||||||
|
*
|
||||||
|
* Stickers do not support captions. Documents get a labelled caption
|
||||||
|
* with the file name. All other types use the plain file name as caption.
|
||||||
|
*
|
||||||
|
* @param fileType - The file type (e.g. "document", "photo", "sticker").
|
||||||
|
* @param fileName - The file name to use in the caption.
|
||||||
|
* @returns The payload object with caption (or empty for sticker).
|
||||||
|
*/
|
||||||
|
export const buildSendPayload = (fileType: string, fileName: string): SendPayload => {
|
||||||
|
const basePayload: SendPayload = { caption: fileName };
|
||||||
|
if (fileType === 'sticker') return {};
|
||||||
|
if (fileType === 'document') return { caption: `📁 ${fileName}` };
|
||||||
|
return basePayload;
|
||||||
|
};
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { config } from '../../env';
|
||||||
|
import { cleanupTempFile } from '../../shared/utils/file';
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export type PreparedUpload = {
|
||||||
|
/** Temporary file path on disk */
|
||||||
|
tempPath: string;
|
||||||
|
/** SHA-256 hash of the file contents */
|
||||||
|
fileHash: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** First bytes of the file for MIME detection */
|
||||||
|
signatureBuffer: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fully materialised file record returned from the batcher.
|
||||||
|
*/
|
||||||
|
export type UploadedFile = FileEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item ready for batched upload to Telegram storage.
|
||||||
|
*/
|
||||||
|
export type BatchUploadItem = {
|
||||||
|
/** Prepared upload metadata */
|
||||||
|
prepared: PreparedUpload;
|
||||||
|
/** Original file name */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the file */
|
||||||
|
mimeType: string;
|
||||||
|
/** File type classification (e.g. "document", "photo") */
|
||||||
|
fileType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal pending upload tracking type, extending BatchUploadItem
|
||||||
|
* with resolve/reject callbacks.
|
||||||
|
*/
|
||||||
|
type PendingUpload = BatchUploadItem & {
|
||||||
|
resolve: (file: FileEntity) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Time window in milliseconds during which uploads are batched together. */
|
||||||
|
const BATCH_WINDOW_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batches multiple file uploads into a single ZIP archive before forwarding
|
||||||
|
* them to Telegram storage. This reduces the number of Telegram API calls
|
||||||
|
* and improves throughput for small-file workloads.
|
||||||
|
*
|
||||||
|
* Injects dependencies via constructor — can be used with any
|
||||||
|
* {@link IFileRepository} and {@link ITelegramService} implementation.
|
||||||
|
*/
|
||||||
|
export class UploadBatcher {
|
||||||
|
private readonly pendingUploads: PendingUpload[] = [];
|
||||||
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param fileRepository - Repository for persisting file records.
|
||||||
|
* @param telegramService - Service for forwarding files to Telegram storage.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly fileRepository: IFileRepository,
|
||||||
|
private readonly telegramService: ITelegramService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a NewFile record from a batch item and its archive metadata.
|
||||||
|
*
|
||||||
|
* @param item - The batched upload item.
|
||||||
|
* @param entry - ZIP entry metadata for the individual file.
|
||||||
|
* @param archive - Archive-level Telegram storage metadata.
|
||||||
|
* @returns A NewFile record ready for repository insertion.
|
||||||
|
*/
|
||||||
|
private buildUploadedFile(
|
||||||
|
item: BatchUploadItem,
|
||||||
|
entry: ZipEntry,
|
||||||
|
archive: {
|
||||||
|
telegramFileId: string;
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
storageMessageId: number;
|
||||||
|
fileName: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
},
|
||||||
|
): NewFile {
|
||||||
|
return {
|
||||||
|
publicId: nanoid(),
|
||||||
|
telegramFileId: archive.telegramFileId,
|
||||||
|
telegramFileUniqueId: archive.telegramFileUniqueId,
|
||||||
|
storageChatId: config.storageChatId,
|
||||||
|
storageMessageId: archive.storageMessageId,
|
||||||
|
fileName: item.fileName,
|
||||||
|
mimeType: item.mimeType || 'application/octet-stream',
|
||||||
|
sizeBytes: item.prepared.sizeBytes,
|
||||||
|
fileType: item.fileType,
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: item.prepared.fileHash,
|
||||||
|
archiveTelegramFileId: archive.telegramFileId,
|
||||||
|
archiveStorageMessageId: archive.storageMessageId,
|
||||||
|
archiveFileName: archive.fileName,
|
||||||
|
archiveEntryName: entry.entryName,
|
||||||
|
archiveMimeType: 'application/zip',
|
||||||
|
archiveSizeBytes: archive.sizeBytes,
|
||||||
|
bucketId: null,
|
||||||
|
s3Key: null,
|
||||||
|
storageBackend: null,
|
||||||
|
isDeleted: null,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all pending uploads by zipping them together and sending
|
||||||
|
* the archive to Telegram storage.
|
||||||
|
*/
|
||||||
|
private async flushUploads(): Promise<void> {
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearTimeout(this.flushTimer);
|
||||||
|
this.flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = this.pendingUploads.splice(0);
|
||||||
|
if (batch.length === 0) return;
|
||||||
|
|
||||||
|
let zipTempPath: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zip = await createZip(
|
||||||
|
batch.map((item) => ({ tempPath: item.prepared.tempPath, fileName: item.fileName })),
|
||||||
|
);
|
||||||
|
zipTempPath = zip.tempPath;
|
||||||
|
const archiveFileName = `filedrop-${nanoid()}.zip`;
|
||||||
|
const archiveResult = await this.telegramService.forwardToStorage(
|
||||||
|
createReadStream(zip.tempPath),
|
||||||
|
archiveFileName,
|
||||||
|
'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
const newFileInputs = batch.map((item, index) =>
|
||||||
|
this.buildUploadedFile(item, zip.entries[index], {
|
||||||
|
telegramFileId: archiveResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: archiveResult.telegramFileUniqueId,
|
||||||
|
storageMessageId: archiveResult.storageMessageId,
|
||||||
|
fileName: archiveFileName,
|
||||||
|
sizeBytes: zip.sizeBytes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist each file record through the repository
|
||||||
|
const createdFiles = await Promise.all(
|
||||||
|
newFileInputs.map((input) => this.fileRepository.create(input)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < batch.length; i++) {
|
||||||
|
batch[i].resolve(createdFiles[i]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
for (const item of batch) {
|
||||||
|
item.reject(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Promise.all(batch.map((item) => cleanupTempFile(item.prepared.tempPath)));
|
||||||
|
if (zipTempPath) await cleanupTempFile(zipTempPath);
|
||||||
|
// Reschedule timer if new items arrived during async processing
|
||||||
|
if (this.pendingUploads.length > 0 && !this.flushTimer) {
|
||||||
|
this.flushTimer = setTimeout(() => {
|
||||||
|
void this.flushUploads();
|
||||||
|
}, BATCH_WINDOW_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total size of all pending uploads in bytes.
|
||||||
|
*
|
||||||
|
* @returns The sum of all pending file sizes.
|
||||||
|
*/
|
||||||
|
private getPendingSize(): number {
|
||||||
|
return this.pendingUploads.reduce((total, item) => total + item.prepared.sizeBytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a prepared upload for batched processing.
|
||||||
|
*
|
||||||
|
* The upload is held for up to {@link BATCH_WINDOW_MS} milliseconds
|
||||||
|
* (or until the batch size/byte thresholds in config are exceeded)
|
||||||
|
* before being flushed to Telegram storage.
|
||||||
|
*
|
||||||
|
* @param item - The prepared upload item to enqueue.
|
||||||
|
* @returns A promise that resolves with the fully created File record.
|
||||||
|
*/
|
||||||
|
enqueuePreparedUpload(item: BatchUploadItem): Promise<FileEntity> {
|
||||||
|
return new Promise<FileEntity>((resolve, reject) => {
|
||||||
|
this.pendingUploads.push({ ...item, resolve, reject });
|
||||||
|
|
||||||
|
if (!this.flushTimer) {
|
||||||
|
this.flushTimer = setTimeout(() => {
|
||||||
|
void this.flushUploads();
|
||||||
|
}, BATCH_WINDOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.pendingUploads.length >= config.batchMaxItems ||
|
||||||
|
this.getPendingSize() >= config.batchMaxSizeBytes
|
||||||
|
) {
|
||||||
|
void this.flushUploads();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately flush all pending uploads, regardless of batch size.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves when the flush is complete.
|
||||||
|
*/
|
||||||
|
async flushPendingUploads(): Promise<void> {
|
||||||
|
await this.flushUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of uploads currently waiting in the batch queue.
|
||||||
|
*
|
||||||
|
* @returns The pending upload count.
|
||||||
|
*/
|
||||||
|
getPendingUploadCount(): number {
|
||||||
|
return this.pendingUploads.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import PQueue from 'p-queue';
|
||||||
|
import { config } from '../../env';
|
||||||
|
import logger from '../../shared/logger/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P-queue instance for serialising Telegram upload tasks.
|
||||||
|
*
|
||||||
|
* Concurrency is governed by {@link config.uploadConcurrency}.
|
||||||
|
* Built-in logging emits warnings when the queue grows beyond 5 pending items.
|
||||||
|
*/
|
||||||
|
const uploadQueue = new PQueue({
|
||||||
|
concurrency: config.uploadConcurrency,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Monitor queue growth and emit warnings for large backlogs */
|
||||||
|
uploadQueue.on('add', () => {
|
||||||
|
const stats = getQueueStats();
|
||||||
|
if (stats.size > 5) {
|
||||||
|
logger.warn('Upload queue building up', { pending: stats.pending, size: stats.size });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadQueue.on('next', () => {
|
||||||
|
const stats = getQueueStats();
|
||||||
|
logger.debug('Processing next upload', { pending: stats.pending, size: stats.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue an upload task to be executed by the queue.
|
||||||
|
*
|
||||||
|
* Tasks are executed in FIFO order, subject to the concurrency limit.
|
||||||
|
*
|
||||||
|
* @param task - An async function representing the upload operation.
|
||||||
|
* @returns A promise that resolves with the task's result.
|
||||||
|
*/
|
||||||
|
export const enqueueUpload = <T>(task: () => Promise<T>): Promise<T> => {
|
||||||
|
return uploadQueue.add(task);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current queue statistics.
|
||||||
|
*
|
||||||
|
* @returns An object with `pending` (actively executing) and `size` (waiting) counts.
|
||||||
|
*/
|
||||||
|
export const getQueueStats = (): { pending: number; size: number } => ({
|
||||||
|
pending: uploadQueue.pending,
|
||||||
|
size: uploadQueue.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of items waiting in the queue (not yet started).
|
||||||
|
*
|
||||||
|
* @returns The number of queued items.
|
||||||
|
*/
|
||||||
|
export const getQueueSize = (): number => uploadQueue.size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of items currently being processed.
|
||||||
|
*
|
||||||
|
* @returns The number of pending (in-flight) items.
|
||||||
|
*/
|
||||||
|
export const getPendingCount = (): number => uploadQueue.pending;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all pending items and wait for in-flight ones to finish.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves when the queue is idle after clearing.
|
||||||
|
*/
|
||||||
|
export const clearQueue = async (): Promise<void> => {
|
||||||
|
uploadQueue.clear();
|
||||||
|
await uploadQueue.onIdle();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the queue to become idle (all tasks finished).
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves when no tasks are pending or in-flight.
|
||||||
|
*/
|
||||||
|
export const waitForQueue = async (): Promise<void> => {
|
||||||
|
await uploadQueue.onIdle();
|
||||||
|
};
|
||||||
@@ -1,29 +1,62 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { type Context, Telegraf } from 'telegraf';
|
import { type Context, Telegraf } from 'telegraf';
|
||||||
import { db, files as fileSchema } from './db';
|
import { config } from '../../env';
|
||||||
import { findFileByUniqueId } from './db/files';
|
import type { NewFile } from '../../domain/entities/file';
|
||||||
import { config } from './env';
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
|
import { DrizzleFileRepository } from '../../infrastructure/persistence/repositories/file-repository';
|
||||||
|
import { botPool } from '../../infrastructure/telegram/bot-pool';
|
||||||
import {
|
import {
|
||||||
detectFileType,
|
detectFileType,
|
||||||
extractFileFromMessage,
|
extractFileFromMessage,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
getFileSizeLimit,
|
getFileSizeLimit,
|
||||||
type TelegramMediaMessage,
|
type TelegramMediaMessage,
|
||||||
} from './utils/file';
|
} from '../../shared/utils/file';
|
||||||
import logger from './utils/logger';
|
import logger from '../../shared/logger/index';
|
||||||
import { forwardToStorage } from './utils/telegram';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal bot context shape used by the media event handler.
|
||||||
|
*
|
||||||
|
* Represents the subset of Telegraf's Context that the handler requires
|
||||||
|
* for processing incoming media messages.
|
||||||
|
*/
|
||||||
type BotContext = {
|
type BotContext = {
|
||||||
|
/** The incoming media message with file attachments. */
|
||||||
message: TelegramMediaMessage;
|
message: TelegramMediaMessage;
|
||||||
|
/** The sender of the message. */
|
||||||
from: { id: number };
|
from: { id: number };
|
||||||
|
/** The chat where the message was sent, if available. */
|
||||||
chat?: { id: number };
|
chat?: { id: number };
|
||||||
|
/**
|
||||||
|
* Reply to the message with text.
|
||||||
|
*
|
||||||
|
* @param text - The reply text.
|
||||||
|
* @param extra - Optional reply parameters (e.g. reply_parameters for threading).
|
||||||
|
*/
|
||||||
reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise<unknown>;
|
reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duck-typed object that exposes a Telegraf-style `on()` method
|
||||||
|
* for registering event handlers on multiple event types.
|
||||||
|
*/
|
||||||
type MediaEventRegistrar = {
|
type MediaEventRegistrar = {
|
||||||
|
/**
|
||||||
|
* Register a handler for the given event types.
|
||||||
|
*
|
||||||
|
* @param events - Array of event type strings (e.g. "document", "photo").
|
||||||
|
* @param handler - Async handler receiving the bot context.
|
||||||
|
*/
|
||||||
on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => void;
|
on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replies to a Telegram message with a download URL for the uploaded file.
|
||||||
|
*
|
||||||
|
* @param ctx - The bot context for the incoming message.
|
||||||
|
* @param publicId - The public identifier of the uploaded file.
|
||||||
|
*/
|
||||||
const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
|
const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
|
||||||
const url = `${config.baseUrl}/f/${publicId}`;
|
const url = `${config.baseUrl}/f/${publicId}`;
|
||||||
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
|
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
|
||||||
@@ -31,7 +64,33 @@ const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startBot = async (): Promise<Telegraf<Context>> => {
|
/**
|
||||||
|
* Start the Telegram bot and register message handlers.
|
||||||
|
*
|
||||||
|
* Creates a Telegraf instance, registers a `/start` command handler,
|
||||||
|
* logging middleware, and media event handlers for all supported file types.
|
||||||
|
* Incoming media files are deduplicated by their Telegram unique ID,
|
||||||
|
* forwarded to the storage channel, and persisted with a public download URL.
|
||||||
|
*
|
||||||
|
* @param deps - Optional external dependencies for testing or DI override.
|
||||||
|
* @param deps.telegramService - The Telegram service used to forward files to
|
||||||
|
* the storage channel. Defaults to the singleton BotPool instance.
|
||||||
|
* @param deps.fileRepo - The file repository used for deduplication queries
|
||||||
|
* and persisting new file records. Defaults to a new DrizzleFileRepository.
|
||||||
|
* @returns The launched Telegraf bot instance, suitable for graceful shutdown
|
||||||
|
* via `bot.stop(signal)`.
|
||||||
|
*/
|
||||||
|
export async function startBot(
|
||||||
|
deps: {
|
||||||
|
/** The Telegram service to forward files to storage. */
|
||||||
|
telegramService?: ITelegramService;
|
||||||
|
/** The file repository for deduplication and persistence. */
|
||||||
|
fileRepo?: IFileRepository;
|
||||||
|
} = {},
|
||||||
|
): Promise<Telegraf<Context>> {
|
||||||
|
const telegramService = deps.telegramService ?? botPool;
|
||||||
|
const fileRepo = deps.fileRepo ?? new DrizzleFileRepository();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bot = new Telegraf(config.botToken);
|
const bot = new Telegraf(config.botToken);
|
||||||
|
|
||||||
@@ -74,7 +133,7 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
|
|||||||
return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
|
return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await findFileByUniqueId(fileObj.file_unique_id);
|
const existing = await fileRepo.findByUniqueId(fileObj.file_unique_id);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await replyWithDownloadUrl(ctx, existing.publicId);
|
await replyWithDownloadUrl(ctx, existing.publicId);
|
||||||
@@ -87,25 +146,36 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await forwardToStorage(file_id, fileName, fileType);
|
const result = await telegramService.forwardToStorage(file_id, fileName, fileType);
|
||||||
const publicId = nanoid();
|
const publicId = nanoid();
|
||||||
|
|
||||||
const uploaded = {
|
const uploaded: NewFile = {
|
||||||
publicId: publicId,
|
publicId,
|
||||||
telegramFileId: result.telegramFileId,
|
telegramFileId: result.telegramFileId,
|
||||||
telegramFileUniqueId: result.telegramFileUniqueId,
|
telegramFileUniqueId: result.telegramFileUniqueId,
|
||||||
storageChatId: config.storageChatId,
|
storageChatId: config.storageChatId,
|
||||||
storageMessageId: result.storageMessageId,
|
storageMessageId: result.storageMessageId,
|
||||||
fileName: fileName,
|
fileName,
|
||||||
mimeType: mime_type || 'application/octet-stream',
|
mimeType: mime_type || 'application/octet-stream',
|
||||||
sizeBytes: fileSize,
|
sizeBytes: fileSize,
|
||||||
fileType: fileType,
|
fileType,
|
||||||
uploaderId: ctx.from.id,
|
uploaderId: ctx.from.id,
|
||||||
createdAt: new Date(),
|
fileHash: null,
|
||||||
updatedAt: new Date(),
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: null,
|
||||||
|
s3Key: null,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(fileSchema).values(uploaded);
|
await fileRepo.create(uploaded);
|
||||||
|
|
||||||
await replyWithDownloadUrl(ctx, publicId);
|
await replyWithDownloadUrl(ctx, publicId);
|
||||||
|
|
||||||
@@ -134,4 +204,4 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
|
|||||||
logger.error('Failed to start bot', { error: getErrorMessage(error) });
|
logger.error('Failed to start bot', { error: getErrorMessage(error) });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { config } from '../../../config/index';
|
||||||
|
import {
|
||||||
|
clearSessionCookie,
|
||||||
|
createSessionCookie,
|
||||||
|
getAuthSession,
|
||||||
|
isAuthEnabled,
|
||||||
|
checkBearerToken,
|
||||||
|
} from '../../../utils/auth';
|
||||||
|
import {
|
||||||
|
createLoginUseCase,
|
||||||
|
createLogoutUseCase,
|
||||||
|
createMeUseCase,
|
||||||
|
type AuthSession,
|
||||||
|
} from '../../../application/use-cases/authenticate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper that builds a JSON Response with optional extra headers.
|
||||||
|
*
|
||||||
|
* @param data - The JSON-serialisable body.
|
||||||
|
* @param status - HTTP status code (default 200).
|
||||||
|
* @param headers - Optional extra response headers.
|
||||||
|
* @returns A JSON Response.
|
||||||
|
*/
|
||||||
|
const json = (data: unknown, status = 200, headers: Record<string, string> = {}): Response =>
|
||||||
|
Response.json(data, { status, headers });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a standard 404 Not Found JSON response.
|
||||||
|
*
|
||||||
|
* Used to hide auth endpoints when auth is disabled.
|
||||||
|
*
|
||||||
|
* @returns A 404 JSON response.
|
||||||
|
*/
|
||||||
|
const notFound = (): Response => json({ error: 'Not found' }, 404);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the login request body, extracting the `token` field.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a JSON body.
|
||||||
|
* @returns The login token payload, or `null` when the body is invalid.
|
||||||
|
*/
|
||||||
|
const readLoginBody = async (req: Request): Promise<{ token: string } | null> => {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as { token?: unknown };
|
||||||
|
if (typeof body.token !== 'string' || body.token.length === 0) return null;
|
||||||
|
return { token: body.token };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the login endpoint.
|
||||||
|
*
|
||||||
|
* Reads the admin API token from the request body, validates it via the
|
||||||
|
* login use case, and sets a session cookie on success.
|
||||||
|
*
|
||||||
|
* When auth is disabled the endpoint returns 404.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns A JSON response with login status and a Set-Cookie header.
|
||||||
|
*/
|
||||||
|
export const handleLogin = async (req: Request): Promise<Response> => {
|
||||||
|
if (!isAuthEnabled()) return notFound();
|
||||||
|
|
||||||
|
const body = await readLoginBody(req);
|
||||||
|
if (!body) return json({ error: 'Token is required' }, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginUseCase = createLoginUseCase({
|
||||||
|
config: {
|
||||||
|
adminApiToken: config.adminApiToken,
|
||||||
|
sessionCookieName: config.sessionCookieName,
|
||||||
|
sessionMaxAgeMs: config.sessionMaxAgeMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loginUseCase({ token: body.token });
|
||||||
|
|
||||||
|
return json({ username: result.username }, 200, {
|
||||||
|
'set-cookie': createSessionCookie('admin'),
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Invalid token';
|
||||||
|
if (message === 'Invalid token') {
|
||||||
|
return json({ error: 'Invalid token' }, 401);
|
||||||
|
}
|
||||||
|
return json({ error: message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the logout endpoint.
|
||||||
|
*
|
||||||
|
* Clears the session cookie and returns a success response.
|
||||||
|
*
|
||||||
|
* @returns A JSON response with a cleared Set-Cookie header.
|
||||||
|
*/
|
||||||
|
export const handleLogout = async (): Promise<Response> => {
|
||||||
|
const logoutUseCase = createLogoutUseCase();
|
||||||
|
await logoutUseCase();
|
||||||
|
|
||||||
|
return json({ success: true }, 200, {
|
||||||
|
'set-cookie': clearSessionCookie(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the current-user (me) endpoint.
|
||||||
|
*
|
||||||
|
* Extracts the authentication session from the request (cookie or bearer
|
||||||
|
* token) and returns the user info via the me use case.
|
||||||
|
*
|
||||||
|
* When auth is disabled the endpoint returns 404.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns A JSON response with user info, or 401 when unauthenticated.
|
||||||
|
*/
|
||||||
|
export const handleMe = async (req: Request): Promise<Response> => {
|
||||||
|
if (!isAuthEnabled()) return notFound();
|
||||||
|
|
||||||
|
const session: AuthSession | null = getAuthSession(req);
|
||||||
|
if (!session && !checkBearerToken(req.headers.get('authorization'))) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meUseCase = createMeUseCase({
|
||||||
|
config: {
|
||||||
|
adminApiToken: config.adminApiToken,
|
||||||
|
sessionCookieName: config.sessionCookieName,
|
||||||
|
sessionMaxAgeMs: config.sessionMaxAgeMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSession = session ?? {
|
||||||
|
username: 'admin',
|
||||||
|
expiresAt: null,
|
||||||
|
method: 'bearer' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await meUseCase(activeSession);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
username: result.username,
|
||||||
|
expiresAt: result.expiresAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { config } from '../../../config/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 { getFileInfo, type TelegramFileInfo } from '../../../utils/telegram';
|
||||||
|
import { locateZipEntry } from '../../../utils/zip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended Request type that includes route parameter access.
|
||||||
|
*/
|
||||||
|
type RequestWithParams = Request & {
|
||||||
|
/** Route parameters extracted by the router. */
|
||||||
|
params?: {
|
||||||
|
/** Public file identifier. */
|
||||||
|
public_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a string into a `string | string[]` for cookie append operations.
|
||||||
|
*
|
||||||
|
* @param value - The string value to wrap.
|
||||||
|
* @returns The value as a single-element tuple.
|
||||||
|
*/
|
||||||
|
const asArray = (value: string): string[] => [value];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves Telegram file metadata for a given file ID, using the in-memory
|
||||||
|
* cache to avoid repeated API calls to Telegram.
|
||||||
|
*
|
||||||
|
* @param telegramFileId - The Telegram file identifier to resolve.
|
||||||
|
* @param publicId - The public file ID (used for logging).
|
||||||
|
* @returns The resolved Telegram file info.
|
||||||
|
*/
|
||||||
|
const getTelegramFileInfo = async (telegramFileId: string, publicId: string): Promise<TelegramFileInfo> => {
|
||||||
|
const cacheKey = `file_info_${telegramFileId}`;
|
||||||
|
const cached = fileInfoCache.get(cacheKey) as TelegramFileInfo | null;
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
logger.debug('File info from cache', { publicId, cacheKey });
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInfo = await getFileInfo(telegramFileId);
|
||||||
|
fileInfoCache.set(cacheKey, fileInfo);
|
||||||
|
logger.debug('File info cached', { publicId, cacheKey });
|
||||||
|
|
||||||
|
return fileInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Telegram CDN download URL from a file path and bot token.
|
||||||
|
*
|
||||||
|
* @param filePath - The Telegram file path returned by getFile.
|
||||||
|
* @param botToken - The bot token used to authenticate the download.
|
||||||
|
* @returns The full Telegram CDN URL.
|
||||||
|
*/
|
||||||
|
const buildTelegramFileUrl = (filePath: string, botToken: string): string =>
|
||||||
|
`https://api.telegram.org/file/bot${botToken}/${filePath}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises a file name for use in a Content-Disposition header, removing
|
||||||
|
* characters that could enable header injection.
|
||||||
|
*
|
||||||
|
* @param fileName - The raw file name.
|
||||||
|
* @returns The sanitised file name.
|
||||||
|
*/
|
||||||
|
const sanitizeFilenameHeader = (fileName: string): string =>
|
||||||
|
fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a JSON error response with the given status code and message.
|
||||||
|
*
|
||||||
|
* @param status - HTTP status code.
|
||||||
|
* @param error - Error message.
|
||||||
|
* @returns A JSON Response.
|
||||||
|
*/
|
||||||
|
const fail = (status: number, error: string): Response => Response.json({ error }, { status });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles file redirect requests.
|
||||||
|
*
|
||||||
|
* Looks up a file by its public identifier and determines the best delivery
|
||||||
|
* method:
|
||||||
|
* - **chunked** files are streamed via the chunked-object response builder.
|
||||||
|
* - **archive-entry** files are extracted from a Telegram-stored zip archive
|
||||||
|
* and streamed as a single file.
|
||||||
|
* - **regular** files are redirected to the Telegram CDN URL (302).
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a `public_id` route parameter.
|
||||||
|
* @returns A redirect or streaming response, or a JSON error.
|
||||||
|
*/
|
||||||
|
export const handleFileRedirect = async (req: RequestWithParams): Promise<Response> => {
|
||||||
|
const publicId = req.params?.public_id;
|
||||||
|
try {
|
||||||
|
if (!publicId) {
|
||||||
|
return fail(400, 'Missing file id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { findFileByPublicId } = await import('../../../db/files');
|
||||||
|
const file = await findFileByPublicId(publicId);
|
||||||
|
if (!file) {
|
||||||
|
logger.warn('File not found', { publicId });
|
||||||
|
return fail(404, 'File not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.storageBackend === 'chunked') {
|
||||||
|
if (file.archiveEntryName) {
|
||||||
|
return fail(501, 'Archive entry extraction is not supported for chunked files');
|
||||||
|
}
|
||||||
|
const range = { type: 'none' as const };
|
||||||
|
return createChunkedObjectResponse({ file, range, reqId: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveEntryName = file.archiveEntryName;
|
||||||
|
if (archiveEntryName) {
|
||||||
|
const archiveFileId = file.archiveTelegramFileId || file.telegramFileId;
|
||||||
|
const archiveInfo = await getTelegramFileInfo(archiveFileId, publicId);
|
||||||
|
const archiveResponse = await fetch(
|
||||||
|
buildTelegramFileUrl(archiveInfo.file_path, archiveInfo.bot_token),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!archiveResponse.ok) {
|
||||||
|
logger.error('Archive download failed', { publicId, status: archiveResponse.status });
|
||||||
|
return fail(500, 'Server error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempZipPath = `/tmp/filedrop-dl-${nanoid()}.zip`;
|
||||||
|
await Bun.write(tempZipPath, archiveResponse);
|
||||||
|
|
||||||
|
const loc = await locateZipEntry(tempZipPath, archiveEntryName);
|
||||||
|
if (!loc) {
|
||||||
|
await cleanupTempFile(tempZipPath);
|
||||||
|
logger.error('Archive entry not found', { publicId, archiveEntryName });
|
||||||
|
return fail(404, 'File not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = createReadStream(tempZipPath, {
|
||||||
|
start: loc.start,
|
||||||
|
end: loc.start + loc.length - 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on('close', () => {
|
||||||
|
void cleanupTempFile(tempZipPath);
|
||||||
|
});
|
||||||
|
fileStream.on('error', () => {
|
||||||
|
void cleanupTempFile(tempZipPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(fileStream as unknown as ReadableStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.mimeType || 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
|
||||||
|
'Content-Length': String(loc.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInfo = await getTelegramFileInfo(file.telegramFileId, publicId);
|
||||||
|
const redirectUrl = buildTelegramFileUrl(fileInfo.file_path, fileInfo.bot_token);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: redirectUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error('File redirect error', { publicId, error: getErrorMessage(error) });
|
||||||
|
return fail(500, 'Server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles file info requests.
|
||||||
|
*
|
||||||
|
* Looks up a file by its public identifier and returns its metadata as JSON.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a `public_id` route parameter.
|
||||||
|
* @returns A JSON response with file metadata, or 404 when not found.
|
||||||
|
*/
|
||||||
|
export const handleFileInfo = async (req: RequestWithParams): Promise<Response> => {
|
||||||
|
const publicId = req.params?.public_id;
|
||||||
|
try {
|
||||||
|
if (!publicId) {
|
||||||
|
return fail(400, 'Missing file id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { findFileByPublicId } = await import('../../../db/files');
|
||||||
|
const file = await findFileByPublicId(publicId);
|
||||||
|
if (!file) {
|
||||||
|
logger.warn('File not found', { publicId });
|
||||||
|
return fail(404, 'File not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
public_id: file.publicId,
|
||||||
|
file_name: file.fileName,
|
||||||
|
mime_type: file.mimeType,
|
||||||
|
size_bytes: file.sizeBytes,
|
||||||
|
file_type: file.fileType,
|
||||||
|
created_at: formatCreatedAt(file.createdAt),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error('File info error', { publicId, error: getErrorMessage(error) });
|
||||||
|
return fail(500, 'Server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../../../infrastructure/persistence/drizzle/index';
|
||||||
|
import { getErrorMessage } from '../../../shared/utils/file';
|
||||||
|
import logger from '../../../shared/logger/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the health-check endpoint.
|
||||||
|
*
|
||||||
|
* Verifies database connectivity by executing a simple `SELECT 1` query.
|
||||||
|
* Returns a 200 response with `{ status: 'ok' }` when the database is
|
||||||
|
* reachable, or a 500 response with the error details when it is not.
|
||||||
|
*
|
||||||
|
* @param _req - The incoming HTTP request (unused).
|
||||||
|
* @returns A JSON response indicating the database health status.
|
||||||
|
*/
|
||||||
|
export const handleHealth = async (_req: Request): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
await db.execute(sql`SELECT 1`);
|
||||||
|
return Response.json({ status: 'ok' }, { status: 200 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
logger.error('Health check failed', { error: message });
|
||||||
|
return Response.json({ status: 'error', error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { BunFile } from 'bun';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the home/dashboard page request.
|
||||||
|
*
|
||||||
|
* Reads the `home.html` file from the adjacent directory and serves it as
|
||||||
|
* an HTML response with UTF-8 charset.
|
||||||
|
*
|
||||||
|
* @returns An HTML response containing the home page content.
|
||||||
|
*/
|
||||||
|
export const handleHome = async (): Promise<Response> => {
|
||||||
|
const html = await (Bun.file(`${import.meta.dir}/home.html`) as BunFile).text();
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FileDrop · S3 File Manager</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff; --bg2: #f5f5f5; --text: #1a1a1a;
|
||||||
|
--text2: #666; --border: #e0e0e0; --accent: #2563eb;
|
||||||
|
--danger: #dc2626; --radius: 8px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --bg2: #161b22; --text: #c9d1d9;
|
||||||
|
--text2: #8b949e; --border: #30363d; --accent: #58a6ff;
|
||||||
|
--danger: #f85149;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 24px; background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0; z-index: 50;
|
||||||
|
}
|
||||||
|
.topbar .logo { font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.topbar select, .topbar button {
|
||||||
|
padding: 6px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg);
|
||||||
|
color: var(--text); font-size: 0.875rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal input {
|
||||||
|
width: 100%; padding: 8px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg);
|
||||||
|
color: var(--text); margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.topbar button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.topbar .spacer { flex: 1; }
|
||||||
|
.topbar .search input {
|
||||||
|
padding: 6px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg);
|
||||||
|
color: var(--text); font-size: 0.875rem; width: 200px;
|
||||||
|
}
|
||||||
|
.file-list { padding: 16px 24px; }
|
||||||
|
.breadcrumb {
|
||||||
|
padding: 8px 0; margin-bottom: 8px; font-size: 0.9rem;
|
||||||
|
color: var(--accent); cursor: pointer;
|
||||||
|
}
|
||||||
|
.breadcrumb span:hover { text-decoration: underline; }
|
||||||
|
.breadcrumb .sep { color: var(--text2); margin: 0 4px; }
|
||||||
|
.file-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px; border-radius: var(--radius);
|
||||||
|
cursor: pointer; transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.file-row:hover { background: var(--bg2); }
|
||||||
|
.file-row .icon { font-size: 1.2rem; width: 28px; text-align: center; flex-shrink: 0; }
|
||||||
|
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-row .size { width: 80px; text-align: right; color: var(--text2); font-size: 0.85rem; }
|
||||||
|
.file-row .date { width: 140px; color: var(--text2); font-size: 0.85rem; }
|
||||||
|
.file-row .actions { display: flex; gap: 4px; }
|
||||||
|
.file-row .actions button {
|
||||||
|
padding: 4px 8px; border: none; border-radius: 4px;
|
||||||
|
background: transparent; color: var(--text2); cursor: pointer; font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.file-row .actions button:hover { color: var(--text); background: var(--border); }
|
||||||
|
.dropzone {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
padding: 12px 24px; background: var(--bg2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center; color: var(--text2); font-size: 0.85rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.dropzone.dragover { background: var(--accent); color: #fff; }
|
||||||
|
.progress-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); display: flex;
|
||||||
|
align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.progress-card {
|
||||||
|
background: var(--bg); padding: 24px; border-radius: var(--radius);
|
||||||
|
min-width: 300px; max-width: 500px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px; background: var(--border); border-radius: 4px;
|
||||||
|
margin: 12px 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar .fill {
|
||||||
|
height: 100%; background: var(--accent);
|
||||||
|
transition: width 0.2s; width: 0%;
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); display: flex;
|
||||||
|
align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg); padding: 24px; border-radius: var(--radius);
|
||||||
|
min-width: 360px; max-width: 500px;
|
||||||
|
}
|
||||||
|
.modal h3 { margin-bottom: 16px; }
|
||||||
|
.modal .buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.modal .buttons button {
|
||||||
|
padding: 8px 16px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg); color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal .buttons .primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.modal .buttons .danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
|
.empty { text-align: center; padding: 48px 24px; color: var(--text2); }
|
||||||
|
.empty h2 { font-size: 1.2rem; margin-bottom: 8px; }
|
||||||
|
.auth-screen {
|
||||||
|
position: fixed; inset: 0; z-index: 200; display: none;
|
||||||
|
align-items: center; justify-content: center; padding: 24px;
|
||||||
|
background: linear-gradient(135deg, var(--bg), var(--bg2));
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: min(100%, 380px); padding: 28px; border: 1px solid var(--border);
|
||||||
|
border-radius: 16px; background: var(--bg); box-shadow: 0 20px 60px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.auth-card h1 { font-size: 1.45rem; margin-bottom: 8px; }
|
||||||
|
.auth-card p { color: var(--text2); margin-bottom: 18px; }
|
||||||
|
.auth-card input {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg2); color: var(--text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.auth-card button {
|
||||||
|
width: 100%; padding: 10px 14px; border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius); background: var(--accent); color: #fff;
|
||||||
|
cursor: pointer; font-weight: 600;
|
||||||
|
}
|
||||||
|
.auth-card button:disabled { opacity: 0.7; cursor: wait; }
|
||||||
|
.auth-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="authScreen" class="auth-screen">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>📦 FileDrop</h1>
|
||||||
|
<p>Enter admin token to continue.</p>
|
||||||
|
<input id="authTokenInput" type="password" placeholder="Admin token" autocomplete="current-password">
|
||||||
|
<div id="authError" class="auth-error" style="display:none"></div>
|
||||||
|
<button id="authLoginBtn" type="button">Login</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="logo">📦 FileDrop</span>
|
||||||
|
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
|
||||||
|
<option value="">— Select bucket —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
|
||||||
|
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
|
||||||
|
<button id="logoutBtn" type="button" onclick="window.logout()" style="display:none">Logout</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<div class="search">
|
||||||
|
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="window.debouncedSearch()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="breadcrumb" class="breadcrumb" style="display:none;padding:8px 24px"></div>
|
||||||
|
<div id="fileList" class="file-list">
|
||||||
|
<div class="empty"><h2>Select a bucket to get started</h2><p>Choose a bucket from the dropdown above, or create a new one.</p></div>
|
||||||
|
</div>
|
||||||
|
<div id="dropzone" class="dropzone" style="display:none">📁 Drop files here or click to upload</div>
|
||||||
|
<div id="progressOverlay" class="progress-overlay" style="display:none">
|
||||||
|
<div class="progress-card">
|
||||||
|
<h3>Uploading...</h3>
|
||||||
|
<div id="progressFileName"></div>
|
||||||
|
<div class="progress-bar"><div id="progressFill" class="fill"></div></div>
|
||||||
|
<div id="progressPercent" style="font-size:0.85rem;color:var(--text2)">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="modalOverlay" class="modal-overlay" style="display:none" onclick="closeModal(event)">
|
||||||
|
<div id="modalContent" class="modal" onclick="event.stopPropagation()"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let currentBucket = null, currentPrefix = '', currentObjects = [], currentPrefixes = [], allBuckets = [], searchTimer = null;
|
||||||
|
const setAuthError = (message) => {
|
||||||
|
const errorEl = document.getElementById('authError');
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.style.display = message ? 'block' : 'none';
|
||||||
|
};
|
||||||
|
const showAuthScreen = () => {
|
||||||
|
document.getElementById('authScreen').style.display = 'flex';
|
||||||
|
document.getElementById('logoutBtn').style.display = 'none';
|
||||||
|
setTimeout(() => document.getElementById('authTokenInput')?.focus(), 50);
|
||||||
|
};
|
||||||
|
const hideAuthScreen = (showLogout) => {
|
||||||
|
document.getElementById('authScreen').style.display = 'none';
|
||||||
|
document.getElementById('logoutBtn').style.display = showLogout ? 'inline-block' : 'none';
|
||||||
|
};
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/me');
|
||||||
|
if (res.ok) { hideAuthScreen(true); return true; }
|
||||||
|
if (res.status === 401) { showAuthScreen(); return false; }
|
||||||
|
if (res.status === 404) { hideAuthScreen(false); return true; }
|
||||||
|
setAuthError('Unable to verify login status. Please try again.');
|
||||||
|
showAuthScreen(); return false;
|
||||||
|
} catch {
|
||||||
|
setAuthError('Network error while checking login status.');
|
||||||
|
showAuthScreen(); return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const input = document.getElementById('authTokenInput');
|
||||||
|
const btn = document.getElementById('authLoginBtn');
|
||||||
|
const token = input.value.trim();
|
||||||
|
if (!token) { setAuthError('Admin token is required.'); input.focus(); return; }
|
||||||
|
btn.disabled = true; btn.textContent = 'Logging in...'; setAuthError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
if (res.ok) { hideAuthScreen(true); input.value = ''; await loadBuckets(); return; }
|
||||||
|
const body = await res.json().catch(() => ({ error: 'Login failed' }));
|
||||||
|
setAuthError(body.error || 'Login failed');
|
||||||
|
} catch {
|
||||||
|
setAuthError('Network error while logging in.');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Login';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch('/api/v1/auth/logout', { method: 'POST' }).catch(() => {});
|
||||||
|
currentBucket = null; currentPrefix = ''; currentObjects = []; currentPrefixes = [];
|
||||||
|
document.getElementById('bucketSelect').innerHTML = '<option value="">— Select bucket —</option>';
|
||||||
|
document.getElementById('fileList').innerHTML = '<div class="empty"><h2>Logged out</h2><p>Enter the admin token to continue.</p></div>';
|
||||||
|
document.getElementById('dropzone').style.display = 'none';
|
||||||
|
showAuthScreen();
|
||||||
|
};
|
||||||
|
const api = async (path, opts = {}) => {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); throw new Error(body.error || res.statusText); }
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
const apiJson = async (path, opts = {}) => { const res = await api(path, { headers: { 'content-type': 'application/json' }, ...opts }); return res.json(); };
|
||||||
|
const loadBuckets = async () => {
|
||||||
|
const data = await apiJson('/api/v1/buckets');
|
||||||
|
allBuckets = data.buckets || [];
|
||||||
|
const sel = document.getElementById('bucketSelect');
|
||||||
|
sel.innerHTML = `<option value="">— Select bucket —</option>${allBuckets.map(b => `<option value="${b.name}">${b.name} (${b.objectCount})</option>`).join('')}`;
|
||||||
|
if (currentBucket) sel.value = currentBucket;
|
||||||
|
};
|
||||||
|
const switchBucket = async (name) => {
|
||||||
|
currentBucket = name || null; currentPrefix = '';
|
||||||
|
if (name) { await loadObjects(); document.getElementById('dropzone').style.display = 'block'; }
|
||||||
|
else {
|
||||||
|
document.getElementById('fileList').innerHTML = '<div class="empty"><h2>Select a bucket</h2><p>Choose a bucket from the dropdown above.</p></div>';
|
||||||
|
document.getElementById('breadcrumb').style.display = 'none'; document.getElementById('dropzone').style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderBreadcrumb = () => {
|
||||||
|
const bc = document.getElementById('breadcrumb');
|
||||||
|
if (!currentPrefix) { bc.style.display = 'none'; return; }
|
||||||
|
bc.style.display = 'block';
|
||||||
|
const parts = currentPrefix.split('/').filter(Boolean);
|
||||||
|
bc.innerHTML = `<span onclick="window.navigateTo('')">${currentBucket}</span>`;
|
||||||
|
let accumulated = '';
|
||||||
|
for (const part of parts) { accumulated += `${part}/`; bc.innerHTML += `<span class="sep">/</span><span onclick="window.navigateTo('${accumulated}')">${part}</span>`; }
|
||||||
|
};
|
||||||
|
const navigateTo = (prefix) => { currentPrefix = prefix; loadObjects(); };
|
||||||
|
const loadObjects = async () => {
|
||||||
|
if (!currentBucket) return;
|
||||||
|
const searchVal = document.getElementById('searchInput').value;
|
||||||
|
const prefix = searchVal || currentPrefix;
|
||||||
|
const url = `/api/v1/buckets/${encodeURIComponent(currentBucket)}/objects?prefix=${encodeURIComponent(prefix)}&delimiter=/&max-keys=200`;
|
||||||
|
try {
|
||||||
|
const data = await apiJson(url);
|
||||||
|
currentObjects = data.objects || []; currentPrefixes = data.prefixes || [];
|
||||||
|
renderFileList(); renderBreadcrumb();
|
||||||
|
} catch (e) { document.getElementById('fileList').innerHTML = `<div class="empty"><h2>Error</h2><p>${e.message}</p></div>`; }
|
||||||
|
};
|
||||||
|
const renderFileList = () => {
|
||||||
|
const container = document.getElementById('fileList');
|
||||||
|
if (currentPrefixes.length === 0 && currentObjects.length === 0) { container.innerHTML = '<div class="empty"><h2>This bucket is empty</h2><p>Drop files here to upload.</p></div>'; return; }
|
||||||
|
let html = '';
|
||||||
|
for (const prefix of currentPrefixes) {
|
||||||
|
const displayName = prefix.replace(currentPrefix, '');
|
||||||
|
html += `<div class="file-row" onclick="window.navigateTo('${prefix}')"><span class="icon">🗂</span><span class="name">${displayName.endsWith('/') ? displayName : `${displayName}/`}</span><span class="size">—</span><span class="date"></span><span class="actions"></span></div>`;
|
||||||
|
}
|
||||||
|
for (const obj of currentObjects) {
|
||||||
|
const displayName = obj.key.replace(currentPrefix, '');
|
||||||
|
html += `<div class="file-row"><span class="icon">📄</span><span class="name">${escapeHtml(displayName)}</span><span class="size">${formatSize(obj.sizeBytes)}</span><span class="date">${formatDate(obj.lastModified)}</span><span class="actions"><button onclick="event.stopPropagation();downloadObject('${obj.key}')" title="Download">⬇</button><button onclick="event.stopPropagation();copyLink('${obj.key}')" title="Copy link">🔗</button><button onclick="event.stopPropagation();deleteObject('${obj.key}')" title="Delete">🗑</button></span></div>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
};
|
||||||
|
const formatSize = (bytes) => { const size = Number(bytes); if (!Number.isFinite(size) || size <= 0) return '0 B'; const u = ['B','KB','MB','GB','TB']; let i=0,s=size; while(s>=1024&&i<u.length-1){s/=1024;i++} return `${s.toFixed(i>0?1:0)} ${u[i]}`; };
|
||||||
|
const formatDate = (iso) => { if(!iso)return ''; return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); };
|
||||||
|
const escapeHtml = (s) => { const d=document.createElement('div');d.textContent=s;return d.innerHTML; };
|
||||||
|
const debouncedSearch = () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadObjects, 300); };
|
||||||
|
const downloadObject = async (key) => { window.open(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`,'_blank'); };
|
||||||
|
const copyLink = (key) => { navigator.clipboard.writeText(`${window.location.origin}/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`).catch(()=>{}); };
|
||||||
|
const deleteObject = async (key) => {
|
||||||
|
if(!confirm(`Delete "${key}"?`))return;
|
||||||
|
try{await api(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/${encodeURIComponent(key)}`,{method:'DELETE'});await loadObjects();}
|
||||||
|
catch(e){alert(`Delete failed: ${e.message}`);}
|
||||||
|
};
|
||||||
|
const uploadFiles = async (files) => {
|
||||||
|
if(!currentBucket||files.length===0)return;
|
||||||
|
const overlay=document.getElementById('progressOverlay'), fill=document.getElementById('progressFill'), pn=document.getElementById('progressFileName'), pp=document.getElementById('progressPercent');
|
||||||
|
overlay.style.display='flex';
|
||||||
|
for(let i=0;i<files.length;i++){
|
||||||
|
const file=files[i]; pn.textContent=`${i+1}/${files.length}: ${file.name}`; fill.style.width='0%'; pp.textContent='0%';
|
||||||
|
await new Promise((resolve,reject)=>{
|
||||||
|
const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name);
|
||||||
|
const xhr=new XMLHttpRequest();
|
||||||
|
xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=`${p}%`;pp.textContent=`${p}%`;}};
|
||||||
|
xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300)resolve();else reject(new Error(xhr.statusText));};
|
||||||
|
xhr.onerror=()=>reject(new Error('Upload failed'));
|
||||||
|
xhr.open('POST',`/api/v1/buckets/${encodeURIComponent(currentBucket)}/upload`); xhr.send(fd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
overlay.style.display='none'; await loadObjects();
|
||||||
|
};
|
||||||
|
const dropzone=document.getElementById('dropzone');
|
||||||
|
dropzone.addEventListener('dragover',e=>{e.preventDefault();dropzone.classList.add('dragover');});
|
||||||
|
dropzone.addEventListener('dragleave',()=>dropzone.classList.remove('dragover'));
|
||||||
|
dropzone.addEventListener('drop',e=>{e.preventDefault();dropzone.classList.remove('dragover');if(e.dataTransfer.files.length>0)uploadFiles(e.dataTransfer.files);});
|
||||||
|
dropzone.addEventListener('click',()=>{const i=document.createElement('input');i.type='file';i.multiple=true;i.onchange=()=>{if(i.files.length>0)uploadFiles(i.files);};i.click();});
|
||||||
|
const showModal=(html)=>{document.getElementById('modalContent').innerHTML=html;document.getElementById('modalOverlay').style.display='flex';};
|
||||||
|
const closeModal=(e)=>{if(e&&e.target!==e.currentTarget)return;document.getElementById('modalOverlay').style.display='none';};
|
||||||
|
const showCreateBucketModal=()=>{showModal(`<h3>Create Bucket</h3><input id="bucketNameInput" type="text" placeholder="my-bucket-name" pattern="[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]"><p style="font-size:0.8rem;color:var(--text2);margin-bottom:12px">Lowercase, 3-63 chars, no underscores</p><div class="buttons"><button onclick="closeModal()">Cancel</button><button class="primary" onclick="createBucket()">Create</button></div>`);setTimeout(()=>document.getElementById('bucketNameInput')?.focus(),100);};
|
||||||
|
const createBucket=async()=>{const n=document.getElementById('bucketNameInput').value.trim();if(!n)return;try{await apiJson('/api/v1/buckets',{method:'POST',body:JSON.stringify({name:n})});closeModal();await loadBuckets();document.getElementById('bucketSelect').value=n;await switchBucket(n);}catch(e){alert(`Failed: ${e.message}`);}};
|
||||||
|
const showCredentialsModal=()=>{showModal(`<h3>S3 Credentials</h3><p style="margin-bottom:12px;font-size:0.85rem;color:var(--text2)">Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)</p><label style="font-size:0.85rem;font-weight:600">Endpoint URL</label><input type="text" value="${window.location.origin}" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Region</label><input type="text" value="us-east-1" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Access Key</label><input id="s3AccessKey" type="text" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Secret Key</label><input id="s3SecretKey" type="password" readonly onclick="this.select()"><div class="buttons"><button type="button" onclick="window.closeModal()">Close</button></div>`);};
|
||||||
|
const init=async()=>{if(await checkAuth())await loadBuckets();};
|
||||||
|
document.getElementById('authLoginBtn').addEventListener('click',handleLogin);
|
||||||
|
document.getElementById('authTokenInput').addEventListener('keydown',e=>{if(e.key==='Enter')handleLogin();});
|
||||||
|
Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal, logout });
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
|||||||
|
import { createWriteStream } from 'node:fs';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { config } from '../../../config/index';
|
||||||
|
import {
|
||||||
|
buildUploadResponse,
|
||||||
|
checkFileSize,
|
||||||
|
cleanupTempFile,
|
||||||
|
computeHash,
|
||||||
|
ensureExtension,
|
||||||
|
extractMimeType,
|
||||||
|
getErrorMessage,
|
||||||
|
getFileType,
|
||||||
|
} 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 { findFileByHash } from '../../../db/files';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum allowed size (in bytes) for a base64 JSON upload.
|
||||||
|
* JSON uploads are limited to 50 MB because base64 encoding adds ~33%
|
||||||
|
* overhead and large payloads strain the JSON parser.
|
||||||
|
*/
|
||||||
|
const JSON_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Number of leading bytes read for magic-byte / signature detection. */
|
||||||
|
const SIGNATURE_BYTES = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload structure accepted by the JSON upload endpoint.
|
||||||
|
*/
|
||||||
|
interface JsonUploadPayload {
|
||||||
|
/** Base64-encoded file data (optionally with a data URI prefix). */
|
||||||
|
file?: unknown;
|
||||||
|
/** Optional file name. */
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a base64-encoded file string, optionally stripping the data URI
|
||||||
|
* prefix.
|
||||||
|
*
|
||||||
|
* Accepts both bare base64 strings and RFC 2397 data URIs (e.g.
|
||||||
|
* `data:image/png;base64,...`).
|
||||||
|
*
|
||||||
|
* @param file - The base64 string, with or without a data URI prefix.
|
||||||
|
* @returns The raw base64 payload and the detected MIME type.
|
||||||
|
*/
|
||||||
|
const parseBase64File = (file: string): { base64Data: string; mimeType: string } => {
|
||||||
|
if (!file.startsWith('data:')) {
|
||||||
|
return { base64Data: file, mimeType: 'application/octet-stream' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = file.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
return match
|
||||||
|
? { base64Data: match[2], mimeType: match[1] }
|
||||||
|
: { base64Data: file, mimeType: 'application/octet-stream' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the Content-Length header value as a number.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns The content length in bytes, or `null` when the header is missing
|
||||||
|
* or invalid.
|
||||||
|
*/
|
||||||
|
const getContentLength = (req: Request): number | null => {
|
||||||
|
const value = req.headers.get('content-length');
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the request body exceeds the configured maximum size and
|
||||||
|
* returns an error response if it does.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns A 413 Response when the request is too large, or `null` when
|
||||||
|
* the size is within bounds (or unknown).
|
||||||
|
*/
|
||||||
|
const rejectOversizedRequest = (req: Request): Response | null => {
|
||||||
|
const contentLength = getContentLength(req);
|
||||||
|
if (contentLength !== null && contentLength > config.maxRequestBodyBytes) {
|
||||||
|
return Response.json({ error: 'Request body too large' }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams a multipart `File` to a temporary file on disk while computing
|
||||||
|
* its SHA-256 hash and extracting the signature (first 16 bytes).
|
||||||
|
*
|
||||||
|
* Backpressure from the write stream is respected via the drain event.
|
||||||
|
*
|
||||||
|
* @param file - The multipart `File` object.
|
||||||
|
* @param maxSizeBytes - Maximum allowed file size; an error is thrown if
|
||||||
|
* the stream exceeds this limit.
|
||||||
|
* @returns A fully prepared upload descriptor with hash, size, and temp path.
|
||||||
|
* @throws {Error} When the file size exceeds `maxSizeBytes`.
|
||||||
|
*/
|
||||||
|
const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<PreparedUpload> => {
|
||||||
|
const tempPath = `/tmp/filedrop-${nanoid()}`;
|
||||||
|
const writer = createWriteStream(tempPath);
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
const reader = file.stream().getReader();
|
||||||
|
const signatureChunks: Buffer[] = [];
|
||||||
|
let signatureBytes = 0;
|
||||||
|
let sizeBytes = 0;
|
||||||
|
|
||||||
|
const writeChunk = async (chunk: Buffer): Promise<void> => {
|
||||||
|
if (!writer.write(chunk)) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writer.once('drain', resolve);
|
||||||
|
writer.once('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishWriter = async (): Promise<void> => {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writer.end(() => resolve());
|
||||||
|
writer.once('error', reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = Buffer.from(value);
|
||||||
|
sizeBytes += chunk.byteLength;
|
||||||
|
if (sizeBytes > maxSizeBytes) {
|
||||||
|
throw new Error('File size exceeds upload limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.update(chunk);
|
||||||
|
await writeChunk(chunk);
|
||||||
|
|
||||||
|
if (signatureBytes < SIGNATURE_BYTES) {
|
||||||
|
const remaining = SIGNATURE_BYTES - signatureBytes;
|
||||||
|
const signatureChunk = chunk.subarray(0, remaining);
|
||||||
|
signatureChunks.push(signatureChunk);
|
||||||
|
signatureBytes += signatureChunk.byteLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await finishWriter();
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempPath,
|
||||||
|
fileHash: hasher.digest('hex'),
|
||||||
|
sizeBytes,
|
||||||
|
signatureBuffer: Buffer.concat(signatureChunks, signatureBytes),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
writer.destroy();
|
||||||
|
await cleanupTempFile(tempPath);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an in-memory buffer to a temporary file on disk.
|
||||||
|
*
|
||||||
|
* Used for base64 JSON uploads where the decoded data is already in a Buffer.
|
||||||
|
*
|
||||||
|
* @param fileBuffer - The decoded file content.
|
||||||
|
* @param fileHash - Pre-computed SHA-256 hex digest.
|
||||||
|
* @returns A prepared upload descriptor.
|
||||||
|
*/
|
||||||
|
const writeBufferToTemp = async (fileBuffer: Buffer, fileHash: string): Promise<PreparedUpload> => {
|
||||||
|
const tempPath = `/tmp/filedrop-${nanoid()}`;
|
||||||
|
try {
|
||||||
|
await Bun.write(tempPath, fileBuffer);
|
||||||
|
return {
|
||||||
|
tempPath,
|
||||||
|
fileHash,
|
||||||
|
sizeBytes: fileBuffer.byteLength,
|
||||||
|
signatureBuffer: fileBuffer.subarray(0, SIGNATURE_BYTES),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await cleanupTempFile(tempPath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a multipart/form-data file upload.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Parse the multipart form and extract the file.
|
||||||
|
* 2. Stream the file to a temp location, computing its hash.
|
||||||
|
* 3. Check for deduplication by content hash.
|
||||||
|
* 4. Determine the MIME type, file name, and Telegram file type.
|
||||||
|
* 5. Validate file size limits.
|
||||||
|
* 6. Upload to Telegram (chunked or single-message).
|
||||||
|
* 7. Return the upload response JSON.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a multipart body.
|
||||||
|
* @returns A JSON response with the uploaded file metadata.
|
||||||
|
*/
|
||||||
|
const handleMultipartUpload = async (req: Request): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
const fileName =
|
||||||
|
(formData.get('fileName') as string) || (file instanceof File ? file.name : null) || 'file';
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return Response.json({ error: 'No file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > config.maxRequestBodyBytes) {
|
||||||
|
return Response.json({ error: 'File size exceeds upload limit' }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared = await streamFileToTemp(file, config.maxRequestBodyBytes);
|
||||||
|
|
||||||
|
const existingFile = await findFileByHash(prepared.fileHash);
|
||||||
|
if (existingFile) {
|
||||||
|
await cleanupTempFile(prepared.tempPath);
|
||||||
|
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
|
||||||
|
const { fileName: finalFileName, mimeType } = ensureExtension(
|
||||||
|
fileName,
|
||||||
|
prepared.signatureBuffer,
|
||||||
|
rawMimeType,
|
||||||
|
);
|
||||||
|
const fileType = getFileType(mimeType, finalFileName);
|
||||||
|
|
||||||
|
if (!checkFileSize(prepared.sizeBytes, fileType)) {
|
||||||
|
await cleanupTempFile(prepared.tempPath);
|
||||||
|
return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
|
||||||
|
const uploadedFile = await storeFileInTelegramChunks({
|
||||||
|
tempPath: prepared.tempPath,
|
||||||
|
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'upload'}`,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: prepared.sizeBytes,
|
||||||
|
fileType,
|
||||||
|
uploaderId: 0,
|
||||||
|
});
|
||||||
|
await cleanupTempFile(prepared.tempPath);
|
||||||
|
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await enqueuePreparedUpload({
|
||||||
|
prepared,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
fileType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
logger.error('Multipart upload error', { error: message });
|
||||||
|
return Response.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an application/json file upload where the file is sent as a
|
||||||
|
* base64-encoded string.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Parse the JSON body and extract the base64 file data.
|
||||||
|
* 2. Decode and estimate the file size; reject if too large for JSON.
|
||||||
|
* 3. Write the decoded buffer to a temp file.
|
||||||
|
* 4. Check deduplication by content hash.
|
||||||
|
* 5. Determine MIME type, file name, and Telegram file type.
|
||||||
|
* 6. Validate file size limits.
|
||||||
|
* 7. Upload to Telegram (chunked or single-message).
|
||||||
|
* 8. Return the upload response JSON.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a JSON body.
|
||||||
|
* @returns A JSON response with the uploaded file metadata.
|
||||||
|
*/
|
||||||
|
const handleJSONUpload = async (req: Request): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { file, fileName = 'file' } = (await req.json()) as JsonUploadPayload;
|
||||||
|
|
||||||
|
if (!file || typeof file !== 'string') {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { base64Data, mimeType: rawMimeType } = parseBase64File(file);
|
||||||
|
const estimatedSizeBytes = Math.floor((base64Data.length * 3) / 4);
|
||||||
|
if (
|
||||||
|
estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES ||
|
||||||
|
estimatedSizeBytes > config.maxRequestBodyBytes
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'JSON base64 uploads are limited to 50MB. Use multipart/form-data for larger files',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBytes = Buffer.from(base64Data, 'base64');
|
||||||
|
const hash = computeHash(fileBytes);
|
||||||
|
|
||||||
|
const existingFile = await findFileByHash(hash);
|
||||||
|
if (existingFile) {
|
||||||
|
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileTypeRaw = getFileType(rawMimeType, fileName);
|
||||||
|
const fileType = fileTypeRaw === 'application' ? 'document' : fileTypeRaw;
|
||||||
|
|
||||||
|
const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType);
|
||||||
|
|
||||||
|
if (!checkFileSize(fileBytes.byteLength, fileType)) {
|
||||||
|
return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared = await writeBufferToTemp(fileBytes, hash);
|
||||||
|
|
||||||
|
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
|
||||||
|
const uploadedFile = await storeFileInTelegramChunks({
|
||||||
|
tempPath: prepared.tempPath,
|
||||||
|
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'json'}`,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: prepared.sizeBytes,
|
||||||
|
fileType,
|
||||||
|
uploaderId: 0,
|
||||||
|
});
|
||||||
|
await cleanupTempFile(prepared.tempPath);
|
||||||
|
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await enqueuePreparedUpload({
|
||||||
|
prepared,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
fileType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
logger.error('JSON upload error', { error: message });
|
||||||
|
return Response.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main upload request handler.
|
||||||
|
*
|
||||||
|
* Dispatches to either the multipart or JSON handler based on the request
|
||||||
|
* Content-Type header, returning an appropriate error for unsupported
|
||||||
|
* content types.
|
||||||
|
*
|
||||||
|
* Recording of upload metrics is handled centrally in this function.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns A JSON response with the uploaded file metadata or an error.
|
||||||
|
*/
|
||||||
|
export const handleUpload = async (req: Request): Promise<Response> => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
try {
|
||||||
|
const contentType = req.headers.get('content-type') || '';
|
||||||
|
const oversizedResponse = rejectOversizedRequest(req);
|
||||||
|
if (oversizedResponse) return oversizedResponse;
|
||||||
|
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
return handleMultipartUpload(req);
|
||||||
|
} else if (contentType.includes('application/json')) {
|
||||||
|
return handleJSONUpload(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Unsupported content type. Use multipart/form-data or application/json' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
metricsCollector.recordError();
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
logger.error('Upload error', { error: message });
|
||||||
|
return Response.json({ error: message }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
metricsCollector.recordUploadTime(performance.now() - startTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets';
|
||||||
|
import {
|
||||||
|
countBucketObjects,
|
||||||
|
findFileByBucketAndKey,
|
||||||
|
listObjectsByPrefix,
|
||||||
|
softDeleteFile,
|
||||||
|
} 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 { forwardToStorage, getFileInfo } from '../../../utils/telegram';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route parameters extracted from the URL path.
|
||||||
|
*/
|
||||||
|
type RouteParams = { bucket?: string; key?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a successful JSON Response.
|
||||||
|
*
|
||||||
|
* @param data - The JSON-serialisable body.
|
||||||
|
* @param status - HTTP status code (default 200).
|
||||||
|
* @returns A JSON Response.
|
||||||
|
*/
|
||||||
|
const json = (data: unknown, status = 200): Response => Response.json(data, { status });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a JSON error Response.
|
||||||
|
*
|
||||||
|
* @param error - The error message.
|
||||||
|
* @param status - HTTP status code.
|
||||||
|
* @returns A JSON Response.
|
||||||
|
*/
|
||||||
|
const jsonError = (error: string, status: number): Response => Response.json({ error }, { status });
|
||||||
|
|
||||||
|
// ─────── Bucket endpoints ───────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all buckets together with their object counts.
|
||||||
|
*
|
||||||
|
* @returns A JSON response with the bucket list.
|
||||||
|
*/
|
||||||
|
export const handleListBucketsV1 = async (): Promise<Response> => {
|
||||||
|
const buckets = await listBuckets();
|
||||||
|
const result = await Promise.all(
|
||||||
|
buckets.map(async (b) => ({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
createdAt: b.createdAt.toISOString(),
|
||||||
|
objectCount: await countBucketObjects(b.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return json({ buckets: result });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new bucket.
|
||||||
|
*
|
||||||
|
* Validates the bucket name format and checks for duplicates before creating.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a JSON body containing `name`.
|
||||||
|
* @returns A JSON response with the created bucket or an error.
|
||||||
|
*/
|
||||||
|
export const handleCreateBucketV1 = async (req: Request): Promise<Response> => {
|
||||||
|
const body = (await req.json()) as { name?: string };
|
||||||
|
if (!body.name || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(body.name)) {
|
||||||
|
return jsonError('Invalid bucket name. Use lowercase, 3-63 chars, no underscore', 400);
|
||||||
|
}
|
||||||
|
const existing = await findBucketByName(body.name);
|
||||||
|
if (existing) return jsonError('Bucket already exists', 409);
|
||||||
|
const bucket = await createBucket(body.name);
|
||||||
|
return json({ id: bucket.id, name: bucket.name }, 201);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a bucket by name.
|
||||||
|
*
|
||||||
|
* Ensures the bucket exists and is empty before deletion.
|
||||||
|
*
|
||||||
|
* @param _req - The incoming HTTP request (unused).
|
||||||
|
* @param params - Route parameters containing the bucket name.
|
||||||
|
* @returns A JSON response indicating success or an error.
|
||||||
|
*/
|
||||||
|
export const handleDeleteBucketV1 = async (
|
||||||
|
_req: Request,
|
||||||
|
params: RouteParams,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const bucket = await findBucketByName(params.bucket!);
|
||||||
|
if (!bucket) return jsonError('Bucket not found', 404);
|
||||||
|
const count = await countBucketObjects(bucket.id);
|
||||||
|
if (count > 0) return jsonError('Bucket is not empty', 409);
|
||||||
|
await deleteBucket(params.bucket!);
|
||||||
|
return json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────── Object endpoints ───────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists objects within a bucket (with prefix filtering and pagination).
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with query parameters.
|
||||||
|
* @param params - Route parameters containing the bucket name.
|
||||||
|
* @returns A JSON response with the object list.
|
||||||
|
*/
|
||||||
|
export const handleListObjectsV1 = async (req: Request, params: RouteParams): Promise<Response> => {
|
||||||
|
const bucket = await findBucketByName(params.bucket!);
|
||||||
|
if (!bucket) return jsonError('Bucket not found', 404);
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const prefix = url.searchParams.get('prefix') || '';
|
||||||
|
const delimiter = url.searchParams.get('delimiter') || '/';
|
||||||
|
const maxKeys = Number.parseInt(url.searchParams.get('max-keys') || '1000', 10);
|
||||||
|
const continuationToken = url.searchParams.get('continuation-token') || null;
|
||||||
|
|
||||||
|
const { objects, prefixes } = await listObjectsByPrefix(
|
||||||
|
bucket.id,
|
||||||
|
prefix,
|
||||||
|
delimiter,
|
||||||
|
maxKeys,
|
||||||
|
continuationToken,
|
||||||
|
);
|
||||||
|
const isTruncated = objects.length > maxKeys;
|
||||||
|
const displayObjects = objects.slice(0, maxKeys);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
objects: displayObjects.map((o) => ({
|
||||||
|
key: o.s3Key,
|
||||||
|
fileName: o.fileName,
|
||||||
|
mimeType: o.mimeType,
|
||||||
|
sizeBytes: Number(o.sizeBytes),
|
||||||
|
fileType: o.fileType,
|
||||||
|
etag: o.fileHash,
|
||||||
|
lastModified:
|
||||||
|
o.createdAt instanceof Date
|
||||||
|
? o.createdAt.toISOString()
|
||||||
|
: new Date(o.createdAt).toISOString(),
|
||||||
|
downloadUrl: `${config.baseUrl}/f/${o.publicId}`,
|
||||||
|
})),
|
||||||
|
prefixes,
|
||||||
|
isTruncated,
|
||||||
|
nextContinuationToken: isTruncated ? displayObjects[displayObjects.length - 1]?.s3Key : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an object to a bucket (Web API V1).
|
||||||
|
*
|
||||||
|
* Accepts multipart/form-data with a `file` field and optional `key` field.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a multipart body.
|
||||||
|
* @param params - Route parameters containing the bucket name.
|
||||||
|
* @returns A JSON response with the object metadata.
|
||||||
|
*/
|
||||||
|
export const handleUploadObjectV1 = async (
|
||||||
|
req: Request,
|
||||||
|
params: RouteParams,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const bucket = await findBucketByName(params.bucket!);
|
||||||
|
if (!bucket) return jsonError('Bucket not found', 404);
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return jsonError('No file provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (formData.get('key') as string) || file.name;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const hash = computeHash(buffer);
|
||||||
|
|
||||||
|
const tempPath = `/tmp/filedrop-web-${nanoid()}`;
|
||||||
|
await Bun.write(tempPath, buffer);
|
||||||
|
|
||||||
|
const signatureBuffer = buffer.subarray(0, 16);
|
||||||
|
const { fileName: finalFileName, mimeType } = ensureExtension(
|
||||||
|
key.split('/').pop() || 'file',
|
||||||
|
signatureBuffer,
|
||||||
|
file.type || 'application/octet-stream',
|
||||||
|
);
|
||||||
|
|
||||||
|
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
|
||||||
|
|
||||||
|
if (buffer.byteLength > config.telegramChunkSizeBytes) {
|
||||||
|
const uploadedFile = await storeFileInTelegramChunks({
|
||||||
|
tempPath,
|
||||||
|
partFileNamePrefix,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: buffer.byteLength,
|
||||||
|
fileType: 'document',
|
||||||
|
uploaderId: 0,
|
||||||
|
bucketId: bucket.id,
|
||||||
|
s3Key: key,
|
||||||
|
});
|
||||||
|
await cleanupTempFile(tempPath);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
size: buffer.byteLength,
|
||||||
|
etag: hash,
|
||||||
|
downloadUrl: `${config.baseUrl}/f/${uploadedFile.publicId}`,
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardResult = await forwardToStorage(
|
||||||
|
createReadStream(tempPath),
|
||||||
|
partFileNamePrefix,
|
||||||
|
'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicId = nanoid();
|
||||||
|
const { db, files: fileSchema } = await import('../../../db/index');
|
||||||
|
|
||||||
|
await db.insert(fileSchema).values({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageChatId: config.storageChatId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: buffer.byteLength,
|
||||||
|
fileType: 'document',
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: hash,
|
||||||
|
bucketId: bucket.id,
|
||||||
|
s3Key: key,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await cleanupTempFile(tempPath);
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{ key, size: buffer.byteLength, etag: hash, downloadUrl: `${config.baseUrl}/f/${publicId}` },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an object from a bucket (soft delete).
|
||||||
|
*
|
||||||
|
* @param _req - The incoming HTTP request (unused).
|
||||||
|
* @param params - Route parameters containing the bucket name and object key.
|
||||||
|
* @returns A JSON response indicating success.
|
||||||
|
*/
|
||||||
|
export const handleDeleteObjectV1 = async (
|
||||||
|
_req: Request,
|
||||||
|
params: RouteParams,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const bucket = await findBucketByName(params.bucket!);
|
||||||
|
if (!bucket) return jsonError('Bucket not found', 404);
|
||||||
|
await softDeleteFile(bucket.id, params.key!);
|
||||||
|
return json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads (or redirects to) an object from a bucket.
|
||||||
|
*
|
||||||
|
* For chunked objects, builds a streaming response. For regular Telegram
|
||||||
|
* objects, issues a 302 redirect to the Telegram CDN URL.
|
||||||
|
*
|
||||||
|
* @param _req - The incoming HTTP request (unused).
|
||||||
|
* @param params - Route parameters containing the bucket name and object key.
|
||||||
|
* @returns A redirect or streaming response, or a JSON error.
|
||||||
|
*/
|
||||||
|
export const handleDownloadObjectV1 = async (
|
||||||
|
_req: Request,
|
||||||
|
params: RouteParams,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const bucket = await findBucketByName(params.bucket!);
|
||||||
|
if (!bucket) return jsonError('Bucket not found', 404);
|
||||||
|
|
||||||
|
const file = await findFileByBucketAndKey(bucket.id, params.key!);
|
||||||
|
if (!file) return jsonError('Object not found', 404);
|
||||||
|
|
||||||
|
if (file.storageBackend === 'chunked') {
|
||||||
|
const range = { type: 'none' as const };
|
||||||
|
return createChunkedObjectResponse({ file, range, reqId: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInfo = await getFileInfo(file.telegramFileId);
|
||||||
|
const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers: { Location: redirectUrl } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies an object from one location to another within the same or a
|
||||||
|
* different bucket.
|
||||||
|
*
|
||||||
|
* Creates a new file record referencing the same Telegram-stored data as
|
||||||
|
* the source object.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request with a JSON body specifying source
|
||||||
|
* and destination keys and the destination bucket.
|
||||||
|
* @param params - Route parameters containing the source bucket name.
|
||||||
|
* @returns A JSON response with the copy result, or an error.
|
||||||
|
*/
|
||||||
|
export const handleCopyObjectV1 = async (req: Request, params: RouteParams): Promise<Response> => {
|
||||||
|
const body = (await req.json()) as {
|
||||||
|
sourceKey?: string;
|
||||||
|
destBucket?: string;
|
||||||
|
destKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.sourceKey || !body.destKey) {
|
||||||
|
return jsonError('sourceKey and destKey are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destBucketName = body.destBucket || params.bucket!;
|
||||||
|
const sourceBucket = await findBucketByName(params.bucket!);
|
||||||
|
const destBucket = await findBucketByName(destBucketName);
|
||||||
|
|
||||||
|
if (!sourceBucket || !destBucket) return jsonError('Bucket not found', 404);
|
||||||
|
|
||||||
|
const sourceFile = await findFileByBucketAndKey(sourceBucket.id, body.sourceKey);
|
||||||
|
if (!sourceFile) return jsonError('Source object not found', 404);
|
||||||
|
|
||||||
|
if (sourceFile.storageBackend === 'chunked') {
|
||||||
|
return json({ error: 'Copying chunked objects is not implemented' }, 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicId = nanoid();
|
||||||
|
const { db, files: fileSchema } = await import('../../../db/index');
|
||||||
|
|
||||||
|
await db.insert(fileSchema).values({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: sourceFile.telegramFileId,
|
||||||
|
telegramFileUniqueId: sourceFile.telegramFileUniqueId,
|
||||||
|
storageChatId: sourceFile.storageChatId,
|
||||||
|
storageMessageId: sourceFile.storageMessageId,
|
||||||
|
fileName: sourceFile.fileName,
|
||||||
|
mimeType: sourceFile.mimeType,
|
||||||
|
sizeBytes: sourceFile.sizeBytes,
|
||||||
|
fileType: sourceFile.fileType,
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: sourceFile.fileHash,
|
||||||
|
bucketId: destBucket.id,
|
||||||
|
s3Key: body.destKey,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ sourceKey: body.sourceKey, destKey: body.destKey, destBucket: destBucketName });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Web API V1 request router.
|
||||||
|
*
|
||||||
|
* Parses the request path and method, then dispatches to the appropriate
|
||||||
|
* handler function for bucket and object operations.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns A JSON response from the matched handler, or 404.
|
||||||
|
*/
|
||||||
|
export const handleWebApiV1 = async (req: Request): Promise<Response> => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const pathname = url.pathname.replace(/^\/api\/v1/, '');
|
||||||
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// GET /api/v1/buckets
|
||||||
|
if (parts.length === 1 && parts[0] === 'buckets' && method === 'GET') {
|
||||||
|
return await handleListBucketsV1();
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/buckets
|
||||||
|
if (parts.length === 1 && parts[0] === 'buckets' && method === 'POST') {
|
||||||
|
return await handleCreateBucketV1(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/buckets/{name}
|
||||||
|
if (parts.length === 2 && parts[0] === 'buckets' && method === 'DELETE') {
|
||||||
|
return await handleDeleteBucketV1(req, { bucket: parts[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/buckets/{name}/objects
|
||||||
|
if (
|
||||||
|
parts.length === 3 &&
|
||||||
|
parts[0] === 'buckets' &&
|
||||||
|
parts[2] === 'objects' &&
|
||||||
|
method === 'GET'
|
||||||
|
) {
|
||||||
|
return await handleListObjectsV1(req, { bucket: parts[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/buckets/{name}/upload
|
||||||
|
if (
|
||||||
|
parts.length === 3 &&
|
||||||
|
parts[0] === 'buckets' &&
|
||||||
|
parts[2] === 'upload' &&
|
||||||
|
method === 'POST'
|
||||||
|
) {
|
||||||
|
return await handleUploadObjectV1(req, { bucket: parts[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/buckets/{name}/copy
|
||||||
|
if (parts.length === 3 && parts[0] === 'buckets' && parts[2] === 'copy' && method === 'POST') {
|
||||||
|
return await handleCopyObjectV1(req, { bucket: parts[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/buckets/{name}/{key+}
|
||||||
|
if (parts.length >= 3 && parts[0] === 'buckets' && method === 'DELETE') {
|
||||||
|
const bucket = parts[1];
|
||||||
|
const key = parts.slice(2).join('/');
|
||||||
|
return await handleDeleteObjectV1(req, { bucket, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/buckets/{name}/download/{key+}
|
||||||
|
if (
|
||||||
|
parts.length >= 4 &&
|
||||||
|
parts[0] === 'buckets' &&
|
||||||
|
parts[2] === 'download' &&
|
||||||
|
method === 'GET'
|
||||||
|
) {
|
||||||
|
const bucket = parts[1];
|
||||||
|
const key = parts.slice(3).join('/');
|
||||||
|
return await handleDownloadObjectV1(req, { bucket, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonError('Not found', 404);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error('Web API error', { path: pathname, error: getErrorMessage(error) });
|
||||||
|
return jsonError('Internal server error', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
import { config } from '../../config/index';
|
import { config } from '../../../config/index';
|
||||||
|
|
||||||
const ADMIN_USERNAME = 'admin';
|
const ADMIN_USERNAME = 'admin';
|
||||||
const SIGNATURE_SEPARATOR = '.';
|
const SIGNATURE_SEPARATOR = '.';
|
||||||
|
|||||||
@@ -1,146 +1 @@
|
|||||||
import { config } from '../../config/index';
|
export { withRateLimit, cleanupRateLimitCache, checkRateLimit, clearRateLimitCache, getRateLimitStats } from '../../../utils/rateLimit';
|
||||||
import { extractClientIp } from '../../../shared/utils/ip';
|
|
||||||
import logger from '../../../shared/logger/index';
|
|
||||||
|
|
||||||
/** An entry in the in-memory rate-limit store. */
|
|
||||||
interface RateLimitEntry {
|
|
||||||
/** Number of requests received during the current window. */
|
|
||||||
count: number;
|
|
||||||
/** Epoch timestamp (ms) when the current window expires. */
|
|
||||||
resetTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-memory store mapping keys (typically client IPs) to rate-limit entries. */
|
|
||||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
||||||
/** Maximum number of tracked entries before LRU eviction kicks in. */
|
|
||||||
const MAX_STORE_ENTRIES = 50000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes all expired entries from the rate-limit store.
|
|
||||||
*
|
|
||||||
* @param now - Current epoch timestamp in milliseconds (defaults to `Date.now()`).
|
|
||||||
* @returns The number of entries that were cleaned.
|
|
||||||
*/
|
|
||||||
const evictExpiredEntries = (now = Date.now()): number => {
|
|
||||||
let cleaned = 0;
|
|
||||||
|
|
||||||
for (const [key, entry] of rateLimitStore.entries()) {
|
|
||||||
if (now > entry.resetTime) {
|
|
||||||
rateLimitStore.delete(key);
|
|
||||||
cleaned++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleaned;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures the store stays below {@link MAX_STORE_ENTRIES} by first
|
|
||||||
* evicting expired entries, then dropping the oldest entries if the
|
|
||||||
* store is still over capacity.
|
|
||||||
*
|
|
||||||
* @param now - Current epoch timestamp in milliseconds.
|
|
||||||
*/
|
|
||||||
const ensureStoreCapacity = (now: number): void => {
|
|
||||||
if (rateLimitStore.size < MAX_STORE_ENTRIES) return;
|
|
||||||
|
|
||||||
evictExpiredEntries(now);
|
|
||||||
while (rateLimitStore.size >= MAX_STORE_ENTRIES) {
|
|
||||||
const oldestKey = rateLimitStore.keys().next().value;
|
|
||||||
if (!oldestKey) break;
|
|
||||||
rateLimitStore.delete(oldestKey);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the given key (typically a client IP) has exceeded
|
|
||||||
* the allowed rate limit.
|
|
||||||
*
|
|
||||||
* On the first request within a window the entry is created and the
|
|
||||||
* caller is allowed through. Subsequent requests increment the
|
|
||||||
* counter. Returns `false` (and logs a warning) when the counter
|
|
||||||
* exceeds the configured maximum.
|
|
||||||
*
|
|
||||||
* @param key - The key to check (e.g. a client IP address).
|
|
||||||
* @returns `true` if the request is within the limit, `false` if
|
|
||||||
* rate-limited.
|
|
||||||
*/
|
|
||||||
export const checkRateLimit = (key: string): boolean => {
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = rateLimitStore.get(key);
|
|
||||||
|
|
||||||
if (!entry || now > entry.resetTime) {
|
|
||||||
ensureStoreCapacity(now);
|
|
||||||
rateLimitStore.set(key, {
|
|
||||||
count: 1,
|
|
||||||
resetTime: now + config.rateLimitWindowMs,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.count >= config.rateLimitMaxRequests) {
|
|
||||||
logger.warn('Rate limit exceeded', { key, count: entry.count });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.count++;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware that wraps a request handler with rate-limiting based
|
|
||||||
* on the client IP address.
|
|
||||||
*
|
|
||||||
* When the client has exceeded the allowed number of requests within
|
|
||||||
* the configured window a 429 Too Many Requests response is returned.
|
|
||||||
*
|
|
||||||
* @typeParam T - The request type (must extend `Request`).
|
|
||||||
* @param handler - The request handler to protect.
|
|
||||||
* @returns A wrapped handler that applies rate-limiting.
|
|
||||||
*/
|
|
||||||
export const withRateLimit = <T extends Request>(
|
|
||||||
handler: (req: T) => Promise<Response>,
|
|
||||||
): ((req: T) => Promise<Response>) => {
|
|
||||||
return async (req: T): Promise<Response> => {
|
|
||||||
const ip = extractClientIp(req, config.trustProxy);
|
|
||||||
if (!checkRateLimit(ip)) {
|
|
||||||
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler(req);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually evicts all expired entries from the rate-limit cache and
|
|
||||||
* logs a debug message with the count of removed entries.
|
|
||||||
*/
|
|
||||||
export const cleanupRateLimitCache = (): void => {
|
|
||||||
const cleaned = evictExpiredEntries();
|
|
||||||
|
|
||||||
if (cleaned > 0) {
|
|
||||||
logger.debug('Rate limit cache cleanup', { cleaned, remaining: rateLimitStore.size });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns diagnostic statistics about the current state of the
|
|
||||||
* rate-limit store.
|
|
||||||
*
|
|
||||||
* @returns An object with tracked-IP count, configured window size,
|
|
||||||
* max requests, and max tracked entries.
|
|
||||||
*/
|
|
||||||
export const getRateLimitStats = () => ({
|
|
||||||
trackedIPs: rateLimitStore.size,
|
|
||||||
windowSize: config.rateLimitWindowMs,
|
|
||||||
maxRequests: config.rateLimitMaxRequests,
|
|
||||||
maxTrackedIPs: MAX_STORE_ENTRIES,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all entries from the rate-limit cache (used primarily in
|
|
||||||
* tests).
|
|
||||||
*/
|
|
||||||
export const clearRateLimitCache = (): void => {
|
|
||||||
rateLimitStore.clear();
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { config } from '../../../config/index';
|
||||||
|
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
||||||
|
import { handleFileRedirect, handleFileInfo } from '../controllers/file-controller';
|
||||||
|
import { handleHealth } from '../controllers/health-controller';
|
||||||
|
import { handleHome } from '../controllers/home-controller';
|
||||||
|
import { handleS3Request } from '../controllers/s3-controller';
|
||||||
|
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
||||||
|
import { handleUpload } from '../controllers/upload-controller';
|
||||||
|
import { handleWebApiV1 } from '../controllers/web-api-controller';
|
||||||
|
import { requireAuth } from '../middleware/auth';
|
||||||
|
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
|
||||||
|
* if it matches a virtual-hosted-style domain.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns The bucket name if found, or null.
|
||||||
|
*/
|
||||||
|
const getS3RouteBucket = (req: Request): string | null => {
|
||||||
|
const host = req.headers.get('host') || '';
|
||||||
|
return extractS3BucketFromHost(host, config.s3VhostDomains);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the incoming request appears to be an S3 API request
|
||||||
|
* based on host headers, authorization headers, or query parameters.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @param headers - A record of parsed request headers.
|
||||||
|
* @returns True if the request should be handled by the S3 handler.
|
||||||
|
*/
|
||||||
|
const shouldHandleS3 = (req: Request, headers: Record<string, string>): boolean => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return Boolean(
|
||||||
|
getS3RouteBucket(req) || isS3Request(headers) || url.searchParams.has('X-Amz-Signature'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles non-GET requests to the root path by dispatching to the S3 handler
|
||||||
|
* if the request matches S3 patterns (virtual-hosted bucket, S3 auth headers,
|
||||||
|
* or presigned URL signature), or returning a 405 Method Not Allowed otherwise.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns A Response from the S3 handler or a 405 response.
|
||||||
|
*/
|
||||||
|
const handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return handleS3Request(req, getS3RouteBucket(req));
|
||||||
|
}
|
||||||
|
const headers = Object.fromEntries(req.headers);
|
||||||
|
if (shouldHandleS3(req, headers)) {
|
||||||
|
return handleS3Request(req, getS3RouteBucket(req));
|
||||||
|
}
|
||||||
|
return new Response('Not Allowed', { status: 405 });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines all HTTP routes for the application.
|
||||||
|
*
|
||||||
|
* Each route maps a URL pattern to its corresponding handler function(s),
|
||||||
|
* with middleware such as rate limiting and authentication applied where needed.
|
||||||
|
* This table is designed to be passed as the `routes` option to `Bun.serve()`.
|
||||||
|
*
|
||||||
|
* Route patterns follow Bun's routing syntax:
|
||||||
|
* - Static paths: `/health`
|
||||||
|
* - Parameterized paths: `/f/:public_id`
|
||||||
|
* - Wildcard paths: `/api/v1/*`
|
||||||
|
*/
|
||||||
|
export const routes = {
|
||||||
|
'/api/upload': {
|
||||||
|
POST: withRateLimit(handleUpload),
|
||||||
|
},
|
||||||
|
'/f/:public_id': {
|
||||||
|
GET: withRateLimit(handleFileRedirect),
|
||||||
|
},
|
||||||
|
'/file/:public_id/info': {
|
||||||
|
GET: withRateLimit(handleFileInfo),
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
GET: handleHealth,
|
||||||
|
},
|
||||||
|
'/docs': {
|
||||||
|
GET: handleSwaggerHtml,
|
||||||
|
},
|
||||||
|
'/swagger.json': {
|
||||||
|
GET: handleSwaggerJson,
|
||||||
|
},
|
||||||
|
'/': {
|
||||||
|
GET: (req: Request): Promise<Response> => {
|
||||||
|
const headers = Object.fromEntries(req.headers);
|
||||||
|
if (shouldHandleS3(req, headers)) {
|
||||||
|
return handleS3Request(req, getS3RouteBucket(req));
|
||||||
|
}
|
||||||
|
return handleHome();
|
||||||
|
},
|
||||||
|
PUT: handleMaybeS3Root,
|
||||||
|
HEAD: handleMaybeS3Root,
|
||||||
|
DELETE: handleMaybeS3Root,
|
||||||
|
POST: handleMaybeS3Root,
|
||||||
|
OPTIONS: handleMaybeS3Root,
|
||||||
|
},
|
||||||
|
'/api/v1/auth/login': {
|
||||||
|
POST: withRateLimit(handleLogin),
|
||||||
|
},
|
||||||
|
'/api/v1/auth/logout': {
|
||||||
|
POST: handleLogout,
|
||||||
|
},
|
||||||
|
'/api/v1/auth/me': {
|
||||||
|
GET: handleMe,
|
||||||
|
},
|
||||||
|
'/api/v1/*': {
|
||||||
|
GET: requireAuth(handleWebApiV1),
|
||||||
|
POST: requireAuth(handleWebApiV1),
|
||||||
|
DELETE: requireAuth(handleWebApiV1),
|
||||||
|
PUT: requireAuth(handleWebApiV1),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { isS3Request, buildCanonicalQueryString, verifyPresignedUrl, verifySignature } from '../../utils/s3/auth';
|
||||||
|
export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export const S3_CORS_HEADERS: Record<string, string> = {
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'access-control-allow-methods': 'GET, PUT, HEAD, DELETE, POST, OPTIONS',
|
||||||
|
'access-control-allow-headers': [
|
||||||
|
'Authorization',
|
||||||
|
'Content-Type',
|
||||||
|
'Content-MD5',
|
||||||
|
'Range',
|
||||||
|
'If-Match',
|
||||||
|
'If-None-Match',
|
||||||
|
'If-Modified-Since',
|
||||||
|
'If-Unmodified-Since',
|
||||||
|
'X-Amz-*',
|
||||||
|
'x-amz-*',
|
||||||
|
].join(', '),
|
||||||
|
'access-control-expose-headers': [
|
||||||
|
'Accept-Ranges',
|
||||||
|
'Content-Length',
|
||||||
|
'Content-Range',
|
||||||
|
'Content-Type',
|
||||||
|
'ETag',
|
||||||
|
'Last-Modified',
|
||||||
|
'x-amz-id-2',
|
||||||
|
'x-amz-request-id',
|
||||||
|
].join(', '),
|
||||||
|
'access-control-max-age': '86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const s3Headers = (
|
||||||
|
requestId: string,
|
||||||
|
extraHeaders: Record<string, string> = {},
|
||||||
|
): Record<string, string> => ({
|
||||||
|
...S3_CORS_HEADERS,
|
||||||
|
...(requestId ? { 'x-amz-request-id': requestId, 'x-amz-id-2': requestId } : {}),
|
||||||
|
...extraHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const applyS3Headers = (headers: Headers, requestId: string): Headers => {
|
||||||
|
const result = new Headers(headers);
|
||||||
|
for (const [key, value] of Object.entries(s3Headers(requestId))) {
|
||||||
|
result.set(key, value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { gunzipSync } from 'node:zlib';
|
||||||
|
import { applyS3Headers } from './headers';
|
||||||
|
import { contentRange, type RangeParseResult } from './range';
|
||||||
|
|
||||||
|
export interface ObjectPartSource {
|
||||||
|
telegramFileId: string;
|
||||||
|
telegramUrl: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
partNumber: number;
|
||||||
|
storedSizeBytes?: number;
|
||||||
|
compressionAlgorithm?: 'gzip' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectResponseInput {
|
||||||
|
reqId: string;
|
||||||
|
contentType: string;
|
||||||
|
etag: string;
|
||||||
|
lastModified: Date;
|
||||||
|
totalSize: number;
|
||||||
|
parts: ObjectPartSource[];
|
||||||
|
range: RangeParseResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlannedPart {
|
||||||
|
part: ObjectPartSource;
|
||||||
|
relativeStart: number;
|
||||||
|
relativeEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseHeaders = (input: ObjectResponseInput, contentLength: number): Headers => {
|
||||||
|
const headers = new Headers({
|
||||||
|
'content-type': input.contentType,
|
||||||
|
'content-length': String(contentLength),
|
||||||
|
etag: `"${input.etag}"`,
|
||||||
|
'last-modified': input.lastModified.toUTCString(),
|
||||||
|
'x-amz-request-id': input.reqId,
|
||||||
|
'accept-ranges': 'bytes',
|
||||||
|
'cache-control': 'public, max-age=31536000',
|
||||||
|
});
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const planParts = (parts: ObjectPartSource[], start: number, end: number): PlannedPart[] => {
|
||||||
|
const planned: PlannedPart[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
const partStart = offset;
|
||||||
|
const partEnd = offset + part.sizeBytes - 1;
|
||||||
|
offset += part.sizeBytes;
|
||||||
|
if (end < partStart || start > partEnd) continue;
|
||||||
|
planned.push({
|
||||||
|
part,
|
||||||
|
relativeStart: Math.max(start, partStart) - partStart,
|
||||||
|
relativeEnd: Math.min(end, partEnd) - partStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return planned;
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamFromBytes = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
|
||||||
|
new Response(bytes).body!;
|
||||||
|
|
||||||
|
const fetchWholePartBytes = async (telegramUrl: string): Promise<Uint8Array> => {
|
||||||
|
const res = await fetch(telegramUrl);
|
||||||
|
if (!res.ok) throw new Error(`Telegram fetch failed: ${res.status}`);
|
||||||
|
return new Uint8Array(await res.arrayBuffer());
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPartBody = async (planned: PlannedPart): Promise<ReadableStream<Uint8Array>> => {
|
||||||
|
const wantsWholePart =
|
||||||
|
planned.relativeStart === 0 && planned.relativeEnd === planned.part.sizeBytes - 1;
|
||||||
|
|
||||||
|
if (planned.part.compressionAlgorithm === 'gzip') {
|
||||||
|
const storedBytes = await fetchWholePartBytes(planned.part.telegramUrl);
|
||||||
|
const bytes = gunzipSync(storedBytes);
|
||||||
|
return streamFromBytes(bytes.subarray(planned.relativeStart, planned.relativeEnd + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeHeader = `bytes=${planned.relativeStart}-${planned.relativeEnd}`;
|
||||||
|
const res = await fetch(
|
||||||
|
planned.part.telegramUrl,
|
||||||
|
wantsWholePart ? undefined : { headers: { range: rangeHeader } },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Telegram fetch failed: ${res.status}`);
|
||||||
|
if (wantsWholePart || res.status === 206) return res.body!;
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
return streamFromBytes(bytes.slice(planned.relativeStart, planned.relativeEnd + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const concatPartStreams = (plannedParts: PlannedPart[]): ReadableStream<Uint8Array> =>
|
||||||
|
new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
try {
|
||||||
|
for (const planned of plannedParts) {
|
||||||
|
const stream = await fetchPartBody(planned);
|
||||||
|
const reader = stream.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value) controller.enqueue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
} catch (error) {
|
||||||
|
controller.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createGetObjectResponse = async (input: ObjectResponseInput): Promise<Response> => {
|
||||||
|
if (input.range.type === 'invalid') {
|
||||||
|
throw new Error('createGetObjectResponse received invalid range');
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = input.range.type === 'valid' ? input.range.start : 0;
|
||||||
|
const end = input.range.type === 'valid' ? input.range.end : input.totalSize - 1;
|
||||||
|
const plannedParts = planParts(input.parts, start, end);
|
||||||
|
const contentLength = end >= start ? end - start + 1 : 0;
|
||||||
|
const headers = applyS3Headers(baseHeaders(input, contentLength), input.reqId);
|
||||||
|
|
||||||
|
if (input.range.type === 'valid') {
|
||||||
|
headers.set('content-range', contentRange(start, end, input.totalSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(concatPartStreams(plannedParts), {
|
||||||
|
status: input.range.type === 'valid' ? 206 : 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export type RangeParseResult =
|
||||||
|
| { type: 'none' }
|
||||||
|
| { type: 'valid'; start: number; end: number }
|
||||||
|
| { type: 'invalid' };
|
||||||
|
|
||||||
|
const DECIMAL = /^\d+$/;
|
||||||
|
|
||||||
|
export const parseRangeHeader = (rangeHeader: string | null, size: number): RangeParseResult => {
|
||||||
|
if (!rangeHeader) return { type: 'none' };
|
||||||
|
if (!Number.isSafeInteger(size) || size < 0) return { type: 'invalid' };
|
||||||
|
if (!rangeHeader.startsWith('bytes=')) return { type: 'invalid' };
|
||||||
|
|
||||||
|
const spec = rangeHeader.slice('bytes='.length).trim();
|
||||||
|
if (spec.includes(',')) return { type: 'invalid' };
|
||||||
|
|
||||||
|
const dash = spec.indexOf('-');
|
||||||
|
if (dash === -1) return { type: 'invalid' };
|
||||||
|
|
||||||
|
const startText = spec.slice(0, dash).trim();
|
||||||
|
const endText = spec.slice(dash + 1).trim();
|
||||||
|
if (!startText && !endText) return { type: 'invalid' };
|
||||||
|
if (size === 0) return { type: 'invalid' };
|
||||||
|
|
||||||
|
if (!startText) {
|
||||||
|
if (!DECIMAL.test(endText)) return { type: 'invalid' };
|
||||||
|
const suffixLength = Number.parseInt(endText, 10);
|
||||||
|
if (suffixLength <= 0) return { type: 'invalid' };
|
||||||
|
return { type: 'valid', start: Math.max(size - suffixLength, 0), end: size - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DECIMAL.test(startText)) return { type: 'invalid' };
|
||||||
|
const start = Number.parseInt(startText, 10);
|
||||||
|
if (start >= size) return { type: 'invalid' };
|
||||||
|
|
||||||
|
if (!endText) return { type: 'valid', start, end: size - 1 };
|
||||||
|
if (!DECIMAL.test(endText)) return { type: 'invalid' };
|
||||||
|
|
||||||
|
const requestedEnd = Number.parseInt(endText, 10);
|
||||||
|
if (requestedEnd < start) return { type: 'invalid' };
|
||||||
|
return { type: 'valid', start, end: Math.min(requestedEnd, size - 1) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentRange = (start: number, end: number, size: number): string =>
|
||||||
|
`bytes ${start}-${end}/${size}`;
|
||||||
|
|
||||||
|
export const unsatisfiedContentRange = (size: number): string => `bytes */${size}`;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { extractS3BucketFromHost } from '../../utils/s3/virtual-host';
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { s3Headers } from './headers';
|
||||||
|
|
||||||
|
const escapeXml = (str: string): string =>
|
||||||
|
str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
|
const encodeKey = (value: string, encodingType: string | null = null): string =>
|
||||||
|
encodingType === 'url' ? encodeURIComponent(value) : escapeXml(value);
|
||||||
|
|
||||||
|
// ─────── Bucket operations ───────
|
||||||
|
|
||||||
|
export const listBucketsXml = (
|
||||||
|
buckets: { name: string; createdAt: Date }[],
|
||||||
|
_requestId: string,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Buckets>
|
||||||
|
${buckets
|
||||||
|
.map(
|
||||||
|
(b) => `<Bucket>
|
||||||
|
<Name>${escapeXml(b.name)}</Name>
|
||||||
|
<CreationDate>${isoDate(b.createdAt)}</CreationDate>
|
||||||
|
</Bucket>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</Buckets>
|
||||||
|
</ListAllMyBucketsResult>`;
|
||||||
|
|
||||||
|
export const bucketVersioningConfigurationXml =
|
||||||
|
(): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>`;
|
||||||
|
|
||||||
|
// ─────── Object listing ───────
|
||||||
|
|
||||||
|
export const listBucketResultXml = (
|
||||||
|
bucketName: string,
|
||||||
|
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
|
||||||
|
prefixes: string[],
|
||||||
|
isTruncated: boolean,
|
||||||
|
marker: string | null,
|
||||||
|
maxKeys: number,
|
||||||
|
prefix: string,
|
||||||
|
delimiter: string | null,
|
||||||
|
nextMarker: string | null,
|
||||||
|
_requestId: string,
|
||||||
|
encodingType: string | null = null,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Name>${escapeXml(bucketName)}</Name>
|
||||||
|
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
|
||||||
|
<Marker>${encodeKey(marker || '', encodingType)}</Marker>
|
||||||
|
<MaxKeys>${maxKeys}</MaxKeys>
|
||||||
|
<Delimiter>${encodeKey(delimiter || '', encodingType)}</Delimiter>
|
||||||
|
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
|
||||||
|
<IsTruncated>${isTruncated}</IsTruncated>
|
||||||
|
${objects
|
||||||
|
.map(
|
||||||
|
(o) => `<Contents>
|
||||||
|
<Key>${encodeKey(o.key, encodingType)}</Key>
|
||||||
|
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||||
|
<ETag>"${o.etag}"</ETag>
|
||||||
|
<Size>${o.sizeBytes}</Size>
|
||||||
|
<StorageClass>STANDARD</StorageClass>
|
||||||
|
</Contents>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
${prefixes
|
||||||
|
.map(
|
||||||
|
(p) => `<CommonPrefixes>
|
||||||
|
<Prefix>${encodeKey(p, encodingType)}</Prefix>
|
||||||
|
</CommonPrefixes>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
${nextMarker ? `<NextMarker>${encodeKey(nextMarker, encodingType)}</NextMarker>` : ''}
|
||||||
|
</ListBucketResult>`;
|
||||||
|
|
||||||
|
export const listBucketV2ResultXml = (
|
||||||
|
bucketName: string,
|
||||||
|
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
|
||||||
|
prefixes: string[],
|
||||||
|
isTruncated: boolean,
|
||||||
|
maxKeys: number,
|
||||||
|
prefix: string,
|
||||||
|
delimiter: string | null,
|
||||||
|
continuationToken: string | null,
|
||||||
|
nextContinuationToken: string | null,
|
||||||
|
keyCount: number,
|
||||||
|
_requestId: string,
|
||||||
|
encodingType: string | null = null,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Name>${escapeXml(bucketName)}</Name>
|
||||||
|
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
|
||||||
|
<MaxKeys>${maxKeys}</MaxKeys>
|
||||||
|
<KeyCount>${keyCount}</KeyCount>
|
||||||
|
${delimiter ? `<Delimiter>${encodeKey(delimiter, encodingType)}</Delimiter>` : ''}
|
||||||
|
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
|
||||||
|
${continuationToken ? `<ContinuationToken>${encodeKey(continuationToken, encodingType)}</ContinuationToken>` : ''}
|
||||||
|
<IsTruncated>${isTruncated}</IsTruncated>
|
||||||
|
${objects
|
||||||
|
.map(
|
||||||
|
(o) => `<Contents>
|
||||||
|
<Key>${encodeKey(o.key, encodingType)}</Key>
|
||||||
|
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||||
|
<ETag>"${o.etag}"</ETag>
|
||||||
|
<Size>${o.sizeBytes}</Size>
|
||||||
|
<StorageClass>STANDARD</StorageClass>
|
||||||
|
</Contents>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
${prefixes
|
||||||
|
.map(
|
||||||
|
(p) => `<CommonPrefixes>
|
||||||
|
<Prefix>${encodeKey(p, encodingType)}</Prefix>
|
||||||
|
</CommonPrefixes>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
${nextContinuationToken ? `<NextContinuationToken>${encodeKey(nextContinuationToken, encodingType)}</NextContinuationToken>` : ''}
|
||||||
|
</ListBucketResultV2>`;
|
||||||
|
|
||||||
|
// ─────── Multipart ───────
|
||||||
|
|
||||||
|
export const initiateMultipartUploadXml = (
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
uploadId: string,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||||
|
<Key>${escapeXml(key)}</Key>
|
||||||
|
<UploadId>${uploadId}</UploadId>
|
||||||
|
</InitiateMultipartUploadResult>`;
|
||||||
|
|
||||||
|
export const listPartsXml = (
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
uploadId: string,
|
||||||
|
parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[],
|
||||||
|
maxParts: number,
|
||||||
|
isTruncated: boolean,
|
||||||
|
_requestId: string,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||||
|
<Key>${escapeXml(key)}</Key>
|
||||||
|
<UploadId>${uploadId}</UploadId>
|
||||||
|
<MaxParts>${maxParts}</MaxParts>
|
||||||
|
<IsTruncated>${isTruncated}</IsTruncated>
|
||||||
|
${parts
|
||||||
|
.map(
|
||||||
|
(p) => `<Part>
|
||||||
|
<PartNumber>${p.partNumber}</PartNumber>
|
||||||
|
<LastModified>${isoDate(p.createdAt)}</LastModified>
|
||||||
|
<ETag>"${p.etag}"</ETag>
|
||||||
|
<Size>${p.sizeBytes}</Size>
|
||||||
|
</Part>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</ListPartsResult>`;
|
||||||
|
|
||||||
|
export const listMultipartUploadsXml = (
|
||||||
|
bucketName: string,
|
||||||
|
uploads: { key: string; uploadId: string; initiatedAt: Date; initiatedBy: string }[],
|
||||||
|
maxUploads: number,
|
||||||
|
isTruncated: boolean,
|
||||||
|
nextKeyMarker: string | null,
|
||||||
|
_requestId: string,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||||
|
<KeyMarker></KeyMarker>
|
||||||
|
<UploadIdMarker></UploadIdMarker>
|
||||||
|
${nextKeyMarker ? `<NextKeyMarker>${escapeXml(nextKeyMarker)}</NextKeyMarker>` : ''}
|
||||||
|
<MaxUploads>${maxUploads}</MaxUploads>
|
||||||
|
<IsTruncated>${isTruncated}</IsTruncated>
|
||||||
|
${uploads
|
||||||
|
.map(
|
||||||
|
(u) => `<Upload>
|
||||||
|
<Key>${escapeXml(u.key)}</Key>
|
||||||
|
<UploadId>${u.uploadId}</UploadId>
|
||||||
|
<Initiator><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Initiator>
|
||||||
|
<Owner><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Owner>
|
||||||
|
<StorageClass>STANDARD</StorageClass>
|
||||||
|
<Initiated>${isoDate(u.initiatedAt)}</Initiated>
|
||||||
|
</Upload>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</ListMultipartUploadsResult>`;
|
||||||
|
|
||||||
|
export const completeMultipartUploadXml = (
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
etag: string,
|
||||||
|
location: string,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<Location>${escapeXml(location)}</Location>
|
||||||
|
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||||
|
<Key>${escapeXml(key)}</Key>
|
||||||
|
<ETag>"${etag}"</ETag>
|
||||||
|
</CompleteMultipartUploadResult>`;
|
||||||
|
|
||||||
|
// ─────── Delete result ───────
|
||||||
|
|
||||||
|
export const deleteResultXml = (
|
||||||
|
deleted: string[],
|
||||||
|
errors: { key: string; code: string; message: string }[],
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
${deleted
|
||||||
|
.map(
|
||||||
|
(key) => `<Deleted>
|
||||||
|
<Key>${escapeXml(key)}</Key>
|
||||||
|
</Deleted>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
${errors
|
||||||
|
.map(
|
||||||
|
(e) => `<Error>
|
||||||
|
<Key>${escapeXml(e.key)}</Key>
|
||||||
|
<Code>${e.code}</Code>
|
||||||
|
<Message>${escapeXml(e.message)}</Message>
|
||||||
|
</Error>`,
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</DeleteResult>`;
|
||||||
|
|
||||||
|
// ─────── Copy ───────
|
||||||
|
|
||||||
|
export const copyObjectResultXml = (
|
||||||
|
etag: string,
|
||||||
|
lastModified: Date,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<ETag>"${etag}"</ETag>
|
||||||
|
<LastModified>${isoDate(lastModified)}</LastModified>
|
||||||
|
</CopyObjectResult>`;
|
||||||
|
|
||||||
|
// ─────── Error ───────
|
||||||
|
|
||||||
|
export const s3ErrorXml = (
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
resource: string,
|
||||||
|
requestId: string,
|
||||||
|
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Error>
|
||||||
|
<Code>${code}</Code>
|
||||||
|
<Message>${escapeXml(message)}</Message>
|
||||||
|
<Resource>${escapeXml(resource)}</Resource>
|
||||||
|
<RequestId>${requestId}</RequestId>
|
||||||
|
<HostId>${requestId}</HostId>
|
||||||
|
</Error>`;
|
||||||
|
|
||||||
|
export const s3ErrorResponse = (
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
resource: string,
|
||||||
|
status: number,
|
||||||
|
requestId: string = '',
|
||||||
|
extraHeaders: Record<string, string> = {},
|
||||||
|
): Response =>
|
||||||
|
new Response(s3ErrorXml(code, message, resource, requestId), {
|
||||||
|
status,
|
||||||
|
headers: s3Headers(requestId, {
|
||||||
|
'content-type': 'application/xml',
|
||||||
|
...extraHeaders,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────── DeleteObjects XML parser ───────
|
||||||
|
|
||||||
|
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
|
||||||
|
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);
|
||||||
|
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>true ');
|
||||||
|
return { keys, quiet };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────── CompleteMultipartUpload XML parser ───────
|
||||||
|
|
||||||
|
export interface CompletePart {
|
||||||
|
partNumber: number;
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseCompleteMultipartBody = (body: string): CompletePart[] => {
|
||||||
|
const parts: CompletePart[] = [];
|
||||||
|
const partRegex = /<Part>[\s\S]*?<\/Part>/g;
|
||||||
|
const partMatch = body.match(partRegex) || [];
|
||||||
|
|
||||||
|
for (const partXml of partMatch) {
|
||||||
|
const numMatch = partXml.match(/<PartNumber>(\d+)<\/PartNumber>/);
|
||||||
|
const etagMatch = partXml.match(/<ETag>"?([^"<\s]+)"?<\/ETag>/);
|
||||||
|
if (numMatch && etagMatch) {
|
||||||
|
parts.push({
|
||||||
|
partNumber: parseInt(numMatch[1], 10),
|
||||||
|
etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
@@ -1,33 +1,4 @@
|
|||||||
import winston from 'winston';
|
import _logger from "../../utils/logger";
|
||||||
|
export default _logger;
|
||||||
/**
|
export { _logger as logger };
|
||||||
* Application-wide logger singleton configured with Winston.
|
export type { Logger } from "winston";
|
||||||
*
|
|
||||||
* Writes error-level logs to `logs/error.log`, all logs to
|
|
||||||
* `logs/combined.log`, and outputs to the console in both
|
|
||||||
* development (colorized, simple format) and production
|
|
||||||
* (JSON format) environments.
|
|
||||||
*/
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.timestamp(),
|
|
||||||
winston.format.errors({ stack: true }),
|
|
||||||
winston.format.json(),
|
|
||||||
),
|
|
||||||
defaultMeta: { service: 'filedrop' },
|
|
||||||
transports: [
|
|
||||||
// Write all logs including error logs to file
|
|
||||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
|
||||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
|
||||||
// Console transport for docker logs / CLI visibility
|
|
||||||
new winston.transports.Console({
|
|
||||||
format:
|
|
||||||
process.env.NODE_ENV !== 'production'
|
|
||||||
? winston.format.combine(winston.format.colorize(), winston.format.simple())
|
|
||||||
: winston.format.json(),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export default logger;
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { metricsCollector, MetricsCollector } from '../../utils/metrics';
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { unlink } from 'node:fs/promises';
|
||||||
|
import logger from '../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts an error message from an unknown value.
|
||||||
|
*
|
||||||
|
* @param error - The error value (caught exception, rejection reason, etc.).
|
||||||
|
* @returns The error message string.
|
||||||
|
*/
|
||||||
|
export const getErrorMessage = (error: unknown): string => {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously removes a temporary file from disk, logging a warning
|
||||||
|
* instead of throwing when the operation fails.
|
||||||
|
*
|
||||||
|
* @param tempPath - Absolute path to the temporary file.
|
||||||
|
*/
|
||||||
|
export const cleanupTempFile = async (tempPath: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await unlink(tempPath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Metadata describing a stored file record. */
|
||||||
|
interface FileMetadata {
|
||||||
|
/** Public-facing unique identifier. */
|
||||||
|
publicId: string;
|
||||||
|
/** Telegram file identifier. */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram file unique identifier (stable across chats). */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** ID of the Telegram chat where the file is stored. */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Message ID within the storage chat. */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Original file name. */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the file. */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Telegram-inferred file type (document, photo, video, etc.). */
|
||||||
|
fileType: string;
|
||||||
|
/** Telegram user ID of the uploader. */
|
||||||
|
uploaderId: number;
|
||||||
|
/** Timestamp when the record was created. */
|
||||||
|
createdAt: Date | string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-file-type size limits in bytes. */
|
||||||
|
const FILE_TYPES: Record<string, number> = {
|
||||||
|
document: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
photo: 10 * 1024 * 1024, // 10MB
|
||||||
|
video: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
audio: 200 * 1024 * 1024, // 200MB
|
||||||
|
voice: 200 * 1024 * 1024, // 200MB
|
||||||
|
animation: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
sticker: 10 * 1024 * 1024, // 10MB
|
||||||
|
video_note: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the Telegram file type from a MIME type and optional caption.
|
||||||
|
*
|
||||||
|
* The returned string matches one of the keys in `FILE_TYPES` (document,
|
||||||
|
* photo, video, audio, voice, animation, sticker, video_note).
|
||||||
|
*
|
||||||
|
* @param mime - The MIME type string (may be null).
|
||||||
|
* @param caption - Optional caption text that may hint at the file type.
|
||||||
|
* @returns The inferred Telegram file type.
|
||||||
|
*/
|
||||||
|
export const getFileType = (mime: string | null, caption?: string): string => {
|
||||||
|
const mimeUpper = mime?.split('/')[0]?.toLowerCase();
|
||||||
|
const captionLower = caption?.toLowerCase();
|
||||||
|
|
||||||
|
if (mime?.toLowerCase() === 'image/webp' || captionLower?.includes('sticker')) return 'sticker';
|
||||||
|
if (captionLower?.includes('video_note')) return 'video_note';
|
||||||
|
if (mimeUpper === 'video') return 'video';
|
||||||
|
if (mimeUpper === 'audio') return 'audio';
|
||||||
|
if (mimeUpper === 'document') return 'document';
|
||||||
|
if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
|
||||||
|
if (captionLower?.includes('voice')) return 'voice';
|
||||||
|
if (captionLower?.includes('animation')) return 'animation';
|
||||||
|
|
||||||
|
return mimeUpper === 'application' ? 'application' : 'document';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a file's size is within the allowed limit for its type.
|
||||||
|
*
|
||||||
|
* @param sizeBytes - File size in bytes.
|
||||||
|
* @param fileType - One of the recognised Telegram file type keys.
|
||||||
|
* @returns `true` if the file size is within bounds, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export const checkFileSize = (sizeBytes: number, fileType: string): boolean => {
|
||||||
|
const limit = FILE_TYPES[fileType] || FILE_TYPES.document;
|
||||||
|
return sizeBytes <= limit;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures a file name has a proper extension based on its content.
|
||||||
|
*
|
||||||
|
* Magic-bytes (PDF, PNG, JPEG, GIF) are detected from the buffer first; if
|
||||||
|
* no magic matches, the optional detected MIME type is consulted.
|
||||||
|
*
|
||||||
|
* @param fileName - The original file name (may lack an extension).
|
||||||
|
* @param buffer - At least the first few bytes of file content.
|
||||||
|
* @param detectedMime - Optional MIME type from an external detector.
|
||||||
|
* @returns An object with the potentially-corrected file name and MIME type.
|
||||||
|
*/
|
||||||
|
export const ensureExtension = (
|
||||||
|
fileName: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
detectedMime?: string,
|
||||||
|
): { fileName: string; mimeType: string } => {
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'text/plain': 'txt',
|
||||||
|
'application/zip': 'zip',
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext: string | null = null;
|
||||||
|
if (buffer.subarray(0, 4).toString() === '%PDF') {
|
||||||
|
ext = 'pdf';
|
||||||
|
} else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||||
|
ext = 'png';
|
||||||
|
} else if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
ext = 'jpg';
|
||||||
|
} else if (buffer.subarray(0, 4).toString() === 'GIF8') {
|
||||||
|
ext = 'gif';
|
||||||
|
} else if (detectedMime) {
|
||||||
|
ext = mimeMap[detectedMime.toLowerCase()] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalFileName = fileName;
|
||||||
|
const hasExtension = fileName.includes('.') && fileName.split('.').pop()!.length >= 2;
|
||||||
|
if (!hasExtension && ext) {
|
||||||
|
finalFileName = `${fileName}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = ext
|
||||||
|
? Object.keys(mimeMap).find((k) => mimeMap[k] === ext) ||
|
||||||
|
detectedMime ||
|
||||||
|
'application/octet-stream'
|
||||||
|
: detectedMime || 'application/octet-stream';
|
||||||
|
|
||||||
|
return { fileName: finalFileName, mimeType };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Duck-typed object that may carry HTTP-like headers. */
|
||||||
|
type HeaderMapRequest = {
|
||||||
|
headers?:
|
||||||
|
| {
|
||||||
|
get?: (name: string) => string | null;
|
||||||
|
}
|
||||||
|
| Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Duck-typed Telegram file-like object. */
|
||||||
|
type FileLike = {
|
||||||
|
/** File name, if available. */
|
||||||
|
fileName?: string;
|
||||||
|
/** MIME type, if available. */
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Duck-typed Telegram message object that may contain file attachments. */
|
||||||
|
type MessageLike = {
|
||||||
|
document?: FileLike;
|
||||||
|
photo?: FileLike[];
|
||||||
|
audio?: FileLike;
|
||||||
|
voice?: FileLike;
|
||||||
|
animation?: FileLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely reads a header value from a request-like object, supporting both
|
||||||
|
* the Fetch API `Headers#get` interface and plain records.
|
||||||
|
*
|
||||||
|
* @param request - An object with an optional `headers` property.
|
||||||
|
* @param name - The header name (case-insensitive for `get()`).
|
||||||
|
* @returns The header value, or `undefined` if not present.
|
||||||
|
*/
|
||||||
|
const getHeader = (request: HeaderMapRequest | null, name: string): string | undefined => {
|
||||||
|
const headers = request?.headers;
|
||||||
|
if (!headers) return undefined;
|
||||||
|
|
||||||
|
const get = 'get' in headers ? headers.get : undefined;
|
||||||
|
if (typeof get === 'function') return get(name) || undefined;
|
||||||
|
|
||||||
|
return (headers as Record<string, string>)[name];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the file name from a Telegram message or an `x-file-name` request
|
||||||
|
* header.
|
||||||
|
*
|
||||||
|
* The request header takes precedence when present.
|
||||||
|
*
|
||||||
|
* @param msg - A duck-typed Telegram message object.
|
||||||
|
* @param request - An optional request-like object for header inspection.
|
||||||
|
* @returns The extracted file name, or `'file'` if none was found.
|
||||||
|
*/
|
||||||
|
export const extractFileName = (msg: MessageLike, request: HeaderMapRequest | null): string => {
|
||||||
|
const headerFileName = getHeader(request, 'x-file-name');
|
||||||
|
if (headerFileName) return headerFileName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
msg.document?.fileName ||
|
||||||
|
msg.photo?.slice(-1)[0]?.fileName ||
|
||||||
|
msg.audio?.fileName ||
|
||||||
|
msg.voice?.fileName ||
|
||||||
|
msg.animation?.fileName ||
|
||||||
|
'file'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the MIME type from a Telegram message or an `x-mime-type` request
|
||||||
|
* header.
|
||||||
|
*
|
||||||
|
* The request header takes precedence when present.
|
||||||
|
*
|
||||||
|
* @param msg - A duck-typed Telegram message object.
|
||||||
|
* @param request - An optional request-like object for header inspection.
|
||||||
|
* @returns The extracted MIME type, or `'application/octet-stream'` as
|
||||||
|
* fallback.
|
||||||
|
*/
|
||||||
|
export const extractMimeType = (msg: MessageLike, request: HeaderMapRequest | null): string => {
|
||||||
|
const headerMimeType = getHeader(request, 'x-mime-type');
|
||||||
|
if (headerMimeType) return headerMimeType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
msg.document?.mimeType ||
|
||||||
|
msg.photo?.slice(-1)[0]?.mimeType ||
|
||||||
|
msg.audio?.mimeType ||
|
||||||
|
msg.voice?.mimeType ||
|
||||||
|
msg.animation?.mimeType ||
|
||||||
|
'application/octet-stream'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the SHA-256 hex digest of a buffer.
|
||||||
|
*
|
||||||
|
* @param buffer - The input data.
|
||||||
|
* @returns The 64-character hex-encoded SHA-256 hash.
|
||||||
|
*/
|
||||||
|
export const computeHash = (buffer: Buffer): string => {
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
hasher.update(buffer);
|
||||||
|
return hasher.digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Telegram API file object (subset of the full file object). */
|
||||||
|
export interface TelegramMessageFile {
|
||||||
|
/** Unique file identifier. */
|
||||||
|
file_id: string;
|
||||||
|
/** Unique file identifier that is stable across different Telegram chats. */
|
||||||
|
file_unique_id: string;
|
||||||
|
/** File size in bytes, if available. */
|
||||||
|
file_size?: number;
|
||||||
|
/** MIME type, if available. */
|
||||||
|
mime_type?: string;
|
||||||
|
/** Original file name, if available. */
|
||||||
|
file_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Telegram API message object that may carry media attachments. */
|
||||||
|
export interface TelegramMediaMessage {
|
||||||
|
/** Message identifier within the chat. */
|
||||||
|
message_id: number;
|
||||||
|
document?: TelegramMessageFile;
|
||||||
|
photo?: TelegramMessageFile[];
|
||||||
|
video?: TelegramMessageFile;
|
||||||
|
audio?: TelegramMessageFile;
|
||||||
|
voice?: TelegramMessageFile;
|
||||||
|
animation?: TelegramMessageFile;
|
||||||
|
sticker?: TelegramMessageFile;
|
||||||
|
video_note?: TelegramMessageFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the relevant TelegramMessageFile from a media message based on the
|
||||||
|
* detected file type.
|
||||||
|
*
|
||||||
|
* For photos the last (largest) entry in the photo array is returned.
|
||||||
|
*
|
||||||
|
* @param msg - The Telegram media message.
|
||||||
|
* @param fileType - The detected file type (photo, document, video, etc.).
|
||||||
|
* @returns The matching file descriptor.
|
||||||
|
*/
|
||||||
|
export const extractFileFromMessage = (
|
||||||
|
msg: TelegramMediaMessage,
|
||||||
|
fileType: string,
|
||||||
|
): TelegramMessageFile => {
|
||||||
|
if (fileType === 'photo') return msg.photo?.slice(-1)[0] as TelegramMessageFile;
|
||||||
|
if (fileType === 'sticker') return msg.sticker as TelegramMessageFile;
|
||||||
|
return msg[fileType as keyof TelegramMediaMessage] as TelegramMessageFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the file type from a Telegram media message by inspecting which
|
||||||
|
* media fields are populated.
|
||||||
|
*
|
||||||
|
* The first populated field in the order document, photo, video, audio,
|
||||||
|
* voice, animation, sticker, video_note determines the type.
|
||||||
|
*
|
||||||
|
* @param msg - The Telegram media message.
|
||||||
|
* @returns The detected file type string.
|
||||||
|
*/
|
||||||
|
export const detectFileType = (msg: TelegramMediaMessage): string => {
|
||||||
|
if (msg.document) return 'document';
|
||||||
|
if (msg.photo) return 'photo';
|
||||||
|
if (msg.video) return 'video';
|
||||||
|
if (msg.audio) return 'audio';
|
||||||
|
if (msg.voice) return 'voice';
|
||||||
|
if (msg.animation) return 'animation';
|
||||||
|
if (msg.sticker) return 'sticker';
|
||||||
|
if (msg.video_note) return 'video_note';
|
||||||
|
return 'document';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum allowed file size in bytes for the given file type.
|
||||||
|
*
|
||||||
|
* @param fileType - One of the recognised Telegram file type keys.
|
||||||
|
* @returns The size limit in bytes.
|
||||||
|
*/
|
||||||
|
export const getFileSizeLimit = (fileType: string): number =>
|
||||||
|
FILE_TYPES[fileType] || FILE_TYPES.document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a `createdAt` value into an ISO-8601 string.
|
||||||
|
*
|
||||||
|
* Accepts a Date instance, a date string, or a Unix timestamp (number).
|
||||||
|
*
|
||||||
|
* @param createdAt - The timestamp value to format.
|
||||||
|
* @returns The ISO-8601 string representation.
|
||||||
|
*/
|
||||||
|
export const formatCreatedAt = (createdAt: Date | string | number): string => {
|
||||||
|
return createdAt instanceof Date ? createdAt.toISOString() : new Date(createdAt).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Public shape of a file in the upload API response. */
|
||||||
|
export interface UploadResponse {
|
||||||
|
/** Public unique identifier. */
|
||||||
|
public_id: string;
|
||||||
|
/** Original file name. */
|
||||||
|
file_name: string;
|
||||||
|
/** MIME type. */
|
||||||
|
mime_type: string;
|
||||||
|
/** File size in bytes. */
|
||||||
|
size_bytes: number;
|
||||||
|
/** Telegram file type. */
|
||||||
|
file_type: string;
|
||||||
|
/** ISO-8601 creation timestamp. */
|
||||||
|
created_at: string;
|
||||||
|
/** Public download URL. */
|
||||||
|
download_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an API response object from stored file metadata.
|
||||||
|
*
|
||||||
|
* @param file - The file metadata record.
|
||||||
|
* @param baseUrl - The server's base URL used to construct the download link.
|
||||||
|
* @returns A plain response object suitable for JSON serialisation.
|
||||||
|
*/
|
||||||
|
export const buildUploadResponse = (file: FileMetadata, baseUrl: string): UploadResponse => {
|
||||||
|
return {
|
||||||
|
public_id: file.publicId,
|
||||||
|
file_name: file.fileName,
|
||||||
|
mime_type: file.mimeType,
|
||||||
|
size_bytes: file.sizeBytes,
|
||||||
|
file_type: file.fileType,
|
||||||
|
created_at: formatCreatedAt(file.createdAt),
|
||||||
|
download_url: `${baseUrl}/f/${file.publicId}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { config } from '../../env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the client IP address from a Request object.
|
||||||
|
*
|
||||||
|
* When the server is behind a trusted proxy (config.trustProxy is true), this
|
||||||
|
* function respects the X-Forwarded-For and X-Real-IP headers. Otherwise it
|
||||||
|
* always returns 127.0.0.1.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns The client IP address as a string.
|
||||||
|
*/
|
||||||
|
export const extractClientIp = (req: Request): string => {
|
||||||
|
if (!config.trustProxy) return '127.0.0.1';
|
||||||
|
|
||||||
|
const forwardedFor = req.headers.get('x-forwarded-for');
|
||||||
|
if (forwardedFor) {
|
||||||
|
const firstIp = forwardedFor.split(',')[0]?.trim();
|
||||||
|
if (firstIp) return firstIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = req.headers.get('x-real-ip')?.trim();
|
||||||
|
if (realIp) return realIp;
|
||||||
|
|
||||||
|
return '127.0.0.1';
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import logger from '../../utils/logger';
|
||||||
|
|
||||||
|
/** Configuration options for retry behaviour. */
|
||||||
|
interface RetryOptions {
|
||||||
|
/** Maximum number of retry attempts (default: 3). */
|
||||||
|
maxRetries?: number;
|
||||||
|
/** Delay before the first retry in milliseconds (default: 100). */
|
||||||
|
initialDelayMs?: number;
|
||||||
|
/** Maximum delay between retries in milliseconds (default: 5000). */
|
||||||
|
maxDelayMs?: number;
|
||||||
|
/** Multiplier for exponential backoff (default: 2). */
|
||||||
|
backoffMultiplier?: number;
|
||||||
|
/**
|
||||||
|
* Predicate that determines whether a given error should trigger a retry.
|
||||||
|
* When omitted, transient network / timeout errors are retried.
|
||||||
|
*/
|
||||||
|
shouldRetry?: (error: unknown) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<RetryOptions> = {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelayMs: 100,
|
||||||
|
maxDelayMs: 5000,
|
||||||
|
backoffMultiplier: 2,
|
||||||
|
shouldRetry: (error: unknown) => {
|
||||||
|
const errorStr = error instanceof Error ? error.message : String(error);
|
||||||
|
// Retry on transient errors
|
||||||
|
return (
|
||||||
|
errorStr.includes('ECONNREFUSED') ||
|
||||||
|
errorStr.includes('ETIMEDOUT') ||
|
||||||
|
errorStr.includes('ENOTFOUND') ||
|
||||||
|
errorStr.includes('429') ||
|
||||||
|
errorStr.includes('timeout')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an async function with exponential backoff retry logic.
|
||||||
|
*
|
||||||
|
* The function is retried up to `maxRetries` times. Between attempts the
|
||||||
|
* delay grows by `backoffMultiplier` (capped at `maxDelayMs`). Only errors
|
||||||
|
* for which `shouldRetry` returns `true` trigger a retry; all others are
|
||||||
|
* thrown immediately. When all retries are exhausted the last error is
|
||||||
|
* thrown.
|
||||||
|
*
|
||||||
|
* @param fn - The async function to execute.
|
||||||
|
* @param options - Optional retry configuration overrides.
|
||||||
|
* @returns The resolved value of `fn`.
|
||||||
|
*/
|
||||||
|
export const withRetry = async <T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: RetryOptions = {},
|
||||||
|
): Promise<T> => {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
let lastError: unknown;
|
||||||
|
let delay = opts.initialDelayMs;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
lastError = error;
|
||||||
|
const errorStr = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
if (attempt === opts.maxRetries || !opts.shouldRetry(error)) {
|
||||||
|
logger.error('Retry exhausted', {
|
||||||
|
attempt,
|
||||||
|
maxRetries: opts.maxRetries,
|
||||||
|
error: errorStr,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Retrying after error', {
|
||||||
|
attempt,
|
||||||
|
delay,
|
||||||
|
error: errorStr,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an async function with a configurable timeout.
|
||||||
|
*
|
||||||
|
* If `fn` does not settle within `timeoutMs` milliseconds the returned
|
||||||
|
* promise rejects with a timeout error. The underlying `fn` continues
|
||||||
|
* executing but its result is ignored.
|
||||||
|
*
|
||||||
|
* @param fn - The async function to execute.
|
||||||
|
* @param timeoutMs - Timeout in milliseconds (default: 30000).
|
||||||
|
* @returns The resolved value of `fn`.
|
||||||
|
*/
|
||||||
|
export const withTimeout = async <T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
timeoutMs: number = 30000,
|
||||||
|
): Promise<T> => {
|
||||||
|
return Promise.race([
|
||||||
|
fn(),
|
||||||
|
new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a primary async function and falls back to a secondary function
|
||||||
|
* if the primary throws.
|
||||||
|
*
|
||||||
|
* The fallback function is called only when the primary rejects. If the
|
||||||
|
* fallback also throws the error propagates to the caller.
|
||||||
|
*
|
||||||
|
* @param primary - The primary async function to attempt first.
|
||||||
|
* @param fallback - The fallback async function invoked on failure.
|
||||||
|
* @returns The resolved value of `primary` or, on failure, of `fallback`.
|
||||||
|
*/
|
||||||
|
export const withFallback = async <T>(
|
||||||
|
primary: () => Promise<T>,
|
||||||
|
fallback: () => Promise<T>,
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await primary();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.warn('Primary operation failed, using fallback', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { once } from 'node:events';
|
||||||
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
|
import { open, stat } from 'node:fs/promises';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { finished } from 'node:stream/promises';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
/** Describes a single file to include in a new ZIP archive. */
|
||||||
|
export type ZipInputFile = {
|
||||||
|
/** Absolute path to the file on disk. */
|
||||||
|
tempPath: string;
|
||||||
|
/** Original file name (used to derive the ZIP entry name). */
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Metadata for one entry in a created ZIP archive. */
|
||||||
|
export type ZipEntry = {
|
||||||
|
/** Original file name as passed to `ZipInputFile`. */
|
||||||
|
fileName: string;
|
||||||
|
/** Sanitised entry name within the archive. */
|
||||||
|
entryName: string;
|
||||||
|
/** CRC-32 checksum of the uncompressed data. */
|
||||||
|
crc32: number;
|
||||||
|
/** Size of the entry when compressed (stored size). */
|
||||||
|
compressedSize: number;
|
||||||
|
/** Size of the uncompressed data. */
|
||||||
|
uncompressedSize: number;
|
||||||
|
/** Byte offset of the local file header in the archive. */
|
||||||
|
localHeaderOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Result returned after creating a ZIP archive. */
|
||||||
|
export type CreatedZip = {
|
||||||
|
/** Absolute path to the temporary ZIP file on disk. */
|
||||||
|
tempPath: string;
|
||||||
|
/** Total size of the archive in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** SHA-256 hex digest of the entire archive content. */
|
||||||
|
fileHash: string;
|
||||||
|
/** Metadata for every entry in the archive. */
|
||||||
|
entries: ZipEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pre-computed CRC-32 lookup table (ISO 3309 / IEEE 802.3 polynomial). */
|
||||||
|
const CRC32_TABLE = new Uint32Array(256).map((_, index) => {
|
||||||
|
let value = index;
|
||||||
|
for (let bit = 0; bit < 8; bit++) {
|
||||||
|
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||||
|
}
|
||||||
|
return value >>> 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a running CRC-32 checksum with the bytes of a buffer.
|
||||||
|
*
|
||||||
|
* @param crc - Current CRC-32 value (typically starts at `0xFFFFFFFF`).
|
||||||
|
* @param chunk - Buffer of bytes to incorporate.
|
||||||
|
* @returns The updated CRC-32 value.
|
||||||
|
*/
|
||||||
|
const updateCrc32 = (crc: number, chunk: Buffer): number => {
|
||||||
|
let value = crc;
|
||||||
|
for (const byte of chunk) {
|
||||||
|
value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||||
|
}
|
||||||
|
return value >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a JavaScript Date into the MS-DOS date/time format used by ZIP
|
||||||
|
* local file headers.
|
||||||
|
*
|
||||||
|
* @param date - The date to convert (defaults to the current time).
|
||||||
|
* @returns An object with separate `time` and `date` bit-fields.
|
||||||
|
*/
|
||||||
|
const dosDateTime = (date = new Date()): { date: number; time: number } => {
|
||||||
|
const year = Math.max(date.getFullYear(), 1980);
|
||||||
|
return {
|
||||||
|
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
||||||
|
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a 16-bit unsigned integer as a little-endian buffer.
|
||||||
|
*
|
||||||
|
* @param value - The integer to write (only the lower 16 bits are used).
|
||||||
|
* @returns A 2-byte buffer.
|
||||||
|
*/
|
||||||
|
const writeUInt16 = (value: number): Buffer<ArrayBuffer> => {
|
||||||
|
const buffer = Buffer.allocUnsafe(2);
|
||||||
|
buffer.writeUInt16LE(value & 0xffff, 0);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a 32-bit unsigned integer as a little-endian buffer.
|
||||||
|
*
|
||||||
|
* @param value - The integer to write (interpreted as unsigned).
|
||||||
|
* @returns A 4-byte buffer.
|
||||||
|
*/
|
||||||
|
const writeUInt32 = (value: number): Buffer<ArrayBuffer> => {
|
||||||
|
const buffer = Buffer.allocUnsafe(4);
|
||||||
|
buffer.writeUInt32LE(value >>> 0, 0);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a chunk of data to a writable stream, waiting for the drain event
|
||||||
|
* if the internal buffer is full (back-pressure handling).
|
||||||
|
*
|
||||||
|
* @param writer - The writable stream (e.g. `createWriteStream` result).
|
||||||
|
* @param chunk - The buffer to write.
|
||||||
|
*/
|
||||||
|
const writeChunk = async (
|
||||||
|
writer: ReturnType<typeof createWriteStream>,
|
||||||
|
chunk: Buffer,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!writer.write(chunk)) {
|
||||||
|
await once(writer, 'drain');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finishes a writable stream and waits for it to close.
|
||||||
|
*
|
||||||
|
* @param writer - The writable stream to end.
|
||||||
|
*/
|
||||||
|
const finishWriter = async (writer: ReturnType<typeof createWriteStream>): Promise<void> => {
|
||||||
|
writer.end();
|
||||||
|
await finished(writer);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises a file name for use as a ZIP entry name.
|
||||||
|
*
|
||||||
|
* Strips directory components, replaces path separators with underscores,
|
||||||
|
* collapses consecutive dots, and ensures uniqueness against the supplied
|
||||||
|
* set of already-used names by appending a numeric suffix when necessary.
|
||||||
|
*
|
||||||
|
* @param fileName - The raw file name to sanitise.
|
||||||
|
* @param usedNames - A set of entry names already claimed; may be mutated.
|
||||||
|
* @returns A unique, safe ZIP entry name.
|
||||||
|
*/
|
||||||
|
export const sanitizeZipEntryName = (fileName: string, usedNames = new Set<string>()): string => {
|
||||||
|
const cleaned = basename(fileName)
|
||||||
|
.replace(/[\\/]+/g, '_')
|
||||||
|
.replace(/\.\.+/g, '.')
|
||||||
|
.trim();
|
||||||
|
const fallback = cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : 'file';
|
||||||
|
const dotIndex = fallback.lastIndexOf('.');
|
||||||
|
const baseName = dotIndex > 0 ? fallback.slice(0, dotIndex) : fallback;
|
||||||
|
const extension = dotIndex > 0 ? fallback.slice(dotIndex) : '';
|
||||||
|
let candidate = fallback;
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (usedNames.has(candidate)) {
|
||||||
|
candidate = `${baseName}-${counter}${extension}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the CRC-32 checksum of a file on disk by streaming its
|
||||||
|
* contents through the lookup-table algorithm.
|
||||||
|
*
|
||||||
|
* @param tempPath - Absolute path to the file.
|
||||||
|
* @returns The CRC-32 value as an unsigned 32-bit integer.
|
||||||
|
*/
|
||||||
|
const calculateFileCrc32 = async (tempPath: string): Promise<number> => {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
const reader = createReadStream(tempPath);
|
||||||
|
for await (const chunk of reader) {
|
||||||
|
crc = updateCrc32(crc, chunk as Buffer);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ZIP archive (stored-only, no compression) from a list of input
|
||||||
|
* files and writes it to a temporary path.
|
||||||
|
*
|
||||||
|
* The archive uses the standard ZIP format with local file headers, a
|
||||||
|
* central directory, and an end-of-central-directory record. Each entry is
|
||||||
|
* stored uncompressed (method 0). The entire archive is SHA-256 hashed
|
||||||
|
* during writing.
|
||||||
|
*
|
||||||
|
* @param files - Array of file descriptors to include in the archive.
|
||||||
|
* @returns Metadata describing the created archive.
|
||||||
|
*/
|
||||||
|
export const createZip = async (files: ZipInputFile[]): Promise<CreatedZip> => {
|
||||||
|
const tempPath = `/tmp/filedrop-${nanoid()}.zip`;
|
||||||
|
const writer = createWriteStream(tempPath);
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
const entries: ZipEntry[] = [];
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const writeHashed = async (chunk: Buffer): Promise<void> => {
|
||||||
|
hasher.update(chunk);
|
||||||
|
await writeChunk(writer, chunk);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const entryName = sanitizeZipEntryName(file.fileName, usedNames);
|
||||||
|
const nameBuffer = Buffer.from(entryName);
|
||||||
|
const fileStats = await stat(file.tempPath);
|
||||||
|
const { date, time } = dosDateTime();
|
||||||
|
const localHeaderOffset = offset;
|
||||||
|
const crc32 = await calculateFileCrc32(file.tempPath);
|
||||||
|
|
||||||
|
const localHeader = Buffer.concat([
|
||||||
|
writeUInt32(0x04034b50),
|
||||||
|
writeUInt16(20),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(time),
|
||||||
|
writeUInt16(date),
|
||||||
|
writeUInt32(crc32),
|
||||||
|
writeUInt32(fileStats.size),
|
||||||
|
writeUInt32(fileStats.size),
|
||||||
|
writeUInt16(nameBuffer.byteLength),
|
||||||
|
writeUInt16(0),
|
||||||
|
nameBuffer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writeHashed(localHeader);
|
||||||
|
const reader = createReadStream(file.tempPath);
|
||||||
|
for await (const chunk of reader) {
|
||||||
|
await writeHashed(chunk as Buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fileName: file.fileName,
|
||||||
|
entryName,
|
||||||
|
crc32,
|
||||||
|
compressedSize: fileStats.size,
|
||||||
|
uncompressedSize: fileStats.size,
|
||||||
|
localHeaderOffset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralDirectoryOffset = offset;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nameBuffer = Buffer.from(entry.entryName);
|
||||||
|
const { date, time } = dosDateTime();
|
||||||
|
await writeHashed(
|
||||||
|
Buffer.concat([
|
||||||
|
writeUInt32(0x02014b50),
|
||||||
|
writeUInt16(20),
|
||||||
|
writeUInt16(20),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(time),
|
||||||
|
writeUInt16(date),
|
||||||
|
writeUInt32(entry.crc32),
|
||||||
|
writeUInt32(entry.compressedSize),
|
||||||
|
writeUInt32(entry.uncompressedSize),
|
||||||
|
writeUInt16(nameBuffer.byteLength),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt32(0),
|
||||||
|
writeUInt32(entry.localHeaderOffset),
|
||||||
|
nameBuffer,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralDirectorySize = offset - centralDirectoryOffset;
|
||||||
|
await writeHashed(
|
||||||
|
Buffer.concat([
|
||||||
|
writeUInt32(0x06054b50),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(entries.length),
|
||||||
|
writeUInt16(entries.length),
|
||||||
|
writeUInt32(centralDirectorySize),
|
||||||
|
writeUInt32(centralDirectoryOffset),
|
||||||
|
writeUInt16(0),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await finishWriter(writer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempPath,
|
||||||
|
sizeBytes: offset,
|
||||||
|
fileHash: hasher.digest('hex'),
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
writer.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a single entry from an in-memory ZIP buffer.
|
||||||
|
*
|
||||||
|
* Only stored (uncompressed) entries are supported — entries compressed
|
||||||
|
* with any method return `null`.
|
||||||
|
*
|
||||||
|
* @param zipBuffer - The full ZIP archive as a buffer.
|
||||||
|
* @param entryName - The exact entry name to extract.
|
||||||
|
* @returns The entry's data as a buffer, or `null` if not found or
|
||||||
|
* compressed.
|
||||||
|
*/
|
||||||
|
export const extractZipEntry = async (
|
||||||
|
zipBuffer: Buffer,
|
||||||
|
entryName: string,
|
||||||
|
): Promise<Buffer | null> => {
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 30 <= zipBuffer.byteLength) {
|
||||||
|
const signature = zipBuffer.readUInt32LE(offset);
|
||||||
|
if (signature !== 0x04034b50) break;
|
||||||
|
|
||||||
|
const compressionMethod = zipBuffer.readUInt16LE(offset + 8);
|
||||||
|
const compressedSize = zipBuffer.readUInt32LE(offset + 18);
|
||||||
|
const fileNameLength = zipBuffer.readUInt16LE(offset + 26);
|
||||||
|
const extraLength = zipBuffer.readUInt16LE(offset + 28);
|
||||||
|
const nameStart = offset + 30;
|
||||||
|
const nameEnd = nameStart + fileNameLength;
|
||||||
|
const dataStart = nameEnd + extraLength;
|
||||||
|
const dataEnd = dataStart + compressedSize;
|
||||||
|
const currentName = zipBuffer.subarray(nameStart, nameEnd).toString();
|
||||||
|
|
||||||
|
if (currentName === entryName) {
|
||||||
|
if (compressionMethod !== 0) return null;
|
||||||
|
return zipBuffer.subarray(dataStart, dataEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Byte-range location of a stored entry within a ZIP archive on disk. */
|
||||||
|
export type LocatedZipEntry = {
|
||||||
|
/** Byte offset where the entry data begins. */
|
||||||
|
start: number;
|
||||||
|
/** Length of the entry data in bytes. */
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates a stored (uncompressed) entry within a ZIP archive on disk
|
||||||
|
* without reading the entire file into memory.
|
||||||
|
*
|
||||||
|
* Scans local file headers sequentially until the matching entry is found
|
||||||
|
* or the end of valid headers is reached.
|
||||||
|
*
|
||||||
|
* @param zipPath - Absolute path to the ZIP file on disk.
|
||||||
|
* @param entryName - The exact entry name to locate.
|
||||||
|
* @returns The byte range of the entry, or `null` if not found or
|
||||||
|
* compressed.
|
||||||
|
*/
|
||||||
|
export const locateZipEntry = async (
|
||||||
|
zipPath: string,
|
||||||
|
entryName: string,
|
||||||
|
): Promise<LocatedZipEntry | null> => {
|
||||||
|
const handle = await open(zipPath, 'r');
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const header = Buffer.alloc(30);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { bytesRead } = await handle.read(header, 0, header.byteLength, offset);
|
||||||
|
if (bytesRead < header.byteLength) return null;
|
||||||
|
|
||||||
|
const signature = header.readUInt32LE(0);
|
||||||
|
if (signature !== 0x04034b50) return null;
|
||||||
|
|
||||||
|
const compressionMethod = header.readUInt16LE(8);
|
||||||
|
const compressedSize = header.readUInt32LE(18);
|
||||||
|
const fileNameLength = header.readUInt16LE(26);
|
||||||
|
const extraLength = header.readUInt16LE(28);
|
||||||
|
const nameBuffer = Buffer.alloc(fileNameLength);
|
||||||
|
const nameOffset = offset + 30;
|
||||||
|
await handle.read(nameBuffer, 0, fileNameLength, nameOffset);
|
||||||
|
|
||||||
|
const dataStart = nameOffset + fileNameLength + extraLength;
|
||||||
|
if (nameBuffer.toString() === entryName) {
|
||||||
|
if (compressionMethod !== 0) return null;
|
||||||
|
return { start: dataStart, length: compressedSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataStart + compressedSize;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -36,7 +36,7 @@ const mockRequireAuth = mock(
|
|||||||
Response.json({ error: 'Unauthorized' }, { status: 401 }),
|
Response.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
mock.module('../src/bot', () => ({
|
mock.module('../src/interfaces/bot/handler', () => ({
|
||||||
startBot: mockStartBot,
|
startBot: mockStartBot,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -65,6 +65,9 @@ mock.module('../src/routes/auth', () => ({
|
|||||||
|
|
||||||
mock.module('../src/utils/rateLimit', () => ({
|
mock.module('../src/utils/rateLimit', () => ({
|
||||||
cleanupRateLimitCache: mock(),
|
cleanupRateLimitCache: mock(),
|
||||||
|
clearRateLimitCache: mock(),
|
||||||
|
checkRateLimit: mock(() => true),
|
||||||
|
getRateLimitStats: mock(() => ({})),
|
||||||
withRateLimit: <T extends Request>(
|
withRateLimit: <T extends Request>(
|
||||||
handler: (req: T) => Promise<Response>,
|
handler: (req: T) => Promise<Response>,
|
||||||
): ((req: T) => Promise<Response>) => handler,
|
): ((req: T) => Promise<Response>) => handler,
|
||||||
|
|||||||
+65
-65
@@ -1,5 +1,5 @@
|
|||||||
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
import type { TelegramMediaMessage } from '../src/utils/file';
|
import type { TelegramMediaMessage } from '../src/shared/utils/file';
|
||||||
import logger from '../src/utils/logger';
|
import logger from '../src/utils/logger';
|
||||||
|
|
||||||
// Mock environment
|
// Mock environment
|
||||||
@@ -46,61 +46,43 @@ mock.module('telegraf', () => ({
|
|||||||
Telegraf: MockTelegraf,
|
Telegraf: MockTelegraf,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock database
|
|
||||||
const mockInsert = mock(() => ({
|
|
||||||
values: mock(() => Promise.resolve()),
|
|
||||||
}));
|
|
||||||
type ExistingFile = {
|
|
||||||
publicId: string;
|
|
||||||
telegramFileId: string;
|
|
||||||
telegramFileUniqueId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockFindFileByUniqueId = mock((): Promise<ExistingFile | null> => Promise.resolve(null));
|
|
||||||
|
|
||||||
mock.module('../src/db/index', () => ({
|
|
||||||
db: {
|
|
||||||
insert: mockInsert,
|
|
||||||
},
|
|
||||||
files: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module('../src/db/files', () => ({
|
|
||||||
findFileByUniqueId: mockFindFileByUniqueId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock forwardToStorage
|
|
||||||
const mockForwardToStorage = mock(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
telegramFileId: 'stored_file_id',
|
|
||||||
telegramFileUniqueId: 'stored_unique_id',
|
|
||||||
storageMessageId: 9999,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
mock.module('../src/utils/telegram', () => ({
|
|
||||||
forwardToStorage: mockForwardToStorage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const infoSpy = spyOn(logger, 'info');
|
const infoSpy = spyOn(logger, 'info');
|
||||||
const errorSpy = spyOn(logger, 'error');
|
const errorSpy = spyOn(logger, 'error');
|
||||||
|
|
||||||
describe('Telegram Bot Handler', () => {
|
describe('Telegram Bot Handler', () => {
|
||||||
|
let mockTelegramService: { forwardToStorage: ReturnType<typeof mock> };
|
||||||
|
let mockFileRepo: { findByUniqueId: ReturnType<typeof mock>; create: ReturnType<typeof mock> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockLaunch.mockClear();
|
mockLaunch.mockClear();
|
||||||
mockCommand.mockClear();
|
mockCommand.mockClear();
|
||||||
mockOn.mockClear();
|
mockOn.mockClear();
|
||||||
mockUse.mockClear();
|
mockUse.mockClear();
|
||||||
mockInsert.mockClear();
|
|
||||||
mockFindFileByUniqueId.mockClear();
|
|
||||||
mockFindFileByUniqueId.mockResolvedValue(null);
|
|
||||||
mockForwardToStorage.mockClear();
|
|
||||||
infoSpy.mockClear();
|
infoSpy.mockClear();
|
||||||
errorSpy.mockClear();
|
errorSpy.mockClear();
|
||||||
|
|
||||||
|
mockTelegramService = {
|
||||||
|
forwardToStorage: mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
telegramFileId: 'stored_file_id',
|
||||||
|
telegramFileUniqueId: 'stored_unique_id',
|
||||||
|
storageMessageId: 9999,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFileRepo = {
|
||||||
|
findByUniqueId: mock((): Promise<unknown> => Promise.resolve(null)),
|
||||||
|
create: mock(() => Promise.resolve()),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize and launch the bot', async () => {
|
it('should initialize and launch the bot', async () => {
|
||||||
const { startBot } = await import('../src/bot');
|
const { startBot } = await import('../src/interfaces/bot/handler');
|
||||||
const bot = await startBot();
|
const bot = await startBot({
|
||||||
|
telegramService: mockTelegramService,
|
||||||
|
fileRepo: mockFileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
expect(bot).toBeDefined();
|
expect(bot).toBeDefined();
|
||||||
expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function));
|
expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function));
|
||||||
@@ -113,8 +95,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle /start command', async () => {
|
it('should handle /start command', async () => {
|
||||||
const { startBot } = await import('../src/bot');
|
const { startBot } = await import('../src/interfaces/bot/handler');
|
||||||
await startBot();
|
await startBot({
|
||||||
|
telegramService: mockTelegramService,
|
||||||
|
fileRepo: mockFileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
const startHandler = getStartHandler();
|
const startHandler = getStartHandler();
|
||||||
const replyMock = mock(() => Promise.resolve());
|
const replyMock = mock(() => Promise.resolve());
|
||||||
@@ -127,8 +112,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should process document uploads and save to db', async () => {
|
it('should process document uploads and save to db', async () => {
|
||||||
const { startBot } = await import('../src/bot');
|
const { startBot } = await import('../src/interfaces/bot/handler');
|
||||||
await startBot();
|
await startBot({
|
||||||
|
telegramService: mockTelegramService,
|
||||||
|
fileRepo: mockFileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
const fileHandler = getFileHandler();
|
const fileHandler = getFileHandler();
|
||||||
const replyMock = mock(() => Promise.resolve());
|
const replyMock = mock(() => Promise.resolve());
|
||||||
@@ -150,8 +138,8 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockForwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document');
|
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document');
|
||||||
expect(mockInsert).toHaveBeenCalled();
|
expect(mockFileRepo.create).toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(
|
expect(replyMock).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('File berhasil diupload'),
|
expect.stringContaining('File berhasil diupload'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -159,8 +147,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject uploads exceeding max size limit', async () => {
|
it('should reject uploads exceeding max size limit', async () => {
|
||||||
const { startBot } = await import('../src/bot');
|
const { startBot } = await import('../src/interfaces/bot/handler');
|
||||||
await startBot();
|
await startBot({
|
||||||
|
telegramService: mockTelegramService,
|
||||||
|
fileRepo: mockFileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
const fileHandler = getFileHandler();
|
const fileHandler = getFileHandler();
|
||||||
const replyMock = mock(() => Promise.resolve());
|
const replyMock = mock(() => Promise.resolve());
|
||||||
@@ -183,18 +174,21 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds'));
|
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return existing download link for duplicates without uploading again', async () => {
|
it('should return existing download link for duplicates without uploading again', async () => {
|
||||||
const { startBot } = await import('../src/bot');
|
const { startBot } = await import('../src/interfaces/bot/handler');
|
||||||
await startBot();
|
await startBot({
|
||||||
|
telegramService: mockTelegramService,
|
||||||
|
fileRepo: mockFileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
const fileHandler = getFileHandler();
|
const fileHandler = getFileHandler();
|
||||||
const replyMock = mock(() => Promise.resolve());
|
const replyMock = mock(() => Promise.resolve());
|
||||||
|
|
||||||
mockFindFileByUniqueId.mockResolvedValueOnce({
|
mockFileRepo.findByUniqueId.mockResolvedValueOnce({
|
||||||
publicId: 'already_exists_abc',
|
publicId: 'already_exists_abc',
|
||||||
telegramFileId: 'stored_file_id',
|
telegramFileId: 'stored_file_id',
|
||||||
telegramFileUniqueId: 'doc_uniq_123',
|
telegramFileUniqueId: 'doc_uniq_123',
|
||||||
@@ -218,8 +212,8 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled();
|
||||||
expect(mockInsert).not.toHaveBeenCalled();
|
expect(mockFileRepo.create).not.toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(
|
expect(replyMock).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('already_exists_abc'),
|
expect.stringContaining('already_exists_abc'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -227,8 +221,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should process sticker uploads', async () => {
|
it('should process sticker uploads', async () => {
|
||||||
const { startBot } = await import('../src/bot');
|
const { startBot } = await import('../src/interfaces/bot/handler');
|
||||||
await startBot();
|
await startBot({
|
||||||
|
telegramService: mockTelegramService,
|
||||||
|
fileRepo: mockFileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
const fileHandler = getFileHandler();
|
const fileHandler = getFileHandler();
|
||||||
const replyMock = mock(() => Promise.resolve());
|
const replyMock = mock(() => Promise.resolve());
|
||||||
@@ -248,8 +245,8 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockForwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker');
|
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker');
|
||||||
expect(mockInsert).toHaveBeenCalled();
|
expect(mockFileRepo.create).toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(
|
expect(replyMock).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('File berhasil diupload'),
|
expect.stringContaining('File berhasil diupload'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -257,8 +254,11 @@ describe('Telegram Bot Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should process video note uploads', async () => {
|
it('should process video note uploads', async () => {
|
||||||
const { startBot } = await import('../src/bot');
|
const { startBot } = await import('../src/interfaces/bot/handler');
|
||||||
await startBot();
|
await startBot({
|
||||||
|
telegramService: mockTelegramService,
|
||||||
|
fileRepo: mockFileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
const fileHandler = getFileHandler();
|
const fileHandler = getFileHandler();
|
||||||
const replyMock = mock(() => Promise.resolve());
|
const replyMock = mock(() => Promise.resolve());
|
||||||
@@ -278,8 +278,8 @@ describe('Telegram Bot Handler', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fileHandler(ctx);
|
await fileHandler(ctx);
|
||||||
expect(mockForwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note');
|
expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note');
|
||||||
expect(mockInsert).toHaveBeenCalled();
|
expect(mockFileRepo.create).toHaveBeenCalled();
|
||||||
expect(replyMock).toHaveBeenCalledWith(
|
expect(replyMock).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('File berhasil diupload'),
|
expect.stringContaining('File berhasil diupload'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -289,4 +289,4 @@ describe('Telegram Bot Handler', () => {
|
|||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
mock.restore();
|
mock.restore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user