import { randomUUID } from "node:crypto"; import express, { type Express, type Request, type Response } from "express"; import { z } from "zod"; import { authenticatedUser } from "./auth.js"; import { supabaseAdmin } from "./supabase.js"; const BUCKET = "profile-images"; const MAX_BYTES = 4 * 1024 * 1024; const uploadBody = express.raw({ type: ["image/jpeg", "image/png", "image/webp"], limit: MAX_BYTES }); const playerIdSchema = z.string().uuid(); type Owner = "players" | "users"; type ImageType = { mime: string; extension: string }; async function canAccessPlayer(userId: string, playerId: string) { const { data: user, error: userError } = await supabaseAdmin.from("users").select("account_role").eq("user_id", userId).maybeSingle(); if (userError) throw new Error(`Unable to verify account permissions: ${userError.message}`); if (String(user?.account_role ?? "user").toLowerCase() === "admin") return true; const { data: player, error } = await supabaseAdmin.from("players").select("player_id").eq("player_id", playerId).eq("user_id", userId).maybeSingle(); if (error) throw new Error(`Unable to verify player ownership: ${error.message}`); return Boolean(player); } function inspectImage(bytes: Buffer): ImageType | null { if (bytes.length < 12 || bytes.length > MAX_BYTES) return null; if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return { mime: "image/jpeg", extension: "jpg" }; if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return { mime: "image/png", extension: "png" }; if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return { mime: "image/webp", extension: "webp" }; return null; } function safePreviousPath(owner: Owner, id: string, path: string | null | undefined) { const prefix = `${owner}/${id}/`; if (!path?.startsWith(prefix)) return null; const filename = path.slice(prefix.length); return /^[0-9a-f-]{36}\.(jpg|png|webp)$/.test(filename) ? path : null; } async function removeStoredImage(path: string) { const { error } = await supabaseAdmin.storage.from(BUCKET).remove([path]); if (error) throw new Error(`Unable to remove profile image: ${error.message}`); } async function sendImage(res: Response, path: string | null | undefined) { if (!path) return res.status(404).json({ error: "Profile image not found." }); const { data, error } = await supabaseAdmin.storage.from(BUCKET).download(path); if (error || !data) return res.status(404).json({ error: "Profile image not found." }); const type = path.endsWith(".png") ? "image/png" : path.endsWith(".webp") ? "image/webp" : "image/jpeg"; res.set({ "Content-Type": type, // Each upload gets a new UUID path, so this response never needs revalidation. "Cache-Control": "private, max-age=31536000, immutable", "Cross-Origin-Resource-Policy": "cross-origin", "X-Content-Type-Options": "nosniff", }); return res.send(Buffer.from(await data.arrayBuffer())); } async function uploadImage(req: Request, res: Response, owner: Owner, id: string, previousPath: string | null | undefined) { const bytes = req.body; if (!Buffer.isBuffer(bytes)) return res.status(415).json({ error: "Choose a JPG, PNG or WEBP image." }); const type = inspectImage(bytes); if (!type || req.headers["content-type"]?.split(";")[0] !== type.mime) { return res.status(415).json({ error: "Choose a valid JPG, PNG or WEBP image up to 4 MB." }); } const path = `${owner}/${id}/${randomUUID()}.${type.extension}`; const { error: uploadError } = await supabaseAdmin.storage.from(BUCKET).upload(path, bytes, { contentType: type.mime, cacheControl: "31536000", upsert: false, }); if (uploadError) throw new Error(`Unable to upload profile image: ${uploadError.message}`); const table = owner === "players" ? "players" : "users"; const idColumn = owner === "players" ? "player_id" : "user_id"; const { data: saved, error: saveError } = await supabaseAdmin.from(table).update({ image_path: path }).eq(idColumn, id).select(idColumn).maybeSingle(); if (saveError || !saved) { await removeStoredImage(path).catch((cleanupError) => console.error(cleanupError)); throw new Error(`Unable to save profile image: ${saveError?.message ?? "Profile not found."}`); } const oldPath = safePreviousPath(owner, id, previousPath); if (oldPath) await removeStoredImage(oldPath).catch((cleanupError) => console.error(cleanupError)); return res.json({ imagePath: path }); } async function deleteImage(res: Response, owner: Owner, id: string, previousPath: string | null | undefined) { const table = owner === "players" ? "players" : "users"; const idColumn = owner === "players" ? "player_id" : "user_id"; const { data: saved, error } = await supabaseAdmin.from(table).update({ image_path: null }).eq(idColumn, id).select(idColumn).maybeSingle(); if (error || !saved) throw new Error(`Unable to remove profile image: ${error?.message ?? "Profile not found."}`); const oldPath = safePreviousPath(owner, id, previousPath); if (oldPath) await removeStoredImage(oldPath).catch((cleanupError) => console.error(cleanupError)); return res.json({ imagePath: null }); } export function registerProfileImageRoutes(app: Express) { app.get("/api/auth/image", async (req, res, next) => { try { const current = await authenticatedUser(req, res); if (!current) return res.status(401).json({ error: "Authentication required." }); const { data, error } = await supabaseAdmin.from("users").select("image_path").eq("user_id", current.user.id).maybeSingle(); if (error) throw new Error(`Unable to load user image: ${error.message}`); return await sendImage(res, safePreviousPath("users", current.user.id, data?.image_path)); } catch (error) { next(error); } }); app.post("/api/auth/image", uploadBody, async (req, res, next) => { try { const current = await authenticatedUser(req, res); if (!current) return res.status(401).json({ error: "Authentication required." }); const { data, error } = await supabaseAdmin.from("users").select("image_path").eq("user_id", current.user.id).maybeSingle(); if (error || !data) return res.status(404).json({ error: "User profile not found." }); return await uploadImage(req, res, "users", current.user.id, data.image_path); } catch (error) { next(error); } }); app.delete("/api/auth/image", async (req, res, next) => { try { const current = await authenticatedUser(req, res); if (!current) return res.status(401).json({ error: "Authentication required." }); const { data, error } = await supabaseAdmin.from("users").select("image_path").eq("user_id", current.user.id).maybeSingle(); if (error || !data) return res.status(404).json({ error: "User profile not found." }); return await deleteImage(res, "users", current.user.id, data.image_path); } catch (error) { next(error); } }); app.get("/api/players/:playerId/image", async (req, res, next) => { try { const current = await authenticatedUser(req, res); if (!current) return res.status(401).json({ error: "Authentication required." }); const id = playerIdSchema.safeParse(req.params.playerId); if (!id.success) return res.status(404).json({ error: "Player not found." }); if (!await canAccessPlayer(current.user.id, id.data)) return res.status(404).json({ error: "Player not found." }); const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).maybeSingle(); if (error) throw new Error(`Unable to load player image: ${error.message}`); if (!data) return res.status(404).json({ error: "Player not found." }); return await sendImage(res, safePreviousPath("players", id.data, data?.image_path)); } catch (error) { next(error); } }); app.post("/api/players/:playerId/image", uploadBody, async (req, res, next) => { try { const current = await authenticatedUser(req, res); if (!current) return res.status(401).json({ error: "Authentication required." }); const id = playerIdSchema.safeParse(req.params.playerId); if (!id.success) return res.status(404).json({ error: "Player not found." }); if (!await canAccessPlayer(current.user.id, id.data)) return res.status(404).json({ error: "Player not found." }); const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).maybeSingle(); if (error || !data) return res.status(404).json({ error: "Player not found." }); return await uploadImage(req, res, "players", id.data, data.image_path); } catch (error) { next(error); } }); app.delete("/api/players/:playerId/image", async (req, res, next) => { try { const current = await authenticatedUser(req, res); if (!current) return res.status(401).json({ error: "Authentication required." }); const id = playerIdSchema.safeParse(req.params.playerId); if (!id.success) return res.status(404).json({ error: "Player not found." }); if (!await canAccessPlayer(current.user.id, id.data)) return res.status(404).json({ error: "Player not found." }); const { data, error } = await supabaseAdmin.from("players").select("image_path").eq("player_id", id.data).maybeSingle(); if (error || !data) return res.status(404).json({ error: "Player not found." }); return await deleteImage(res, "players", id.data, data.image_path); } catch (error) { next(error); } }); }