240 lines
6.0 KiB
JavaScript
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 });
|
|
}
|
|
}
|