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;
|