All files / services upload.service.ts

99.09% Statements 110/111
98.55% Branches 68/69
100% Functions 11/11
100% Lines 105/105

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386                                                                                        5x   5x                         59x 59x 59x   59x                             7x   7x                 7x                         7x 7x 7x 7x 7x 7x 6x                         32x   32x 32x 32x     32x                         20x           20x           14x                             6x 6x 1x     5x 1x       4x 1x       3x 1x 1x         2x 2x   1x 1x 1x       1x   1x                       9x 9x   1x       8x 6x     6x     6x 2x     2x 2x 2x         6x   4x 1x 1x         3x 3x 1x     2x                 3x 3x   3x 4x   4x   3x 3x     1x             3x             28x 28x   28x   4x 1x     3x     2x 1x     1x     2x 1x     1x     3x 1x     2x         10x     2x 1x     1x     2x 1x     1x     2x 1x     1x     1x                   28x                   32x 1x   31x 1x   30x 1x       29x 31x 1x            
// Upload Service - Presigned URL upload lifecycle management
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { Dal } from "../dal";
import type { Upload } from "../db/schema";
import { generateId } from "../lib/utils";
import {
	NotFoundError,
	ValidationError,
	ForbiddenError,
	ConfigurationError,
} from "../lib/errors";
import { MAX_IMAGE_SIZE, MAX_VIDEO_SIZE } from "../lib/file-validation";
import { EntityCreator } from "./entity-creator";
 
// Upload context types (matches schema enum)
export type UploadContext =
	| "project-photo"
	| "blog-cover"
	| "blog-image"
	| "profile"
	| "certification"
	| "leadership"
	| "testimonial"
	| "logo"
	| "cover"
	| "room-media";
 
export interface RequestUploadInput {
	proId: string;
	context: UploadContext;
	contextId?: string;
	fileName: string;
	contentType: string;
}
 
export interface RequestUploadResult {
	uploadId: string;
	presignedUrl: string;
	storageKey: string;
	expiresAt: Date;
}
 
// Presigned URL expiry: 15 minutes
const PRESIGNED_URL_EXPIRES_IN = 15 * 60;
// Upload record expiry: 1 hour (gives buffer beyond presigned URL)
const UPLOAD_RECORD_TTL_MS = 60 * 60 * 1000;
 
/**
 * UploadService manages the presigned URL upload lifecycle:
 * 1. Generate presigned PUT URLs for client-side uploads
 * 2. Confirm uploads and create entity records
 * 3. Handle R2 event notifications (backup confirmation)
 * 4. Clean up expired/abandoned uploads
 */
export class UploadService {
	private entityCreator: EntityCreator;
 
	constructor(
		private dal: Dal,
		private env: CloudflareBindings,
		private s3Client?: S3Client,
	) {
		this.entityCreator = new EntityCreator(dal);
	}
 
