import { eq, sql } from 'drizzle-orm'; import * as schema from '@shared/types/schema'; import { BaseTracker } from './BaseTracker'; import { TransferStatus } from '@shared/types/schema'; import type { ReminderMessage } from '../types/notification-type'; import { TransferNotFoundError, TransferDetailsNotFoundError, InvalidPasswordError, PasswordRequiredError, TransferExpiredError, } from './errors'; import type { DownloadTrackerStub, AccessVerificationOptions, AccessVerificationResult, DownloadRecordOptions, TransferInitData, DownloadTrackerStateResponse, } from './types'; /** * Durable Object for managing file transfers and downloads */ export class DownloadTracker extends BaseTracker { private notificationQueue: Queue; constructor(state: DurableObjectState, env: any) { super(state, env, 'DownloadTracker'); console.log('Queue available:', !!env.NOTIFICATION_QUEUE); this.notificationQueue = env.NOTIFICATION_QUEUE; } get downloadId(): string { return this.state.id.toString(); } /** * Get a typed stub for this Durable Object */ static getStub(id: DurableObjectId, env: Env): DownloadTrackerStub { // Cast to unknown first to avoid type error return env.DOWNLOAD_TRACKER.get(id) as unknown as DownloadTrackerStub; } static getStubFromId(transferId: string, env: Env): DownloadTrackerStub { const id = env.DOWNLOAD_TRACKER.idFromString(transferId); return this.getStub(id, env); } /** * Create a stub from name */ static getStubFromName(name: string, env: Env): DownloadTrackerStub { const id = env.DOWNLOAD_TRACKER.idFromName(name); return this.getStub(id, env); } /** * Hash a password using PBKDF2 with SHA-256 * @param password - The plain text password to hash * @returns The base64 encoded hash and salt */ private async hashPassword(password: string): Promise { // Generate a random salt const salt = crypto.getRandomValues(new Uint8Array(16)); // Convert password to buffer const enc = new TextEncoder(); const passwordBuffer = enc.encode(password); // Import password as raw key const passwordKey = await crypto.subtle.importKey('raw', passwordBuffer, 'PBKDF2', false, ['deriveBits']); // Generate hash using PBKDF2 const hash = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, passwordKey, 256); // Combine salt and hash const combined = new Uint8Array(salt.length + hash.byteLength); combined.set(salt); combined.set(new Uint8Array(hash), salt.length); // Return as base64 return btoa(String.fromCharCode(...combined)); } /** * Verify a password against a stored hash * @param password - The plain text password to verify * @param storedHash - The stored base64 hash+salt to verify against * @returns True if the password matches, false otherwise */ private async verifyPassword(password: string, storedHash: string): Promise { try { // Decode the stored hash const combined = Uint8Array.from(atob(storedHash), (c) => c.charCodeAt(0)); // Extract salt and hash const salt = combined.slice(0, 16); const hash = combined.slice(16); // Convert password to buffer const enc = new TextEncoder(); const passwordBuffer = enc.encode(password); // Import password as raw key const passwordKey = await crypto.subtle.importKey('raw', passwordBuffer, 'PBKDF2', false, ['deriveBits']); // Generate hash using same parameters const newHash = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, passwordKey, 256); // Compare hashes const newHashArray = new Uint8Array(newHash); if (hash.length !== newHashArray.length) return false; return hash.every((value, index) => value === newHashArray[index]); } catch (error) { this.logger.error('Error verifying password:', { error: error instanceof Error ? error.message : String(error) }); return false; } } /** * Initialize the Download Tracker with transfer data * * Called when an upload is completed to set up the download tracker. * This method: * 1. Creates download record * 2. Creates header record (1:1) * 3. Creates file entry records (1:many with header) * 4. Creates transfer details * 5. Sets expiration alarm */ async initialize(transferData: TransferInitData): Promise { this.logger.debug(`Starting to initialize transfer: ${transferData.transferId}`); // Calculate expiration based on TTL from transferDetails or default const ttlDays = transferData.transferDetails.ttl || this.env.DEFAULT_SHARE_TTL_DAYS || 7; const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString(); this.logger.debug(`Beginning transaction for transfer: ${transferData.transferId}`); // Hash password if provided let passwordHash: string | null = null; if (transferData.transferDetails?.password) { this.logger.debug(`Hashing password for transfer: ${transferData.transferId}`); passwordHash = await this.hashPassword(transferData.transferDetails.password); } await this.ctx.storage.transactionSync>(async () => { this.logger.debug(`Inserting download record for transfer: ${transferData.transferId}`); // Insert downloads record (without header) this.exec( this.db.insert(schema.downloadsTable).values({ id: this.downloadId, transferId: transferData.transferId, userId: transferData.userId, fileSize: transferData.fileSize, fileName: transferData.fileName, contentType: transferData.contentType, status: TransferStatus.READY, downloadCount: 0, createdAt: new Date().toISOString(), expiresAt: expiresAt, storage: transferData.storage, fileId: transferData.fileId, }), ); this.logger.debug(`Inserting transfer header for transfer: ${transferData.transferId}`); this.exec( this.db.insert(schema.transferHeadersTable).values({ downloadId: this.downloadId!, version: transferData.header.version!, totalSize: transferData.header.totalSize!, createdAt: transferData.header.createdAt!, algorithm: transferData.header.algorithm, container: transferData.header.container, compressionDelta: transferData.header.compressionDelta, }), ); if (transferData.header.files && transferData.header.files.length > 0) { this.execBulk( this.db.insert(schema.transferFileEntriesTable), transferData.header.files.map((file) => ({ ...file, downloadId: this.downloadId, })), ); } this.logger.debug(`Inserting transfer details for transfer: ${transferData.transferId}`); // Insert transfer details this.exec( this.db.insert(schema.transferDetailsTable).values({ downloadId: this.downloadId, ...transferData.transferDetails, passwordHash: passwordHash, }), ); }); // If we get here, transaction was successful and committed this.logger.info(`Transaction committed successfully for transfer: ${transferData.transferId}`); // Calculate notification dates (for testing: 1 min and 2 min) - MOVED AFTER TRANSACTION const now = new Date(); const fifthDay = new Date(now.getTime() + 120 * 1000); // 1 minute from now const sixthDay = new Date(now.getTime() + 180 * 1000); // 2 minutes from now // Store these dates for the alarm handler await this.state.storage.put('downloadNotificationDates', { fifthDay: fifthDay.toISOString(), sixthDay: sixthDay.toISOString(), expiresAt: expiresAt, // Store actual expiration too }); // Set alarm using ONLY the date object await this.state.storage.setAlarm(fifthDay); this.logger.info(`Alarm set successfully for transfer: ${transferData.transferId} at ${fifthDay.toISOString()}`); } /** * Verify access to the transfer (check password only) * This method is only used for direct password verification, not for general access control. * * NOTE: No transaction was used here, which is correct. Simple reads don't need explicit * transactions since Durable Objects handle read consistency automatically. */ async verifyAccess(options: AccessVerificationOptions): Promise { // Get transfer and details - individual reads are fine for verification const download = await this.db .select() .from(schema.downloadsTable) // .where(eq(schema.downloadsTable.id, transferId)) .get(); if (!download) { throw new TransferNotFoundError(); } this.logger.debug(`Verifying password for transfer: ${download.id}`); const details = await this.db .select() .from(schema.transferDetailsTable) .where(eq(schema.transferDetailsTable.downloadId, download.id)) .get(); if (!details) { throw new TransferDetailsNotFoundError(download.id); } // Check if transfer has expired const now = new Date(); const expiresAt = new Date(download.expiresAt); if (now > expiresAt) { throw new TransferExpiredError(download.id); } // Calculate time until transfer expiration (in seconds) const timeUntilExpiration = Math.max(0, Math.floor((expiresAt.getTime() - now.getTime()) / 1000)); // If transfer has a password, verify it if (details.passwordHash) { if (!options.password) { throw new PasswordRequiredError(); } const matches = await this.verifyPassword(options.password, details.passwordHash); if (!matches) { throw new InvalidPasswordError(); } } return { allowed: true, ttl: timeUntilExpiration, reason: 'Access verified', }; } /** * Record a download event * * NOTE: Transaction still needed here because we read the current download count * and then increment it atomically. This prevents race conditions with concurrent downloads. */ async recordDownload(options: DownloadRecordOptions): Promise { this.logger.debug(`Recording download for transfer: ${this.downloadId}`); const transferId = this.downloadId; const now = new Date().toISOString(); const result = this.db .update(schema.downloadsTable) .set({ downloadCount: sql`${schema.downloadsTable.downloadCount} + 1`, lastDownloadAt: now, }) // .where(eq(schema.downloadsTable.id, transferId)) .returning({ downloadCount: schema.downloadsTable.downloadCount }); const updated = await result.get(); if (!updated) { throw new TransferNotFoundError(transferId); } } /** * Get transfer details (for API responses and admin panel) * * This method encapsulates all business logic for retrieving and formatting transfer details. * It handles: * 1. Retrieving all relevant data from the database * 2. Processing and formatting the data for external consumption * 3. Adding computed properties like downloadUrl and hasPassword * 4. Reconstructing header from relational data for backward compatibility * * @param tx Optional transaction object. If provided, uses the transaction for database operations */ async getStatus(): Promise { this.logger.debug(`Getting details for transfer: ${this.downloadId}`); // Get download data const download = await this.db.select().from(schema.downloadsTable).where(eq(schema.downloadsTable.id, this.downloadId)).get(); if (!download) { throw new TransferNotFoundError(); } if (download?.status === schema.TransferStatus.EXPIRED) { throw new TransferExpiredError(download.id); } // Get transfer details const details = await this.db .select() .from(schema.transferDetailsTable) .where(eq(schema.transferDetailsTable.downloadId, download.id)) .get(); if (!details) { throw new TransferDetailsNotFoundError(download.id); } // Get header data const headerData = await this.db .select() .from(schema.transferHeadersTable) .where(eq(schema.transferHeadersTable.downloadId, this.downloadId)) .get(); // Get file entries const fileEntries = await this.db .select() .from(schema.transferFileEntriesTable) .where(eq(schema.transferFileEntriesTable.downloadId, this.downloadId)) .all(); // Calculate the download URL const downloadUrl = new URL(`/download/${this.downloadId}`, this.env.PUBLIC_URL).toString(); return { ...download, transferDetails: details, downloadUrl, header: { ...headerData, files: fileEntries, }, }; } // In DownloadTracker.ts async alarm(): Promise { this.logger.info(`Handling alarm for download tracker ${this.downloadId}`); const state = await this.getStatus(); const notificationDates = await this.state.storage.get<{ fifthDay: string; sixthDay: string; expiresAt: string; }>('downloadNotificationDates'); if (!notificationDates) return; const now = new Date(); const fifthDay = new Date(notificationDates.fifthDay); const sixthDay = new Date(notificationDates.sixthDay); const expiresAt = new Date(notificationDates.expiresAt); if (this.notificationQueue) { if (now >= fifthDay && now < sixthDay) { // Send first notification await this.notificationQueue.send({ fileName: state.fileName, expiresAt: state.expiresAt, EmailId: state.transferDetails?.emailSharing?.email ?? '', ownerId: state.userId, fileId: state.fileId, }); // Set next alarm await this.state.storage.setAlarm(sixthDay); } else if (now >= sixthDay && now < expiresAt) { // Send second notification await this.notificationQueue.send({ fileName: state.fileName, expiresAt: state.expiresAt, EmailId: state.transferDetails?.emailSharing?.email ?? '', ownerId: state.userId, fileId: state.fileId, }); } } // Handle expiration if (now >= expiresAt) { await this.db .update(schema.downloadsTable) .set({ status: TransferStatus.EXPIRED }) .where(eq(schema.downloadsTable.id, this.downloadId)); await this.destroy(); } } }