tia/src/app/api/garments/upload/route.ts
Mannu 3cfcbdc0ca fix: garment upload MIME/proxy, log edit time pre-fill, date-ist hardening, wardrobe camera+gallery
- /api/img: add garments/ to ALLOWED_PREFIXES so garment images proxy correctly
- garments upload: resolve Android empty MIME type from file extension; return
  /api/img proxy URLs instead of raw pub-*.r2.dev (blocked by Cloudflare Bot Mgmt)
- garments route + [id] route: toDto() now builds /api/img?key= proxy URLs
- date-ist.ts: add toUTCDate() helper -- strings without Z/offset treated as UTC,
  preventing browser local-time misinterpretation; used in fmtTime, fmtDate, dateIST
- LogModal: add editTime to SmartDefault; pre-fill time picker (custom preset) when
  editing an existing log instead of defaulting to now
- activity page: pass editTime: log.loggedAt in handleEdit so LogModal pre-fills
- wardrobe/add: explicit Camera and Gallery buttons via separate hidden inputs
  (one with capture=environment for direct camera, one without for media picker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:42:04 +05:30

111 lines
3.7 KiB
TypeScript

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import sharp from "sharp";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
const MAX_BYTES = 8 * 1024 * 1024; // 8MB
/** Android often returns file.type="" or "application/octet-stream" — infer from extension. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
webp: "image/webp", heic: "image/heic",
};
return map[ext] || "image/jpeg";
}
function getR2Config() {
return {
accountId: process.env.R2_ACCOUNT_ID!,
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretKey: process.env.R2_SECRET_ACCESS_KEY!,
bucket: process.env.R2_BUCKET_NAME!,
publicUrl: process.env.R2_PUBLIC_URL,
};
}
function makeClient(R2: ReturnType<typeof getR2Config>) {
return new S3Client({
region: "auto",
endpoint: `https://${R2.accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2.accessKeyId, secretAccessKey: R2.secretKey },
});
}
// POST /api/garments/upload
// Accepts multipart/form-data with a single "file" field.
// Returns { imageKey, thumbKey, imageUrl, thumbUrl }
export async function POST(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
// family_id comes from session — never from request params
const familyId = auth.session!.familyId!;
const R2 = getR2Config();
if (!R2.accountId || !R2.accessKeyId || !R2.secretKey || !R2.bucket) {
return NextResponse.json({ error: "R2 not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
const contentType = resolveContentType(file);
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "File too large (max 8MB)" }, { status: 400 });
}
const ext = file.name.split(".").pop()?.toLowerCase() || "jpg";
const id = randomUUID();
const imageKey = `garments/${familyId}/${id}-original.${ext}`;
const thumbKey = `garments/${familyId}/${id}-thumb.webp`;
const arrayBuffer = await file.arrayBuffer();
const originalBuffer = Buffer.from(arrayBuffer);
const thumbBuffer = await sharp(originalBuffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 70 })
.toBuffer();
const client = makeClient(R2);
await Promise.all([
client.send(new PutObjectCommand({
Bucket: R2.bucket,
Key: imageKey,
Body: originalBuffer,
ContentType: contentType,
})),
client.send(new PutObjectCommand({
Bucket: R2.bucket,
Key: thumbKey,
Body: thumbBuffer,
ContentType: "image/webp",
})),
]);
// Return proxy URLs (never raw R2 pub URLs — those are blocked by Cloudflare Bot Management)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
return NextResponse.json({
imageKey,
thumbKey,
imageUrl: `${appUrl}/api/img?key=${encodeURIComponent(imageKey)}`,
thumbUrl: `${appUrl}/api/img?key=${encodeURIComponent(thumbKey)}`,
});
}