import mongoose from "mongoose"; import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, } from "@aws-sdk/client-s3"; import { encode } from "blurhash"; import sharp from "sharp"; import config from "../config.js"; import { Image, Patch } from "../db/models/index.js"; import { getLogger } from "../logger.js"; const logger = getLogger("Images"); const EXT_BY_MIME = { "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif", }; const ALLOWED_MIMES = new Set(Object.keys(EXT_BY_MIME)); let s3Client = null; /** * R2 S3 API uses the account endpoint only; bucket must not be in the URL path. * @param {string} urlStr */ function normalizeS3Endpoint(urlStr) { if (!urlStr) return ""; try { const u = new URL(urlStr); if (!/r2\.cloudflarestorage\.com$/i.test(u.hostname)) { return urlStr; } const path = (u.pathname || "").replace(/\/+$/, ""); if (path) { logger.warn( "R2 expects s3.endpoint without a bucket path (use s3.bucket separately); using origin only", ); u.pathname = ""; return u.href.replace(/\/$/, ""); } return urlStr; } catch { return urlStr; } } function isR2Endpoint(urlStr) { try { return /r2\.cloudflarestorage\.com$/i.test(new URL(urlStr).hostname); } catch { return false; } } function buildS3ClientOptions() { const endpointRaw = config.s3?.endpoint?.trim() || ""; const endpoint = endpointRaw ? normalizeS3Endpoint(endpointRaw) : ""; let region; if (endpoint && isR2Endpoint(endpoint)) { region = "auto"; } else { region = (config.s3?.region || "").trim() || "us-east-1"; } /** @type {import("@aws-sdk/client-s3").S3ClientConfig} */ const opts = { region }; if (endpoint) { opts.endpoint = endpoint; } if ( config.s3?.forcePathStyle === true || config.s3?.forcePathStyle === false ) { opts.forcePathStyle = config.s3.forcePathStyle; } const accessKeyId = config.s3?.accessKeyId?.trim(); const secretAccessKey = config.s3?.secretAccessKey?.trim(); const sessionToken = config.s3?.sessionToken?.trim(); if (accessKeyId && secretAccessKey) { opts.credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}), }; } return opts; } function getS3Client() { const bucket = config.s3?.bucket?.trim(); if (!bucket) return null; if (!s3Client) { s3Client = new S3Client(buildS3ClientOptions()); } return s3Client; } export function isS3UploadConfigured() { return !!(config.s3?.bucket?.trim()); } function s3Prefix() { return (config.s3?.prefix || "allotment-images").replace(/^\/+|\/+$/g, ""); } /** * Creates the Image document, uploads to S3 under the new id, then computes BlurHash. * @param {Buffer} buffer * @param {string} mimeType * @returns {Promise<{ id: string, blurHash: string }>} */ export async function uploadPatchImage(buffer, mimeType) { const mime = String(mimeType || "").toLowerCase(); if (!ALLOWED_MIMES.has(mime)) { throw new Error("Unsupported image type"); } const s3 = getS3Client(); if (!s3) { throw new Error("Image upload is not configured (set s3.bucket or S3_BUCKET)"); } const bucket = config.s3.bucket.trim(); const ext = EXT_BY_MIME[mime]; const _id = new mongoose.Types.ObjectId(); const s3Key = `${s3Prefix()}/${_id}${ext}`; await Image.create({ _id, blurHash: "", contentType: mime, s3Key, }); try { await s3.send( new PutObjectCommand({ Bucket: bucket, Key: s3Key, Body: buffer, ContentType: mime, }), ); } catch (e) { await Image.deleteOne({ _id }); logger.error("S3 PutObject failed", e); throw new Error("Failed to store image"); } let blurHash = ""; try { const { data, info } = await sharp(buffer) .rotate() .resize(32, 32, { fit: "inside" }) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); blurHash = encode( new Uint8ClampedArray(data), info.width, info.height, 4, 4, ); } catch (e) { logger.warn("BlurHash generation failed:", e?.message || e); } await Image.updateOne({ _id }, { $set: { blurHash } }); return { id: _id.toString(), blurHash }; } /** * @param {string} imageId * @returns {Promise<{ stream: import("stream").Readable, contentType: string } | null>} */ export async function getImageFileStream(imageId) { const id = String(imageId || "").trim(); if (!mongoose.Types.ObjectId.isValid(id)) return null; const img = await Image.findById(id).lean(); if (!img?.s3Key) return null; const s3 = getS3Client(); const bucket = config.s3?.bucket?.trim(); if (!s3 || !bucket) return null; try { const out = await s3.send( new GetObjectCommand({ Bucket: bucket, Key: img.s3Key }), ); if (!out.Body) return null; return { stream: out.Body, contentType: img.contentType || "application/octet-stream", }; } catch (e) { logger.error("S3 GetObject failed", e); return null; } } /** * Deletes Image rows (and S3 objects) that are not referenced by any patch `images` list. */ export async function deleteOrphanImages() { const s3 = getS3Client(); const bucket = config.s3?.bucket?.trim(); const patches = await Patch.find({}).select("images").lean(); const referenced = new Set(); for (const p of patches) { for (const oid of p.images || []) { if (oid) referenced.add(String(oid)); } } const refIds = [...referenced].filter((x) => mongoose.Types.ObjectId.isValid(x)); const filter = refIds.length > 0 ? { _id: { $nin: refIds.map((x) => new mongoose.Types.ObjectId(x)) } } : {}; const orphans = await Image.find(filter).lean(); for (const doc of orphans) { if (bucket && s3 && doc.s3Key) { try { await s3.send( new DeleteObjectCommand({ Bucket: bucket, Key: doc.s3Key }), ); } catch (e) { logger.warn(`S3 delete ${doc.s3Key}:`, e?.message || e); } } await Image.deleteOne({ _id: doc._id }); } }