mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -6,6 +6,7 @@ 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 { extractVideoMeta } = require("../../services/ffprobe.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
@@ -20,6 +21,29 @@ const { Op } = require('sequelize');
|
||||
|
||||
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";
|
||||
@@ -166,20 +190,63 @@ function redactS3Url(asset) {
|
||||
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 result = await paginate(Asset, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: { where: { deletedAt: null } },
|
||||
});
|
||||
result.data = result.data.map(redactS3Url);
|
||||
return R.success(res, "Assets retrieved.", result);
|
||||
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);
|
||||
@@ -195,7 +262,9 @@ exports.getAsset = async (req, res) => {
|
||||
|
||||
const asset = await Asset.findOne({
|
||||
where: { asset_id: assetId, ...notDeleted },
|
||||
attributes: { exclude: ["storage_key", "storage_bucket"] },
|
||||
// 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", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||
@@ -219,6 +288,14 @@ exports.getAsset = async (req, res) => {
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -404,6 +481,7 @@ exports.uploadAsset = async (req, res) => {
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
||||
|
||||
@@ -471,6 +549,7 @@ exports.updateAsset = async (req, res) => {
|
||||
|
||||
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 });
|
||||
|
||||
@@ -494,6 +573,7 @@ exports.archiveAsset = async (req, res) => {
|
||||
|
||||
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) {
|
||||
@@ -517,6 +597,7 @@ exports.archiveAssets = async (req, res) => {
|
||||
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,
|
||||
@@ -540,6 +621,7 @@ exports.restoreAsset = async (req, res) => {
|
||||
|
||||
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) {
|
||||
@@ -566,6 +648,7 @@ exports.restoreAssets = async (req, res) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user