mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
929 lines
40 KiB
JavaScript
929 lines
40 KiB
JavaScript
// controllers/admin/assets.controller.js
|
|
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const sequelize = require("../../config/db.config");
|
|
const Asset = require("../../models/assets/assets.mdl");
|
|
const chibi = require("../../services/chibisafe.service");
|
|
const s3 = require("../../services/s3.service");
|
|
const mediaToken = require("../../services/mediaToken.service");
|
|
const uploadProgress = require("../../services/uploadProgress.service");
|
|
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
|
const documentConversion = require("../../services/documentConversion.service");
|
|
const R = require('../../utils/response.util');
|
|
const { paginate } = require("../../utils/paginate.util");
|
|
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
|
|
const mdl_Users = require('../../models/users/users.mdl');
|
|
const { getFieldValues } = require("../../utils/fieldValues.util");
|
|
const logActivity = require('../../utils/logActivity.util');
|
|
|
|
const { Op } = require('sequelize');
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
const notDeleted = { deletedAt: null };
|
|
|
|
// List queries keep storage_key selected (unlike adminExclude) so
|
|
// attachStreamTokens can sign a stream token server-side without a second
|
|
// query — it's deleted from every row before the response is sent.
|
|
const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key");
|
|
|
|
// ─── In-memory list cache (no Redis yet) ───────────────────────────────────────
|
|
// Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g.
|
|
// AssetPickerSheet being opened/closed repeatedly with the same filters — so
|
|
// Postgres isn't re-queried on every toggle. Cleared on any mutation below.
|
|
// Single-process only; fine for one instance, won't stay consistent across
|
|
// multiple app instances without a shared store like Redis.
|
|
const LIST_CACHE_TTL_MS = 20_000;
|
|
const listCache = new Map(); // queryKey -> { result, expiresAt }
|
|
|
|
function listCacheKey(req) {
|
|
return JSON.stringify({
|
|
page: req.query.page, limit: req.query.limit,
|
|
filters: req.query.filters, sort: req.query.sort,
|
|
});
|
|
}
|
|
|
|
function invalidateListCache() { listCache.clear(); }
|
|
|
|
function resolveFileType(mimeType = "") {
|
|
if (mimeType.startsWith("image/")) return "image";
|
|
if (mimeType.startsWith("video/")) return "video";
|
|
if (mimeType.startsWith("audio/")) return "audio";
|
|
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
|
|
}
|
|
|
|
function resolveExtension(originalName = "") {
|
|
return path.extname(originalName).replace(".", "").toLowerCase() || null;
|
|
}
|
|
|
|
function resolveChecksum(buffer) {
|
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
}
|
|
|
|
function streamToBuffer(stream) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
stream.on("data", (chunk) => chunks.push(chunk));
|
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
stream.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function resolveResolution(width, height) {
|
|
if (!width || !height) return null;
|
|
const h = Math.min(width, height);
|
|
if (h >= 2160) return "4K";
|
|
if (h >= 1440) return "1440p";
|
|
if (h >= 1080) return "1080p";
|
|
if (h >= 720) return "720p";
|
|
if (h >= 480) return "480p";
|
|
if (h >= 360) return "360p";
|
|
if (h >= 240) return "240p";
|
|
return `${width}x${height}`;
|
|
}
|
|
|
|
// ─── Provider resolver ────────────────────────────────────────────────────────
|
|
//
|
|
// Returns the correct service module based on storage_provider.
|
|
// Both chibi and s3 expose the same interface: uploadFile / deleteFile.
|
|
//
|
|
function getProvider(storageProvider) {
|
|
if (storageProvider === "s3") return s3;
|
|
if (storageProvider === "chibisafe") return chibi;
|
|
return null; // local / other — no remote provider needed
|
|
}
|
|
|
|
// ─── rollbackUploads ──────────────────────────────────────────────────────────
|
|
//
|
|
// Best-effort cleanup after a failed DB transaction.
|
|
// uploads: [{ key, provider }]
|
|
//
|
|
async function rollbackUploads(uploads = []) {
|
|
for (const { key, provider } of uploads) {
|
|
if (!key || !provider) continue;
|
|
const svc = getProvider(provider);
|
|
if (!svc) continue;
|
|
try {
|
|
await svc.deleteFile(key);
|
|
} catch (err) {
|
|
console.error(`[ASSET][ROLLBACK] Failed to delete "${key}" from "${provider}":`, err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── uploadToProvider ─────────────────────────────────────────────────────────
|
|
//
|
|
// Uploads a single file to the resolved provider.
|
|
// Returns { file_url, storage_key, mime_type, extension, checksum, file_type }
|
|
//
|
|
async function uploadToProvider(file, ownerType, storageProvider) {
|
|
const mime_type = file.mimetype;
|
|
const extension = resolveExtension(file.originalname);
|
|
const checksum = resolveChecksum(file.buffer);
|
|
const file_type = resolveFileType(mime_type);
|
|
|
|
const svc = getProvider(storageProvider);
|
|
const result = await svc.uploadFile({
|
|
buffer: file.buffer,
|
|
originalname: file.originalname,
|
|
mimetype: mime_type,
|
|
ownerType,
|
|
});
|
|
|
|
return {
|
|
file_url: result.url,
|
|
storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB
|
|
mime_type,
|
|
extension,
|
|
checksum,
|
|
file_type,
|
|
};
|
|
}
|
|
|
|
// ─── applyAssetUpdate ─────────────────────────────────────────────────────────
|
|
|
|
async function applyAssetUpdate(asset, file, body) {
|
|
const isThumbnailOnly = asset.file_type === "video" && !!file;
|
|
|
|
if (body.display_name !== undefined) asset.display_name = body.display_name;
|
|
if (body.description !== undefined) asset.description = body.description;
|
|
if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true;
|
|
asset.updatedBy = body.updatedBy ?? null;
|
|
|
|
if (file) {
|
|
if (isThumbnailOnly) {
|
|
asset.thumbnail_url = file.file_url;
|
|
asset.thumbnail_storage_key = file.storage_key;
|
|
} else {
|
|
asset.original_name = file.originalname;
|
|
asset.file_url = file.file_url;
|
|
asset.file_size = file.size;
|
|
asset.mime_type = file.mime_type;
|
|
asset.extension = file.extension;
|
|
asset.checksum = file.checksum;
|
|
asset.file_type = file.file_type;
|
|
asset.storage_key = file.storage_key;
|
|
|
|
const parsedWidth = body.width ? parseInt(body.width) : null;
|
|
const parsedHeight = body.height ? parseInt(body.height) : null;
|
|
if (parsedWidth || parsedHeight) {
|
|
asset.width = parsedWidth;
|
|
asset.height = parsedHeight;
|
|
asset.resolution = resolveResolution(parsedWidth, parsedHeight);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── deleteOldFile ────────────────────────────────────────────────────────────
|
|
|
|
async function deleteOldFile(storageProvider, oldStorageKey, newKey) {
|
|
if (!oldStorageKey || oldStorageKey === newKey) return;
|
|
const svc = getProvider(storageProvider);
|
|
if (!svc) return;
|
|
try {
|
|
await svc.deleteFile(oldStorageKey);
|
|
} catch (err) {
|
|
console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message);
|
|
}
|
|
}
|
|
|
|
// ─── Helper: hide S3 file_url from responses ──────────────────────────────────
|
|
//
|
|
// The raw S3 presigned/public URL is never sent to any browser.
|
|
// Admin viewers request a short-lived stream token instead
|
|
// (POST /api/admin/media/token → GET /api/client/media/stream/:token).
|
|
// Chibisafe assets keep their file_url (CDN public URL, no proxy needed).
|
|
//
|
|
function redactS3Url(asset) {
|
|
if (asset?.storage_provider === "s3") asset.file_url = null;
|
|
return asset;
|
|
}
|
|
|
|
// ─── attachStreamTokens ─────────────────────────────────────────────────────
|
|
//
|
|
// Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row
|
|
// so pickers/tables reading the list can render thumbnails immediately instead
|
|
// of firing a second POST /admin/media/tokens round-trip and waiting on it.
|
|
// storage_key is kept out of the DB attribute exclude list (unlike the rest of
|
|
// adminExclude) purely so it's available here to sign the token — it's still
|
|
// stripped from every row before the response goes out.
|
|
//
|
|
// Operates on shallow copies: `result.data` is shared with listCache, and
|
|
// mutating those rows in place would delete storage_key from the cached
|
|
// objects, breaking token issuance for the next request that hits the cache.
|
|
//
|
|
async function attachStreamTokens(rows, req) {
|
|
const ip = mediaToken.resolveIp(req);
|
|
const userId = req.user?.user_id;
|
|
|
|
return Promise.all(rows.map(async (original) => {
|
|
const row = { ...original };
|
|
const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type);
|
|
|
|
if (eligible) {
|
|
const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip);
|
|
row.stream_token = token;
|
|
if (thumbnail_url) row.thumbnail_url = thumbnail_url;
|
|
}
|
|
|
|
delete row.storage_key;
|
|
return row;
|
|
}));
|
|
}
|
|
|
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
|
|
|
exports.getAssets = async (req, res) => {
|
|
try {
|
|
const key = listCacheKey(req);
|
|
const cached = listCache.get(key);
|
|
let result;
|
|
|
|
if (cached && Date.now() < cached.expiresAt) {
|
|
result = cached.result;
|
|
} else {
|
|
result = await paginate(Asset, req, {
|
|
excludeAttributes: LIST_QUERY_EXCLUDE,
|
|
jsonbSchemas,
|
|
computedAttributes,
|
|
context: "list",
|
|
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
|
findOptions: { where: { deletedAt: null } },
|
|
});
|
|
result.data = result.data.map(redactS3Url);
|
|
listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
|
|
}
|
|
|
|
const data = await attachStreamTokens(result.data, req);
|
|
return R.success(res, "Assets retrieved.", { ...result, data });
|
|
} catch (err) {
|
|
console.error("[ASSET][GET ALL]", err);
|
|
return R.error(res, "Could not retrieve assets.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
|
|
|
exports.getAsset = async (req, res) => {
|
|
try {
|
|
const { assetId } = req.params;
|
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
|
|
|
const asset = await Asset.findOne({
|
|
where: { asset_id: assetId, ...notDeleted },
|
|
// storage_key stays selected here (unlike the list query) so it's
|
|
// available below to sign a stream token — stripped before the response.
|
|
attributes: { exclude: ["storage_bucket"] },
|
|
include: [
|
|
{ model: mdl_Users, as: "creator", attributes: ["user_id", "email", "personal_info"], foreignKey: "createdBy" },
|
|
{ model: mdl_Users, as: "updater", attributes: ["user_id", "email", "personal_info"], foreignKey: "updatedBy" },
|
|
],
|
|
});
|
|
|
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
|
|
|
const json = asset.toJSON();
|
|
|
|
// Falls back to email when full_name hasn't been filled in — better than
|
|
// surfacing the raw numeric user_id in the admin UI.
|
|
if (json.creator) {
|
|
json.creator = {
|
|
user_id: json.creator.user_id,
|
|
full_name: json.creator.personal_info?.name?.full_name || json.creator.email || null,
|
|
};
|
|
}
|
|
if (json.updater) {
|
|
json.updater = {
|
|
user_id: json.updater.user_id,
|
|
full_name: json.updater.personal_info?.name?.full_name || json.updater.email || null,
|
|
};
|
|
}
|
|
|
|
if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) {
|
|
const ip = mediaToken.resolveIp(req);
|
|
const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip);
|
|
json.stream_token = token;
|
|
if (thumbnail_url) json.thumbnail_url = thumbnail_url;
|
|
}
|
|
delete json.storage_key;
|
|
|
|
redactS3Url(json);
|
|
return R.success(res, "Asset retrieved.", { data: json });
|
|
} catch (err) {
|
|
console.error("[ASSET][GET ONE]", err);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── UPLOAD (shared core) ──────────────────────────────────────────────────────
|
|
//
|
|
// ┌─────────────────────────────────────────────────────────────────────────┐
|
|
// │ TRANSACTION STRATEGY │
|
|
// │ │
|
|
// │ Phase 1 — SLOW WORK (outside transaction): │
|
|
// │ • Input validation │
|
|
// │ • ffprobe metadata extraction │
|
|
// │ • Provider upload (chibi or s3) → track for rollback │
|
|
// │ • Thumbnail upload → track for rollback │
|
|
// │ │
|
|
// │ Phase 2 — FAST WORK (transaction open milliseconds only): │
|
|
// │ • BEGIN → Asset.create() → COMMIT │
|
|
// │ │
|
|
// │ On any error: │
|
|
// │ • ROLLBACK transaction (if opened) │
|
|
// │ • rollbackUploads([{ key, provider }]) to clean orphans │
|
|
// └─────────────────────────────────────────────────────────────────────────┘
|
|
//
|
|
// Shared by the single-file POST / and the multi-file POST /batch routes.
|
|
// `requireVideoThumbnail` is false for batch uploads — a bulk drop has no
|
|
// per-file thumbnail step, so videos land with thumbnail_url null and pick
|
|
// one up later via the existing "thumbnail-only" path in updateAsset().
|
|
//
|
|
async function createAssetFromUpload({ file, thumbFile, body, user, requireVideoThumbnail = true, uploadId }) {
|
|
const uploadedFiles = []; // [{ key, provider }]
|
|
|
|
try {
|
|
if (!file) throw Object.assign(new Error("No file uploaded."), { status: 400 });
|
|
|
|
const {
|
|
display_name,
|
|
description,
|
|
is_public = false,
|
|
// TEMPORARY: defaulting to "s3" instead of "chibisafe" — zrok tunnel is
|
|
// dropping Chibisafe uploads (502s). Revert this default once off zrok/VPS migration.
|
|
storage_provider = "s3",
|
|
storage_bucket,
|
|
storage_key,
|
|
createdBy,
|
|
} = body;
|
|
|
|
if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 });
|
|
|
|
const mime_type = file.mimetype;
|
|
const file_type = resolveFileType(mime_type);
|
|
const extension = resolveExtension(file.originalname);
|
|
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
|
|
const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
|
|
|
|
if (file_type === "video" && requireVideoThumbnail && !thumbFile) {
|
|
throw Object.assign(new Error("A thumbnail image is required for video uploads."), { status: 400 });
|
|
}
|
|
|
|
if (usesProvider && !file.buffer) {
|
|
throw Object.assign(new Error("File buffer is required. Ensure multer uses memoryStorage."), { status: 400 });
|
|
}
|
|
|
|
// ── Phase 1b: Upload main file ────────────────────────────────────────────
|
|
|
|
let file_url = null;
|
|
let storage_key_resolved = null;
|
|
|
|
if (usesProvider) {
|
|
const svc = getProvider(storage_provider);
|
|
// Real Express -> Garage progress, main file only (not the thumbnail —
|
|
// it's small enough that tracking it wouldn't add anything useful).
|
|
// Relayed live to the browser over SSE; see uploadProgress.service.js.
|
|
const onProgress = (storage_provider === "s3" && uploadId)
|
|
? ({ loaded, total }) => uploadProgress.publish(uploadId, {
|
|
phase: "storing",
|
|
loaded,
|
|
total,
|
|
pct: total ? Math.round((loaded / total) * 100) : 0,
|
|
})
|
|
: undefined;
|
|
|
|
const result = await svc.uploadFile({
|
|
buffer: file.buffer,
|
|
originalname: file.originalname,
|
|
mimetype: mime_type,
|
|
ownerType: file_type,
|
|
onProgress,
|
|
});
|
|
file_url = result.url;
|
|
storage_key_resolved = result.uuid;
|
|
uploadedFiles.push({ key: storage_key_resolved, provider: storage_provider });
|
|
|
|
} else {
|
|
file_url = storage_provider === "local"
|
|
? `/uploads/${file.filename}`
|
|
: body.file_url;
|
|
|
|
if (!file_url) throw Object.assign(new Error("file_url is required for non-local storage."), { status: 400 });
|
|
}
|
|
|
|
// ── Phase 1c: ffprobe + thumbnail ─────────────────────────────────────────
|
|
|
|
let width = null, height = null, resolution = null;
|
|
let duration = null, frame_rate = null, bitrate = null;
|
|
let video_codec = null, audio_codec = null;
|
|
let thumbnail_url = null;
|
|
|
|
if (file_type === "video" || file_type === "audio") {
|
|
const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || (file_type === "video" ? "mp4" : "mp3") });
|
|
width = meta.width;
|
|
height = meta.height;
|
|
resolution = meta.resolution;
|
|
duration = meta.duration;
|
|
frame_rate = meta.frame_rate;
|
|
bitrate = meta.bitrate;
|
|
video_codec = meta.video_codec;
|
|
audio_codec = meta.audio_codec;
|
|
|
|
if (usesProvider && thumbFile) {
|
|
if (!thumbFile.buffer) {
|
|
await rollbackUploads(uploadedFiles);
|
|
throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 });
|
|
}
|
|
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
|
const svc = getProvider(storage_provider);
|
|
const thumbResult = await svc.uploadFile({
|
|
buffer: thumbFile.buffer,
|
|
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
|
|
mimetype: thumbFile.mimetype,
|
|
ownerType: "thumbnail",
|
|
});
|
|
thumbnail_url = thumbResult.url;
|
|
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
|
|
} else if (!usesProvider) {
|
|
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
|
|
}
|
|
|
|
} else {
|
|
const parsedWidth = body.width ? parseInt(body.width) : null;
|
|
const parsedHeight = body.height ? parseInt(body.height) : null;
|
|
width = parsedWidth;
|
|
height = parsedHeight;
|
|
resolution = resolveResolution(parsedWidth, parsedHeight);
|
|
}
|
|
|
|
// ── Phase 2: DB insert ────────────────────────────────────────────────────
|
|
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const asset = await Asset.create({
|
|
original_name: file.originalname,
|
|
display_name: display_name || file.originalname,
|
|
file_url,
|
|
file_size: file.size,
|
|
mime_type,
|
|
extension,
|
|
checksum,
|
|
file_type,
|
|
width,
|
|
height,
|
|
resolution,
|
|
duration,
|
|
frame_rate,
|
|
bitrate,
|
|
video_codec,
|
|
audio_codec,
|
|
thumbnail_url,
|
|
description,
|
|
storage_provider,
|
|
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
|
|
storage_key: storage_key_resolved || storage_key || file.filename || null,
|
|
is_public,
|
|
createdBy,
|
|
}, { transaction: t });
|
|
|
|
await t.commit();
|
|
logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
|
return asset;
|
|
|
|
} catch (dbErr) {
|
|
try { await t.rollback(); } catch { /* connection gone */ }
|
|
await rollbackUploads(uploadedFiles);
|
|
throw dbErr;
|
|
}
|
|
|
|
} catch (err) {
|
|
await rollbackUploads(uploadedFiles);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
exports.uploadAsset = async (req, res) => {
|
|
const { uploadId } = req.body;
|
|
try {
|
|
const file = req.files?.file?.[0];
|
|
const thumbFile = req.files?.thumbnail?.[0];
|
|
|
|
const asset = await createAssetFromUpload({ file, thumbFile, body: req.body, user: req.user, uploadId });
|
|
invalidateListCache();
|
|
if (uploadId) uploadProgress.complete(uploadId, { phase: "done", pct: 100 });
|
|
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
|
|
|
} catch (err) {
|
|
console.error("[ASSET][UPLOAD]", err);
|
|
if (uploadId) uploadProgress.complete(uploadId, { phase: "error" });
|
|
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── UPLOAD PROGRESS (SSE) ──────────────────────────────────────────────────
|
|
//
|
|
// Client opens this before POSTing the file, correlated by a client-generated
|
|
// uploadId sent as a form field on the upload request itself. Streams the
|
|
// real Express -> Garage httpUploadProgress events from s3.service.js's
|
|
// Upload — not a simulated or estimated number.
|
|
//
|
|
exports.streamUploadProgress = (req, res) => {
|
|
uploadProgress.subscribe(req.params.uploadId, res);
|
|
};
|
|
|
|
// Same broadcaster, reused as-is for document-conversion job progress — it's
|
|
// just a generic string-keyed SSE channel, nothing upload-specific about it.
|
|
exports.streamConvertProgress = (req, res) => {
|
|
uploadProgress.subscribe(req.params.jobId, res);
|
|
};
|
|
|
|
// ─── UPLOAD (batch) ─────────────────────────────────────────────────────────
|
|
//
|
|
// Accepts multiple files under the "files" field in one multipart request,
|
|
// all sharing the same is_public / storage_provider / createdBy. Each file
|
|
// is uploaded independently — one failing (bad codec, DB constraint, etc.)
|
|
// does not roll back the others. display_name defaults to the filename
|
|
// (minus extension) since there's no per-file metadata step in bulk mode.
|
|
// Videos land without a thumbnail (see createAssetFromUpload) — add one
|
|
// later via the existing "replace thumbnail" path on PATCH /:assetId.
|
|
//
|
|
exports.uploadAssetsBatch = async (req, res) => {
|
|
const files = req.files ?? [];
|
|
if (!files.length) return R.error(res, "No files uploaded.", 400);
|
|
|
|
// TEMPORARY: defaulting to "s3" instead of "chibisafe" — zrok tunnel is
|
|
// dropping Chibisafe uploads (502s). Revert this default once off zrok/VPS migration.
|
|
const { display_name, description, is_public = false, storage_provider = "s3", createdBy } = req.body;
|
|
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
|
|
|
const results = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
|
const asset = await createAssetFromUpload({
|
|
file,
|
|
thumbFile: null,
|
|
body: {
|
|
display_name: files.length === 1 ? (display_name || baseName) : baseName,
|
|
description,
|
|
is_public,
|
|
storage_provider,
|
|
createdBy,
|
|
},
|
|
user: req.user,
|
|
requireVideoThumbnail: false,
|
|
});
|
|
results.push({ originalname: file.originalname, success: true, data: asset });
|
|
} catch (err) {
|
|
console.error("[ASSET][UPLOAD BATCH]", file.originalname, err.stack || err);
|
|
results.push({ originalname: file.originalname, success: false, message: err.status ? err.message : "Internal server error." });
|
|
}
|
|
}
|
|
|
|
invalidateListCache();
|
|
const createdCount = results.filter((r) => r.success).length;
|
|
return R.success(res, `${createdCount} of ${files.length} asset(s) uploaded.`, { results }, 201);
|
|
};
|
|
|
|
// ─── CONVERT TO MARKDOWN ────────────────────────────────────────────────────
|
|
//
|
|
// PDF/PPTX -> Markdown, text only (see services/documentConversion.service.js
|
|
// for the compile/validate/automate stages and why OCR/images are out of
|
|
// scope). Nothing here is written to the database — the caller (the Lesson
|
|
// block builder) treats the response as a draft and only persists it if/when
|
|
// the admin explicitly inserts it into a block and saves the lesson page.
|
|
//
|
|
// Mirrors the upload flow's SSE progress pattern exactly: the actual work
|
|
// runs synchronously inside this request (uploadProgress.service.js is
|
|
// reused as-is, keyed by a client-generated jobId) while stage transitions
|
|
// are published for a live "Compiling / Validating / Generating" UI, same as
|
|
// AddAsset.jsx already renders for uploads.
|
|
//
|
|
const CONVERTIBLE_EXTENSIONS = new Set(["pdf", "pptx"]);
|
|
const MAX_CONVERT_SIZE_BYTES = 25 * 1024 * 1024; // 25MB — keeps this comfortably synchronous
|
|
// compile() calls the MarkItDown sidecar over HTTP now instead of running
|
|
// officeparser in-process, so this leaves a bit more room than the original
|
|
// 45s for network/queueing overhead — MarkItDown's own parsing is plain
|
|
// CPU-bound work, not ML inference, so it doesn't need much more than that.
|
|
const CONVERT_TIMEOUT_MS = 60_000;
|
|
|
|
exports.convertAssetToMarkdown = async (req, res) => {
|
|
const { jobId } = req.body;
|
|
|
|
try {
|
|
const { assetId } = req.params;
|
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
|
|
|
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
|
|
|
const extension = (asset.extension || "").toLowerCase();
|
|
if (!CONVERTIBLE_EXTENSIONS.has(extension)) {
|
|
return R.error(res, "Only PDF and PPTX documents can be converted to Markdown.", 400);
|
|
}
|
|
if (asset.storage_provider !== "s3" || !asset.storage_key) {
|
|
return R.error(res, "This asset has no stored file to convert.", 400);
|
|
}
|
|
if (Number(asset.file_size) > MAX_CONVERT_SIZE_BYTES) {
|
|
return R.error(res, "File is too large to convert (25MB max).", 400);
|
|
}
|
|
|
|
const publish = (phase) => { if (jobId) uploadProgress.publish(jobId, { phase }); };
|
|
const abortController = new AbortController();
|
|
const timeout = setTimeout(() => abortController.abort(), CONVERT_TIMEOUT_MS);
|
|
|
|
let markdown, warnings, stats;
|
|
try {
|
|
publish("compiling");
|
|
const { stream } = await s3.getObjectStream(asset.storage_key);
|
|
const buffer = await streamToBuffer(stream);
|
|
const ast = await documentConversion.compile(buffer, extension, { signal: abortController.signal });
|
|
|
|
publish("validating");
|
|
const validated = documentConversion.validate(ast);
|
|
|
|
publish("generating");
|
|
const generated = await documentConversion.automate(ast, { signal: abortController.signal });
|
|
|
|
markdown = generated.markdown;
|
|
warnings = [...validated.warnings, ...generated.messages];
|
|
stats = validated.stats;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
if (jobId) uploadProgress.complete(jobId, { phase: "done" });
|
|
return R.success(res, "Document converted.", { markdown, warnings, stats });
|
|
|
|
} catch (err) {
|
|
if (jobId) uploadProgress.complete(jobId, { phase: "error", message: err.message });
|
|
console.error("[ASSET][CONVERT TO MARKDOWN]", err);
|
|
if (err.status) return R.error(res, err.message, err.status);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
|
|
|
exports.updateAsset = async (req, res) => {
|
|
let newUpload = null; // { key, provider }
|
|
|
|
try {
|
|
const { assetId } = req.params;
|
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
|
|
|
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
|
|
|
const file = req.files?.file?.[0] ?? req.file ?? null;
|
|
const isVideo = asset.file_type === "video";
|
|
const isDocument = asset.file_type === "document";
|
|
|
|
if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400);
|
|
if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400);
|
|
if (file && !file.buffer) return R.error(res, "File buffer is required.", 400);
|
|
|
|
const storageProvider = asset.storage_provider;
|
|
const usesProvider = ["chibisafe", "s3"].includes(storageProvider);
|
|
const oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
|
|
|
|
// ── Phase 1: Upload ───────────────────────────────────────────────────────
|
|
|
|
let uploaded = null;
|
|
|
|
if (file && usesProvider) {
|
|
const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype);
|
|
uploaded = await uploadToProvider(file, ownerType, storageProvider);
|
|
newUpload = { key: uploaded.storage_key, provider: storageProvider };
|
|
}
|
|
|
|
// ── Phase 2: DB update ────────────────────────────────────────────────────
|
|
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body);
|
|
await asset.save({ transaction: t });
|
|
await t.commit();
|
|
} catch (dbErr) {
|
|
try { await t.rollback(); } catch { /* gone */ }
|
|
if (newUpload) await rollbackUploads([newUpload]);
|
|
throw dbErr;
|
|
}
|
|
|
|
// ── Phase 3: Cleanup old file ─────────────────────────────────────────────
|
|
|
|
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
|
|
|
|
invalidateListCache();
|
|
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
|
return R.success(res, "Asset updated.", { data: asset });
|
|
|
|
} catch (err) {
|
|
if (newUpload) await rollbackUploads([newUpload]);
|
|
console.error("[ASSET][UPDATE]", err);
|
|
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
|
|
|
exports.archiveAsset = async (req, res) => {
|
|
try {
|
|
const { assetId } = req.params;
|
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
|
|
|
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
|
|
|
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
|
await asset.destroy();
|
|
invalidateListCache();
|
|
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
|
return R.success(res, "Asset archived.");
|
|
} catch (err) {
|
|
console.error("[ASSET][ARCHIVE]", err);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
|
|
|
exports.archiveAssets = async (req, res) => {
|
|
try {
|
|
const { ids, deletedBy } = req.body;
|
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
|
|
|
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
|
|
if (!assets.length) return R.error(res, "No assets found.", 404);
|
|
|
|
const activeIds = assets.map((a) => a.asset_id);
|
|
|
|
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
|
|
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
|
|
|
invalidateListCache();
|
|
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
|
return R.success(res, `${activeIds.length} asset(s) archived.`, {
|
|
archived_ids: activeIds,
|
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
|
});
|
|
} catch (err) {
|
|
console.error("[ASSET][BULK ARCHIVE]", err);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
|
|
|
exports.restoreAsset = async (req, res) => {
|
|
try {
|
|
const { assetId } = req.params;
|
|
|
|
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
|
if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400);
|
|
|
|
await asset.restore();
|
|
await asset.update({ deletedBy: null });
|
|
invalidateListCache();
|
|
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
|
return R.success(res, "Asset restored.", { data: asset });
|
|
} catch (err) {
|
|
console.error("[ASSET][RESTORE]", err);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
|
|
|
exports.restoreAssets = async (req, res) => {
|
|
try {
|
|
const { ids } = req.body;
|
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
|
|
|
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
|
if (!assets.length) return R.error(res, "No assets found.", 404);
|
|
|
|
const archivedAssets = assets.filter((a) => a.deletedAt);
|
|
if (!archivedAssets.length) return R.error(res, "All selected assets are already active.", 400);
|
|
|
|
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
|
|
|
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
|
|
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
|
|
|
|
invalidateListCache();
|
|
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
|
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
|
|
restored_ids: archivedIds,
|
|
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
|
});
|
|
} catch (err) {
|
|
console.error("[ASSET][BULK RESTORE]", err);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── PERMANENT DELETE (single) ─────────────────────────────────────────────────
|
|
|
|
exports.permanentlyDeleteAsset = async (req, res) => {
|
|
try {
|
|
const { assetId } = req.params;
|
|
|
|
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
|
if (!asset.deletedAt) return R.error(res, "Asset must be archived before it can be permanently deleted.", 400);
|
|
|
|
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
|
|
|
|
await asset.destroy({ force: true });
|
|
|
|
const svc = getProvider(storage_provider);
|
|
if (svc) {
|
|
if (storage_key) {
|
|
try { await svc.deleteFile(storage_key); }
|
|
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${storage_key}":`, err.message); }
|
|
}
|
|
if (thumbnail_storage_key) {
|
|
try { await svc.deleteFile(thumbnail_storage_key); }
|
|
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${thumbnail_storage_key}":`, err.message); }
|
|
}
|
|
}
|
|
|
|
invalidateListCache();
|
|
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
|
|
return R.success(res, "Asset permanently deleted.");
|
|
} catch (err) {
|
|
console.error("[ASSET][PERMANENT DELETE]", err);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
|
|
|
exports.permanentlyDeleteAssets = async (req, res) => {
|
|
try {
|
|
const { ids } = req.body;
|
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
|
|
|
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
|
if (!assets.length) return R.error(res, "No assets found.", 404);
|
|
|
|
const archivedAssets = assets.filter((a) => a.deletedAt);
|
|
if (!archivedAssets.length) return R.error(res, "All selected assets must be archived before they can be permanently deleted.", 400);
|
|
|
|
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
|
|
|
await Asset.destroy({ where: { asset_id: { [Op.in]: archivedIds } }, force: true });
|
|
|
|
for (const asset of archivedAssets) {
|
|
const svc = getProvider(asset.storage_provider);
|
|
if (!svc) continue;
|
|
if (asset.storage_key) {
|
|
try { await svc.deleteFile(asset.storage_key); }
|
|
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${asset.storage_key}":`, err.message); }
|
|
}
|
|
if (asset.thumbnail_storage_key) {
|
|
try { await svc.deleteFile(asset.thumbnail_storage_key); }
|
|
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${asset.thumbnail_storage_key}":`, err.message); }
|
|
}
|
|
}
|
|
|
|
invalidateListCache();
|
|
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
|
return R.success(res, `${archivedIds.length} asset(s) permanently deleted.`, {
|
|
deleted_ids: archivedIds,
|
|
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
|
});
|
|
} catch (err) {
|
|
console.error("[ASSET][BULK PERMANENT DELETE]", err);
|
|
return R.error(res, "Internal server error.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
|
|
|
exports.getArchivedAssets = async (req, res) => {
|
|
try {
|
|
const result = await paginate(Asset, req, {
|
|
excludeAttributes: adminExclude,
|
|
jsonbSchemas,
|
|
computedAttributes,
|
|
context: "list",
|
|
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
|
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
|
});
|
|
result.data = result.data.map(redactS3Url);
|
|
return R.success(res, "Archived assets retrieved.", result);
|
|
} catch (err) {
|
|
console.error("[ASSET][GET ARCHIVED]", err);
|
|
return R.error(res, "Could not retrieve archived assets.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getAssetFieldValues = getFieldValues(Asset, "ASSET"); |