All files / routes/pro uploads.routes.ts

100% Statements 55/55
95.83% Branches 23/24
100% Functions 4/4
100% Lines 55/55

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                                            1x                       10x                                 1x     1x 6x 6x 6x   6x 6x   6x 1x     5x     5x 5x     5x 4x                   4x               2x           1x       1x               1x       12x 12x 12x 12x 12x 12x   12x 1x       11x   10x 10x 10x 10x   10x 1x       9x 9x   9x 1x           8x 12x 2x           6x     6x 6x     6x 6x             6x                       6x 6x 6x     6x       6x   6x            
// Pro Upload Routes - Handle image uploads to R2
import { Hono } from "hono";
import type { Dal } from "../../dal";
import type { getDb } from "../../db";
import type { Services } from "../../services";
import { success, handleError } from "../../lib/response";
import { generateId } from "../../lib/utils";
import { requireProAccess } from "../../middleware";
import { ValidationError } from "../../lib/errors";
import {
	ALLOWED_IMAGE_TYPES,
	ALLOWED_VIDEO_TYPES,
	MAX_IMAGE_SIZE,
	MAX_VIDEO_SIZE,
	validateUploadedFile,
} from "../../lib/file-validation";
import type { DualCache } from "../../lib/cache";
import {
	invalidateProjectsListCache,
	recomputeProjectAfterContentChange,
} from "../../lib/project-mutations";
 
const MIME_TO_EXT: Record<string, string> = {
	"image/jpeg": "jpg",
	"image/png": "png",
	"image/webp": "webp",
	"image/gif": "gif",
	"image/avif": "avif",
	"video/mp4": "mp4",
	"video/quicktime": "mov",
	"video/webm": "webm",
};
 
function getSafeExtension(file: File): string {
	return MIME_TO_EXT[file.type] || "jpg";
}
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
		db: ReturnType<typeof getDb>;
		cache: DualCache;
		proId: string;
		proRole: string;
	};
};
 
const uploads = new Hono<Env>();
 
// Upload image to R2
uploads.post("/:proId/upload", requireProAccess, async (c) => {
	try {
		const proId = c.get("proId");
		const r2 = c.env.R2;
 
		const formData = await c.req.formData();
		const file = formData.get("file") as File | null;
 
		if (!file) {
			throw new ValidationError("No file provided");
		}
 
		validateUploadedFile(file);
 
		// Generate unique filename
		const ext = getSafeExtension(file);
		const filename = `${proId}/${generateId()}.${ext}`;
 
		// Upload to R2
		const arrayBuffer = await file.arrayBuffer();
		await r2.put(filename, arrayBuffer, {
			httpMetadata: {
				contentType: file.type,
			},
		});
 
		// Return the image path (will be used to construct URL).
		// `storageKey` mirrors `path` to satisfy the typed UploadResult contract
		// in apps/portal/src/lib/upload.ts (consumers like LogoModal read
		// result.storageKey to populate <ResponsiveImage>).
		return success(c, {
			path: filename,
			storageKey: filename,
			url: `/api/images/${filename}`,
			contentType: file.type,
			size: file.size,
		});
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Upload project photo (legacy endpoint — project_photos table removed)
// Use the media upload endpoint (POST /:proId/projects/:projectId/rooms/:roomId/media) instead.
uploads.post(
	"/:proId/projects/:projectId/upload-photo",
	requireProAccess,
	async (c) => {
		return c.json(
			{ error: "Legacy photo upload is no longer supported. Use the rooms/media upload endpoint." },
			410,
		);
	},
);
 
// Upload media to a room (images + videos)
uploads.post(
	"/:proId/rooms/:roomId/upload-media",
	requireProAccess,
	async (c) => {
		try {
			const proId = c.get("proId");
			const roomIdStr = c.req.param("roomId");
			const roomId = Number.parseInt(roomIdStr, 10);
			const services = c.get("services");
			const r2 = c.env.R2;
 
			if (Number.isNaN(roomId)) {
				throw new ValidationError("Invalid room ID");
			}
 
			// Verify room belongs to pro
			await services.media.verifyRoomOwnership(roomId, proId);
 
			const formData = await c.req.formData();
			const file = formData.get("file") as File | null;
			const caption = formData.get("caption") as string | null;
			const altText = formData.get("altText") as string | null;
 
			if (!file) {
				throw new ValidationError("No file provided");
			}
 
			// Determine media type
			const isImage = ALLOWED_IMAGE_TYPES.includes(file.type);
			const isVideo = ALLOWED_VIDEO_TYPES.includes(file.type);
 
			if (!isImage && !isVideo) {
				throw new ValidationError(
					"Invalid file type. Allowed: JPEG, PNG, WebP, GIF, MP4, WebM, MOV",
				);
			}
 
			// Validate file size based on type
			const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
			if (file.size > maxSize) {
				throw new ValidationError(
					`File too large. Maximum size is ${isVideo ? "500MB" : "10MB"}`,
				);
			}
 
			// Get the room to find the project
			const room = await services.room.getById(roomId);
 
			// Generate unique filename
			const ext = getSafeExtension(file);
			const filename = `${proId}/projects/${room.projectId}/rooms/${roomId}/${generateId()}.${ext}`;
 
			// Upload to R2
			const arrayBuffer = await file.arrayBuffer();
			await r2.put(filename, arrayBuffer, {
				httpMetadata: {
					contentType: file.type,
				},
			});
 
			// Create media record in database
			const media = await services.media.create({
				roomId,
				mediaType: isVideo ? "video" : "image",
				filename: file.name,
				originalFilename: file.name,
				storageKey: filename,
				fileSize: file.size,
				caption: caption ?? undefined,
				altText: altText ?? undefined,
			});
 
			// Re-compute enriched cache and cover image (non-blocking)
			const dal = c.get("dal");
			const db = c.get("db");
			c.executionCtx.waitUntil(
				recomputeProjectAfterContentChange(room.projectId, db, dal, services),
			);
			c.executionCtx.waitUntil(
				invalidateProjectsListCache(c.get("cache"), proId),
			);
 
			return success(c, media, 201);
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
export default uploads;