All files / routes/pro presigned-uploads.routes.ts

100% Statements 36/36
100% Branches 22/22
100% Functions 2/2
100% Lines 36/36

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                            1x                                                                           1x                             1x     1x       20x 20x 20x   20x               20x 1x   19x 1x   18x 1x       17x 1x                   16x       9x         7x     20x 2x         5x               5x   15x           1x       6x 6x 6x 6x                     6x 3x 3x       1x       5x   3x           3x            
// Pro Presigned Upload Routes - Request presigned URLs and confirm uploads
import { Hono } from "hono";
import type { Dal } from "../../dal";
import { ValidationError } from "../../lib/errors";
import {
	ALLOWED_IMAGE_TYPES,
	ALLOWED_UPLOAD_TYPES,
} from "../../lib/file-validation";
import { ForbiddenError } from "../../lib/errors";
import { handleError, success } from "../../lib/response";
import { requireProAccess, STAFF_WRITE_DENIED_MESSAGE } from "../../middleware";
import type { Services } from "../../services";
import type { UploadContext } from "../../services/upload.service";
 
const VALID_CONTEXTS: UploadContext[] = [
	"project-photo",
	"blog-cover",
	"blog-image",
	"profile",
	"certification",
	"leadership",
	"testimonial",
	"logo",
	"cover",
	"room-media",
];
 
/**
 * The contexts a `staff` member may drive through the presigned pair.
 *
 * WHY THIS EXISTS — the second body-level hole in the staff allowlist, and the
 * same shape as the first (`newStageId` on the notes route).
 *
 * `STAFF_WRITE_ALLOWLIST` in `middleware/auth.middleware.ts` permits
 * `POST /uploads/request` and `POST /uploads/:uploadId/confirm` because they
 * are the presigned leg of **room media** — the capability staff are granted.
 * But the context is in the BODY, and a path allowlist cannot see a body. The
 * pair accepts ten contexts, and confirming one writes a real entity
 * (`services/entity-creator.ts`): `logo`/`profile` set `pros.logo_url`,
 * `cover` sets `pros.cover_image`, and `leadership`/`certification`/
 * `testimonial`/`blog-cover`/`blog-image` write company-profile and blog
 * assets. All of those are studio branding on a public marketplace listing,
 * and the allowlist denies every *path* that otherwise reaches them —
 * `POST /upload`, `PUT /company-profile`, `POST /blogs/my-blogs/:id/images/upload`.
 * So without this gate the presigned pair is a side door around each of those
 * denials, reachable by any staff member with an HTTP client.
 *
 * Note this only bites in presigned mode. `lib/upload.ts` in the portal routes
 * worker-mode uploads to the per-context paths instead, which the allowlist
 * already refuses one by one — so this makes the two modes agree rather than
 * inventing a new rule.
 */
const STAFF_UPLOAD_CONTEXTS: readonly UploadContext[] = ["room-media"];
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
		db: ReturnType<typeof import("../../db").getDb>;
		proId: string;
		proRole: string;
	};
};
 
const presignedUploads = new Hono<Env>();
 
// Request a presigned upload URL
presignedUploads.post(
	"/:proId/uploads/request",
	requireProAccess,
	async (c) => {
		try {
			const proId = c.get("proId");
			const services = c.get("services");
 
			const body = await c.req.json<{
				fileName?: string;
				contentType?: string;
				context?: string;
				contextId?: string;
			}>();
 
			// Validate required fields
			if (!body.fileName?.trim()) {
				throw new ValidationError("fileName is required");
			}
			if (!body.contentType?.trim()) {
				throw new ValidationError("contentType is required");
			}
			if (!body.context?.trim()) {
				throw new ValidationError("context is required");
			}
 
			// Validate context against known values
			if (!VALID_CONTEXTS.includes(body.context as UploadContext)) {
				throw new ValidationError(
					`Invalid upload context. Allowed: ${VALID_CONTEXTS.join(", ")}`,
				);
			}
 
			// Staff are allowlisted for this route to upload ROOM MEDIA. Any
			// other context writes studio branding, blog or company-profile
			// assets on confirm — see STAFF_UPLOAD_CONTEXTS above. Refused at
			// request time so no upload row is created and no presigned URL is
			// ever handed out, rather than only at confirm.
			if (
				c.get("proRole") === "staff" &&
				!STAFF_UPLOAD_CONTEXTS.includes(body.context as UploadContext)
			) {
				throw new ForbiddenError(STAFF_WRITE_DENIED_MESSAGE);
			}
 
			// Validate contentType: room-media allows video, others are image-only
			const allowedTypes =
				body.context === "room-media"
					? ALLOWED_UPLOAD_TYPES
					: ALLOWED_IMAGE_TYPES;
			if (!allowedTypes.includes(body.contentType)) {
				throw new ValidationError(
					`Invalid content type. Allowed: ${allowedTypes.join(", ")}`,
				);
			}
 
			const result = await services.upload.requestUpload({
				proId,
				fileName: body.fileName,
				contentType: body.contentType,
				context: body.context as UploadContext,
				contextId: body.contextId,
			});
 
			return success(c, result, 201);
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
// Confirm an upload has completed
presignedUploads.post(
	"/:proId/uploads/:uploadId/confirm",
	requireProAccess,
	async (c) => {
		try {
			const proId = c.get("proId");
			const uploadId = c.req.param("uploadId");
			const services = c.get("services");
 
			// Belt and braces behind the request-time gate above: a staff member
			// cannot create a non-room-media upload row, but an owner or manager
			// can, and confirming someone else's pending row is what would write
			// the entity. Checked here rather than in `confirmUpload` so the
			// service stays role-agnostic.
			//
			// A missing row, or one belonging to another pro, is deliberately NOT
			// distinguished — that stays the service's 404/403 to report, so this
			// guard cannot be used to probe which upload ids exist.
			if (c.get("proRole") === "staff") {
				const pending = await c.get("dal").uploads.getById(uploadId);
				if (
					pending?.proId === proId &&
					!STAFF_UPLOAD_CONTEXTS.includes(pending.context as UploadContext)
				) {
					throw new ForbiddenError(STAFF_WRITE_DENIED_MESSAGE);
				}
			}
 
			const upload = await services.upload.confirmUpload(uploadId, proId);
 
			return success(c, {
				status: upload.status,
				storageKey: upload.storageKey,
				contextId: upload.contextId,
			});
		} catch (err) {
			return handleError(c, err);
		}
	},
);
 
export default presignedUploads;