This repository has been archived on 2026-10-08. You can view files and clone it, but cannot push or open issues or pull requests.

240 lines
6.0 KiB
JavaScript

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