Sporalize-api / src /profile-images.ts
Shoraky's picture
Optimize profile image caching
200d616
Raw History Blame Contribute Delete
9.58 kB
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); }
});
}