	/**
	 * Get or create the S3 client for R2 presigned URL generation.
	 */
	private getS3Client(): S3Client {
		/* v8 ignore start -- V8 artifact: cached client always returned after first call */
		if (this.s3Client) return this.s3Client;
		/* v8 ignore stop */
 
		// Validate before constructing — undefined access keys make AWS SDK throw
		// an opaque "credential is missing" string at sign time, surfacing as a
		// generic 500 INTERNAL_ERROR. Failing here gives operators a clear
		// "which env var is missing" signal in both the API response and logs.
		this.validateR2Config();
 
		this.s3Client = new S3Client({
			region: "auto",
			endpoint: `https://${this.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
			credentials: {
				accessKeyId: this.env.R2_ACCESS_KEY_ID,
				secretAccessKey: this.env.R2_SECRET_ACCESS_KEY,
			},
		});
 
		return this.s3Client;
	}
 
	/**
	 * Verify the four R2 S3-compatible env vars required for presigned URL
	 * generation are all set. Throws ConfigurationError listing exactly which
	 * are missing — operators don't need to grep server logs to debug.
	 *
	 * - R2_ACCOUNT_ID, R2_BUCKET_NAME: non-secret, set in wrangler.jsonc vars.
	 * - R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY: secrets, set via
	 *   `wrangler secret put <name> --env <env>`.
	 */
	private validateR2Config(): void {
		const missing: string[] = [];
		if (!this.env.R2_ACCOUNT_ID) missing.push("R2_ACCOUNT_ID");
		if (!this.env.R2_BUCKET_NAME) missing.push("R2_BUCKET_NAME");
		if (!this.env.R2_ACCESS_KEY_ID) missing.push("R2_ACCESS_KEY_ID");
		if (!this.env.R2_SECRET_ACCESS_KEY) missing.push("R2_SECRET_ACCESS_KEY");
		if (missing.length > 0) {
			throw new ConfigurationError(
				`R2 presigned upload service is not configured. Missing env vars: ${missing.join(", ")}.`,
			);
		}
	}
 
	/**
	 * Request an upload: generates a storage key, creates an upload record,
	 * and returns a presigned PUT URL for the client to upload directly to R2.
	 */
	async requestUpload(
		input: RequestUploadInput,
	): Promise<RequestUploadResult> {
		this.validateRequestInput(input);
 
		const uploadId = `upl_${generateId()}`;
		const storageKey = this.buildStorageKey(input);
		const expiresAt = new Date(Date.now() + UPLOAD_RECORD_TTL_MS);
 
		// Create upload record in DB
		await this.dal.uploads.create({
			id: uploadId,
			proId: input.proId,
			context: input.context,
			contextId: input.contextId,
			storageKey,
			fileName: input.fileName,
			contentType: input.contentType,
			status: "uploading",
			expiresAt,
		});
 
		// Generate presigned PUT URL
		const command = new PutObjectCommand({
			Bucket: this.env.R2_BUCKET_NAME,
			Key: storageKey,
			ContentType: input.contentType,
		});
 
		const presignedUrl = await getSignedUrl(
			this.getS3Client(),
			command,
			{ expiresIn: PRESIGNED_URL_EXPIRES_IN },
		);
 
		return {
			uploadId,
			presignedUrl,
			storageKey,
			expiresAt,
		};
	}
 
	/**
	 * Confirm an upload: verifies the upload exists and belongs to the pro,
	 * creates the entity record, and sets status to "ready".
	 *
	 * Idempotent: if already confirmed, returns success without re-creating entity.
	 */
	async confirmUpload(uploadId: string, proId: string): Promise<Upload> {
		const upload = await this.dal.uploads.getById(uploadId);
		if (!upload) {
			throw new NotFoundError("Upload", uploadId);
		}
 
		if (upload.proId !== proId) {
			throw new ForbiddenError("Upload does not belong to this pro");
		}
 
		// Idempotent: already confirmed
		if (upload.status === "ready") {
			return upload;
		}
 
		// Reject expired uploads
		if (upload.expiresAt < new Date()) {
			await this.dal.uploads.updateStatus(uploadId, "expired");
			throw new ValidationError("Upload has expired");
		}
 
		// Atomically transition to 'ready' — prevents race with R2 event handler
		const transitioned =
			await this.dal.uploads.setReadyIfUploading(uploadId);
		if (!transitioned) {
			// Another path already confirmed — return current state
			const current = await this.dal.uploads.getById(uploadId);
			Iif (!current) throw new NotFoundError("Upload not found");
			return current;
		}
 
		// Create the entity based on context
		await this.entityCreator.createEntity(upload);
 
		return transitioned;
	}
 
	/**
	 * Handle R2 event notification (backup for when client confirm fails).
	 * If the upload is still in "uploading" status, creates the entity.
	 * Always updates fileSize if provided.
	 */
	async handleR2Event(
		storageKey: string,
		fileSize?: number,
	): Promise<void> {
		const upload = await this.dal.uploads.getByStorageKey(storageKey);
		if (!upload) {
			// Unknown storage key — not tracked by uploads system
			return;
		}
 
		// Update file size if provided
		if (fileSize !== undefined) {
			await this.dal.uploads.updateFileSize(upload.id, fileSize);
 
			// Enforce size limits — delete oversized objects
			const maxSize = upload.contentType.startsWith("video/")
				? MAX_VIDEO_SIZE
				: MAX_IMAGE_SIZE;
			if (fileSize > maxSize) {
				console.warn(
					`[Upload] Oversized file detected: ${storageKey} (${fileSize} bytes, max ${maxSize})`,
				);
				await this.env.R2.delete(storageKey);
				await this.dal.uploads.updateStatus(upload.id, "expired");
				return;
			}
		}
 
		// If still uploading, try to confirm as backup
		if (upload.status === "uploading") {
			// Check not expired
			if (upload.expiresAt < new Date()) {
				await this.dal.uploads.updateStatus(upload.id, "expired");
				return;
			}
 
			// Atomically transition — prevents race with client confirm
			const transitioned =
				await this.dal.uploads.setReadyIfUploading(upload.id);
			if (!transitioned) {
				return; // Client confirm already handled it
			}
 
			await this.entityCreator.createEntity(upload);
		}
	}
 
	/**
	 * Clean up expired uploads: finds expired upload records,
	 * deletes the R2 objects, and removes the DB records.
	 */
	async cleanupExpired(): Promise<{ deleted: number }> {
		const expired = await this.dal.uploads.getExpiredUploads();
		let deleted = 0;
 
		for (const upload of expired) {
			try {
				// Delete R2 object
				await this.env.R2.delete(upload.storageKey);
				// Delete DB record
				await this.dal.uploads.delete(upload.id);
				deleted++;
			} catch (error) {
				// Log but continue with other deletions
				console.error(
					`Failed to clean up upload ${upload.id}:`,
					error,
				);
			}
		}
 
		return { deleted };
	}
 
	/**
	 * Build the storage key based on upload context and conventions.
	 */
	private buildStorageKey(input: RequestUploadInput): string {
		const uuid = generateId();
		const ext = this.getExtension(input.fileName);
 
		switch (input.context) {
			case "project-photo":
				if (!input.contextId)
					throw new ValidationError(
						"contextId (projectId) is required for project-photo",
					);
				return `${input.proId}/projects/${input.contextId}/${uuid}.${ext}`;
 
			case "blog-cover":
				if (!input.contextId)
					throw new ValidationError(
						"contextId (blogId) is required for blog-cover",
					);
				return `${input.proId}/blogs/${input.contextId}/cover-${uuid}.${ext}`;
 
			case "blog-image":
				if (!input.contextId)
					throw new ValidationError(
						"contextId (blogId) is required for blog-image",
					);
				return `${input.proId}/blogs/${input.contextId}/${uuid}.${ext}`;
 
			case "room-media":
				if (!input.contextId)
					throw new ValidationError(
						"contextId (roomId) is required for room-media",
					);
				return `${input.proId}/rooms/${input.contextId}/${uuid}.${ext}`;
 
			case "profile":
			case "logo":
			case "cover":
				return `${input.proId}/profile/${uuid}.${ext}`;
 
			case "leadership":
				if (!input.contextId)
					throw new ValidationError(
						"contextId (leadershipId) is required for leadership",
					);
				return `${input.proId}/profile/leadership-${input.contextId}-${uuid}.${ext}`;
 
			case "certification":
				if (!input.contextId)
					throw new ValidationError(
						"contextId (certificationId) is required for certification",
					);
				return `${input.proId}/profile/cert-${input.contextId}-${uuid}.${ext}`;
 
			case "testimonial":
				if (!input.contextId)
					throw new ValidationError(
						"contextId (testimonialId) is required for testimonial",
					);
				return `${input.proId}/profile/testimonial-${input.contextId}-${uuid}.${ext}`;
 
			default:
				throw new ValidationError(
					`Unknown upload context: ${input.context}`,
				);
		}
	}
 
	/**
	 * Extract file extension from filename, defaulting to "jpg".
	 */
	private getExtension(fileName: string): string {
		const ext = fileName.split(".").pop();
		/* v8 ignore start -- V8 artifact: || fallback */
		return ext || "jpg";
		/* v8 ignore stop */
	}
 
	/**
	 * Validate the request upload input.
	 */
	private validateRequestInput(input: RequestUploadInput): void {
		if (!input.proId?.trim()) {
			throw new ValidationError("proId is required");
		}
		if (!input.fileName?.trim()) {
			throw new ValidationError("fileName is required");
		}
		if (!input.contentType?.trim()) {
			throw new ValidationError("contentType is required");
		}
 
		// Validate content type
		const allowedPrefixes = ["image/", "video/"];
		if (!allowedPrefixes.some((p) => input.contentType.startsWith(p))) {
			throw new ValidationError(
				"contentType must be an image or video type",
			);
		}
	}
}