routes/adminRoutes.ts
Rama main · réplica de lectura del repositorio original.
import express from "express";
import mongoose from "mongoose";
import bcrypt from "bcrypt";
import QRCode from "qrcode";
import User from "../models/User";
import BlockedIP from "../models/blockedIP";
import LoginLog from "../models/LoginLog";
import Settings from "../models/Settings";
import Collection from "../models/Collection";
import { requireAuth, requireAdmin, requireCollectionRole } from "../middleware/authMiddleware";
import { generateUniqueSlug, generateShareToken } from "../utils/collectionHelpers";
import { BASE_URL, SUPPORTED_LANGUAGES } from "../config/constants";
import { getInstanceSettings, saveInstanceSettings, InstanceSettingsData } from "../utils/instanceSettings";
import PRESETS from "../config/themes";
import Item from "../models/Item";
import PriceHistory from "../models/PriceHistory";
import Furniture from "../models/Furniture";
import List from "../models/List";
import { registry } from "../core/registry.js";
import { canRefresh, refreshPatchFor, gatherImages, pluginSources } from "../core/sources.js";
import { CARD_ASPECT_RATIOS } from "../core/customPlugin";
import { PermanentRefreshError, syncStamp, getPublicProtocol } from "../core/helpers";
import { deleteItemsAndContents } from "../utils/itemHelpers";
import { deleteUnusedManagedItemImages, managedItemImagesForQuery } from "../core/itemImageStorage";
import {
collectionInfoFromForm,
collectionInfoOf,
MAX_COLLECTION_INFO_BODY,
MAX_COLLECTION_INFO_IMAGES,
MAX_COLLECTION_INFO_TITLE,
} from "../core/collectionInfo";
import { renderMarkdown } from "../core/markdown";
import { nativeLookalikesByPlugin } from "../core/pluginExtraFields";
import { alignImagesAfterRefresh } from "../core/itemImages";
import { AI_PROVIDERS } from "../core/ai/providers";
import { getAiConfig, getStoredAiSettings, saveAiSettings } from "../core/ai/instance";
import { isAiConfigured, resolveTestConfig } from "../core/ai/config";
import { keyHint } from "../core/ai/secret";
import { aiChat, AiError } from "../core/ai/client";
const router = express.Router();
const createPassword = (length = 12): string => {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+";
let password = "";
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
};
const escapeRegExp = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
interface CollectionAdminData {
allGenres: Record<string, string[]>;
visibilitySettings: any;
collectionDoc: any;
members: any[];
}
interface InstanceAdminData {
users: any[];
blockedIps: any[];
logs: any[];
collections: any[];
itemsTotal: number;
instanceSettings: InstanceSettingsData;
}
/**
* Data for the per-collection admin page (/admin): everything scoped to the
* active collection - genres for the visibility panel, visibility settings,
* the collection document itself and its members (user info populated).
*/
async function loadCollectionAdminData(activeCollectionId: any): Promise<CollectionAdminData> {
// Get distinct genres grouped by kind (scoped to the active collection)
const pipeline = [
// Contained items carry their holder's genres, so counting them here would just
// repeat what the holder already contributed.
{ $match: { collection: activeCollectionId, parent: { $exists: false } } },
{
$project: {
kind: 1,
allGenres: {
$concatArrays: [
{ $cond: [{ $in: ["$genre", ["", null]] }, [], ["$genre"]] },
{ $ifNull: ["$genres", []] },
{ $ifNull: ["$styles", []] },
],
},
},
},
{ $unwind: "$allGenres" },
{
$group: {
_id: "$kind",
genres: { $addToSet: "$allGenres" },
},
},
];
const genreGroupsRaw = await Item.aggregate(pipeline);
const allGenres: Record<string, string[]> = {};
genreGroupsRaw.forEach((group: any) => {
if (group._id && group.genres && group.genres.length > 0) {
allGenres[group._id] = group.genres.filter(Boolean).sort();
}
});
const visibilitySettings =
(await Settings.findOne({ collection: activeCollectionId }).populate("visibility.hiddenItems").lean()) || {};
const collectionDoc = await Collection.findById(activeCollectionId)
.populate("members.user", "username email img isAdmin lastChange")
.lean();
const members = (collectionDoc?.members || []).filter((m: any) => m.user);
return { allGenres, visibilitySettings, collectionDoc, members };
}
/**
* Data for the instance admin page (/admin/instance): global users, IPs,
* login logs and the full collections list (members populated + item counts,
* to drive the per-collection management modal).
*/
async function loadInstanceAdminData(): Promise<InstanceAdminData> {
const users = await User.find().sort({ lastChange: -1 });
const blockedIps = await BlockedIP.find().sort({ createdAt: -1 });
const logs = await LoginLog.find().sort({ timestamp: -1 }).limit(20);
const collections: any[] = await Collection.find()
.sort({ created_at: -1 })
.populate("members.user", "username email img isAdmin")
.lean();
// Counted like the grid shows: a show with five seasons is one line and counts as one,
// so the number here always matches what a member can count on screen.
const counts = await Item.aggregate([
{ $match: { parent: { $exists: false } } },
{ $group: { _id: "$collection", n: { $sum: 1 } } },
]);
const countByCollection: Record<string, number> = {};
let itemsTotal = 0;
for (const c of counts) {
countByCollection[String(c._id)] = c.n;
itemsTotal += c.n;
}
for (const c of collections) {
c.itemCount = countByCollection[String(c._id)] || 0;
// Drop membership rows whose user was deleted outside the app
c.members = (c.members || []).filter((m: any) => m.user);
}
return {
users,
blockedIps,
logs,
collections,
itemsTotal,
instanceSettings: await getInstanceSettings(),
};
}
// The message keys a redirect reports a failure with, so the banner is drawn as
// an error instead of a success.
function isErrorMessageKey(key: string | undefined): boolean {
return !!key && (key.startsWith("error_") || key.endsWith("_error"));
}
// COLLECTION ADMIN PAGE (GET /admin) - gated on the active collection's admin role
router.get("/", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const data = await loadCollectionAdminData(res.locals.activeCollectionId);
// Read optional message key from query and translate in the view.
const msgKey = req.query.msg as string | undefined;
// User-defined fields that now look like a plugin's own field, typically one the
// plugin gained in an update. Surfaced here since nothing else would say so until
// someone opened that plugin's customization.
const lookalikes = nativeLookalikesByPlugin(
res.locals.settings,
registry.getEnabled(res.locals.settings),
(key) => SUPPORTED_LANGUAGES.map((lng) => req.t(key, { lng })),
);
const fieldLookalikes = Object.entries(lookalikes).map(([pluginId, fields]) => ({
pluginLabel: req.t(registry.get(pluginId)!.label),
fields: fields.map((f) => ({ name: f.name, label: f.label, nativeLabel: req.t(f.nativeLabel) })),
}));
res.render("admin", {
...data,
fieldLookalikes,
user: res.locals.user,
successMessage: msgKey ? req.t(`messages.${msgKey}`) : null,
messageIsError: isErrorMessageKey(msgKey),
newPassword: null,
// Share links must show a full, absolute URL (scheme + host) - a bare
// baseUrl-relative path is not something you can scan/paste elsewhere.
siteOrigin: `${getPublicProtocol(req)}://${req.get("host")}`,
// Known once, server-side, from the config this page already has every reason to
// read - no reason to make the browser ask again just to decide whether to show the card.
aiConfigured: isAiConfigured(await getAiConfig()),
});
} catch (err) {
console.error(err);
res.status(500).send(req.t("errors.generic_server_error"));
}
});
// INSTANCE ADMIN PAGE (GET /admin/instance): global users, all collections, IPs,
// login logs, whole-instance backup. Distinct from GET / above, which manages
// only the active collection and is reachable by a collection's own admins.
router.get("/instance", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const data = await loadInstanceAdminData();
const msgKey = req.query.msg as string | undefined;
res.render("admin-instance", {
...data,
user: res.locals.user,
successMessage: msgKey ? req.t(`messages.${msgKey}`) : null,
messageIsError: isErrorMessageKey(msgKey),
newPassword: null,
apiKeyStatus: registry.getApiKeyStatus(),
});
} catch (err) {
console.error(err);
res.status(500).send(req.t("errors.generic_server_error"));
}
});
// Names why an account could not be created: a duplicate key names the field
// already in use, anything else falls back to a generic failure.
function userCreationErrorKey(err: any): string {
if (err?.code === 11000) {
return err.keyPattern?.username ? "error_username_taken" : "error_email_taken";
}
if (err?.name === "ValidationError") return "error_user_invalid";
return "error_user_create";
}
// Add user at the INSTANCE level (POST) - creates a global account with no
// collection membership; collection admins attach members from their own page.
router.post("/add-user", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const { username, email } = req.body;
const password = createPassword();
const hashedPassword = await bcrypt.hash(password, 10);
// Create user then force-update the stored password hash.
const newUser = await User.create({
username,
email,
password: password,
lastChange: new Date(),
});
await User.updateOne(
{ _id: newUser._id },
{ $set: { password: hashedPassword } },
);
console.log(`[ADMIN] User created: ${username} <${email}> by ${res.locals.user?.email}`);
const data = await loadInstanceAdminData();
res.render("admin-instance", {
...data,
user: res.locals.user,
successMessage: req.t("messages.user_created_success", { name: username }),
newPassword: password,
apiKeyStatus: registry.getApiKeyStatus(),
});
} catch (err) {
console.error("[ADMIN] User creation error:", err);
res.redirect(`/admin/instance?msg=${userCreationErrorKey(err)}`);
}
});
// Assign a member's home collection: the one they land in when they open the app
// (see utils/homePage.ts). The user can change it themselves from their settings;
// an admin sets it here so a new account starts somewhere sensible. Empty clears it,
// which puts the user back on whatever they last browsed.
router.post("/users/home-collection", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const { userId, collectionId } = req.body;
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.redirect("/admin/instance");
}
if (!collectionId) {
await User.updateOne({ _id: userId }, { $set: { homeCollectionId: null } });
return res.redirect("/admin/instance?msg=home_collection_updated");
}
// Only a collection the user belongs to: anything else would send them, every
// launch, somewhere the collection middleware has to heal them away from.
const member = await Collection.findOne({
_id: mongoose.Types.ObjectId.isValid(collectionId) ? collectionId : null,
"members.user": userId,
}).select("_id");
if (!member) {
return res.redirect("/admin/instance?msg=error_member_not_found");
}
await User.updateOne({ _id: userId }, { $set: { homeCollectionId: member._id } });
res.redirect("/admin/instance?msg=home_collection_updated");
} catch (err) {
console.error("[ADMIN] Home collection error:", err);
res.redirect("/admin/instance?msg=generic_error");
}
});
// Instance-wide settings (POST) - currently the member self-service collection toggle
// and its per-user quota. Stored in the InstanceSettings singleton, not in the
// per-collection Settings document.
router.post("/instance/settings", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
// Unchecked checkboxes are simply absent from the body
const allow = req.body.allowMemberCollectionCreation === "on"
|| req.body.allowMemberCollectionCreation === "true";
// Clamp to the schema's bounds: the number input is client-side only, a crafted
// POST could otherwise store 0 (nobody can create) or a negative quota.
const parsed = parseInt(req.body.maxCollectionsPerUser, 10);
const max = Math.min(100, Math.max(1, isNaN(parsed) ? 1 : parsed));
await saveInstanceSettings({
allowMemberCollectionCreation: allow,
maxCollectionsPerUser: max,
});
console.log(`[ADMIN] Instance settings updated by ${res.locals.user?.email}: member collection creation ${allow ? "enabled" : "disabled"} (max ${max}/user)`);
res.redirect("/admin/instance?msg=saved");
} catch (err) {
console.error("[ADMIN] Instance settings error:", err);
res.redirect("/admin/instance?msg=generic_error");
}
});
// GET /admin/instance/ai -> current AI settings, never the key itself
router.get("/instance/ai", requireAuth, requireAdmin, async (_req: any, res: any) => {
const config = await getAiConfig();
res.json({
enabled: config.enabled,
provider: config.provider,
baseUrl: config.baseUrl,
model: config.model,
visionModel: config.visionModel,
hasKey: Boolean(config.apiKey),
keyHint: keyHint(config.apiKey),
// The panel greys its inputs out when the environment is in charge, so nobody edits
// a value that an AI_* variable is going to override on the next read.
fromEnv: config.fromEnv,
configured: isAiConfigured(config),
providers: AI_PROVIDERS.map(p => ({
id: p.id, label: p.label, baseUrl: p.baseUrl,
defaultModel: p.defaultModel, defaultVisionModel: p.defaultVisionModel, docsUrl: p.docsUrl
}))
});
});
// POST /admin/instance/ai -> save the AI settings
router.post("/instance/ai", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
await saveAiSettings({
enabled: req.body?.enabled === true || req.body?.enabled === 'true',
provider: String(req.body?.provider || 'openrouter'),
baseUrl: String(req.body?.baseUrl || ''),
model: String(req.body?.model || ''),
visionModel: String(req.body?.visionModel || ''),
apiKey: typeof req.body?.apiKey === 'string' ? req.body.apiKey : ''
});
res.json({ success: true });
} catch (err: any) {
console.error('[ERR] AI settings save:', err.message);
res.status(500).json({ success: false, error: req.t('ai.err_save') });
}
});
// POST /admin/instance/ai/test -> one real round-trip, so a bad key is found here.
// Tests the panel's current fields, not the saved config, so a key can be tried before
// committing to Save; any field left blank falls back to what's actually stored.
router.post("/instance/ai/test", requireAuth, requireAdmin, async (req: any, res: any) => {
const stored = await getStoredAiSettings();
const config = resolveTestConfig({
provider: String(req.body?.provider || ''),
baseUrl: String(req.body?.baseUrl || ''),
model: String(req.body?.model || ''),
visionModel: String(req.body?.visionModel || ''),
apiKey: typeof req.body?.apiKey === 'string' ? req.body.apiKey.trim() : ''
}, stored);
if (!isAiConfigured(config)) {
// A hosted provider other than the saved one is tested with a key typed for it: the
// held key never leaves for another endpoint (see resolveTestConfig).
const error = !config.apiKey && config.provider !== 'custom' ? 'ai.err_test_key_required' : 'ai.err_not_configured';
return res.status(400).json({ success: false, error: req.t(error) });
}
try {
const result = await aiChat(
config,
[{ role: 'user', content: 'Reply with the single word: ready' }],
{ maxTokens: 16, timeoutMs: 15000 }
);
res.json({ success: true, model: result.model, reply: result.text.trim().slice(0, 100) });
} catch (err: any) {
const status = err instanceof AiError && err.status ? err.status : 502;
res.status(status).json({ success: false, error: err.message });
}
});
// Create collection (POST) - instance level: only the instance admin creates collections.
router.post("/collections/create", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const name = (req.body.name || "").trim();
if (!name) {
return res.redirect("/admin/instance?msg=error_collection_name");
}
const slug = await generateUniqueSlug(name);
await Collection.create({
name,
slug,
createdBy: req.user._id,
isDefault: false,
shelvesSeeded: true,
members: [{ user: req.user._id, role: "admin" }],
});
res.redirect("/admin/instance?msg=collection_created");
} catch (err) {
console.error("Collection creation error:", err);
res.redirect("/admin/instance?msg=error_collection_name");
}
});
// Rename collection (POST) - a collection admin renames HIS OWN (active) collection;
// the instance admin can rename any. Slug stays stable (not used for routing).
router.post("/collections/:id/rename", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const name = (req.body.name || "").trim();
if (!name) {
return res.redirect("/admin?msg=error_collection_name");
}
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
return res.redirect("/admin");
}
const isOwnActive = String(req.params.id) === String(res.locals.activeCollectionId);
if (!isOwnActive && !res.locals.user.isAdmin) {
return res.redirect("/admin");
}
await Collection.updateOne({ _id: req.params.id }, { $set: { name } });
res.redirect(isOwnActive ? "/admin?msg=collection_renamed" : "/admin/instance?msg=collection_renamed");
} catch (err) {
console.error("Collection rename error:", err);
res.redirect("/admin?msg=error_collection_name");
}
});
// Delete collection (POST) - instance level, destructive cascade: removes the
// collection's items and settings. The default collection cannot be deleted.
router.post("/collections/:id/delete", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
return res.redirect("/admin/instance");
}
const target = await Collection.findById(req.params.id);
if (!target) {
return res.redirect("/admin/instance");
}
if (target.isDefault) {
return res.redirect("/admin/instance?msg=error_delete_default_collection");
}
const itemImages = await managedItemImagesForQuery({ collection: target._id });
await Item.deleteMany({ collection: target._id });
await Settings.deleteMany({ collection: target._id });
// The value snapshots describe a collection that is about to stop existing, and
// nothing else points at them: left behind they would only be unreachable rows.
await PriceHistory.deleteMany({ collection: target._id });
// Shelves and lists only mean anything next to the items they arrange.
await Furniture.deleteMany({ collection: target._id });
await List.deleteMany({ collection: target._id });
// Users pointing at this collection self-heal to another membership on next request
await User.updateMany(
{ lastActiveCollectionId: target._id },
{ $set: { lastActiveCollectionId: null } },
);
await User.updateMany(
{ homeCollectionId: target._id },
{ $set: { homeCollectionId: null } },
);
await Collection.deleteOne({ _id: target._id });
try {
await deleteUnusedManagedItemImages(itemImages);
} catch (cleanupError) {
console.warn('[ITEM IMAGE] Collection cleanup failed:', cleanupError);
}
res.redirect("/admin/instance?msg=collection_deleted");
} catch (err) {
console.error("Collection delete error:", err);
res.redirect("/admin/instance");
}
});
// ============ INSTANCE-LEVEL MEMBER MANAGEMENT (any collection by :id) ============
// The instance admin manages any collection's members straight from /admin/instance.
router.post("/instance/collections/:id/members/add", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const { userId } = req.body;
const role = ["admin", "editor", "viewer"].includes(req.body.role) ? req.body.role : "viewer";
if (!mongoose.Types.ObjectId.isValid(req.params.id) || !mongoose.Types.ObjectId.isValid(userId)) {
return res.redirect("/admin/instance");
}
const target = await User.findById(userId).select("_id lastActiveCollectionId");
if (!target) return res.redirect("/admin/instance?msg=error_member_not_found");
const already = await Collection.findOne({ _id: req.params.id, "members.user": userId });
if (already) return res.redirect("/admin/instance?msg=error_member_exists");
await Collection.updateOne(
{ _id: req.params.id },
{ $addToSet: { members: { user: userId, role } } },
);
// Give homeless users a landing collection right away
if (!target.lastActiveCollectionId) {
await User.updateOne({ _id: userId }, { $set: { lastActiveCollectionId: req.params.id } });
}
res.redirect("/admin/instance?msg=member_added");
} catch (err) {
console.error("Instance member add error:", err);
res.redirect("/admin/instance?msg=error_member");
}
});
router.post("/instance/collections/:id/members/role", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const { userId } = req.body;
const role = ["admin", "editor", "viewer"].includes(req.body.role) ? req.body.role : null;
if (!role || !mongoose.Types.ObjectId.isValid(req.params.id) || !mongoose.Types.ObjectId.isValid(userId)) {
return res.redirect("/admin/instance");
}
await Collection.updateOne(
{ _id: req.params.id, "members.user": userId },
{ $set: { "members.$.role": role } },
);
res.redirect("/admin/instance?msg=member_role_updated");
} catch (err) {
console.error("Instance member role error:", err);
res.redirect("/admin/instance?msg=error_member");
}
});
router.post("/instance/collections/:id/members/remove", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const { userId } = req.body;
if (!mongoose.Types.ObjectId.isValid(req.params.id) || !mongoose.Types.ObjectId.isValid(userId)) {
return res.redirect("/admin/instance");
}
await Collection.updateOne(
{ _id: req.params.id },
{ $pull: { members: { user: userId } } },
);
await User.updateOne(
{ _id: userId, lastActiveCollectionId: req.params.id },
{ $set: { lastActiveCollectionId: null } },
);
await User.updateOne(
{ _id: userId, homeCollectionId: req.params.id },
{ $set: { homeCollectionId: null } },
);
res.redirect("/admin/instance?msg=member_removed");
} catch (err) {
console.error("Instance member remove error:", err);
res.redirect("/admin/instance?msg=error_member");
}
});
// ============ COLLECTION MEMBERS (collection-admin scope) ============
const MEMBER_ROLES = ["admin", "editor", "viewer"];
/** True if the collection keeps at least one 'admin' member besides `excludedUserId`. */
function hasAnotherCollectionAdmin(collectionDoc: any, excludedUserId: any): boolean {
return (collectionDoc?.members || []).some(
(m: any) => m.role === "admin" && String(m.user) !== String(excludedUserId),
);
}
// Create a NEW instance user directly as a member of the active collection.
router.post("/members/create", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const { username, email } = req.body;
const role = MEMBER_ROLES.includes(req.body.role) ? req.body.role : "viewer";
const password = createPassword();
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = await User.create({
username,
email,
password: password,
lastChange: new Date(),
});
await User.updateOne({ _id: newUser._id }, { $set: { password: hashedPassword } });
await Collection.updateOne(
{ _id: res.locals.activeCollectionId },
{ $addToSet: { members: { user: newUser._id, role } } },
);
await User.updateOne(
{ _id: newUser._id },
{ $set: { lastActiveCollectionId: res.locals.activeCollectionId } },
);
const data = await loadCollectionAdminData(res.locals.activeCollectionId);
res.render("admin", {
...data,
user: res.locals.user,
successMessage: req.t("messages.user_created_success", { name: username }),
newPassword: password,
});
} catch (err) {
console.error("Member creation error:", err);
res.redirect(`/admin?msg=${userCreationErrorKey(err)}`);
}
});
// Invite an EXISTING instance user into the active collection (by email or username).
router.post("/members/invite", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const identifier = (req.body.identifier || "").trim();
const role = MEMBER_ROLES.includes(req.body.role) ? req.body.role : "viewer";
if (!identifier) return res.redirect("/admin?msg=error_member_not_found");
const target: any = await User.findOne({
$or: [{ email: identifier.toLowerCase() }, { username: identifier }],
});
if (!target) return res.redirect("/admin?msg=error_member_not_found");
const already = await Collection.findOne({
_id: res.locals.activeCollectionId,
"members.user": target._id,
});
if (already) return res.redirect("/admin?msg=error_member_exists");
await Collection.updateOne(
{ _id: res.locals.activeCollectionId },
{ $addToSet: { members: { user: target._id, role } } },
);
res.redirect("/admin?msg=member_added");
} catch (err) {
console.error("Member invite error:", err);
res.redirect("/admin?msg=error_member");
}
});
// Change a member's role. One cannot change one's OWN role (another admin must),
// and the last collection admin cannot be demoted.
router.post("/members/role", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const { userId } = req.body;
const role = MEMBER_ROLES.includes(req.body.role) ? req.body.role : null;
if (!role || !mongoose.Types.ObjectId.isValid(userId)) return res.redirect("/admin");
if (String(userId) === String(req.user._id)) {
return res.redirect("/admin?msg=error_self_role");
}
const coll = await Collection.findById(res.locals.activeCollectionId);
const member = (coll?.members || []).find((m: any) => String(m.user) === String(userId));
if (!member) return res.redirect("/admin?msg=error_member_not_found");
if (member.role === "admin" && role !== "admin" && !hasAnotherCollectionAdmin(coll, userId)) {
return res.redirect("/admin?msg=error_last_admin");
}
await Collection.updateOne(
{ _id: res.locals.activeCollectionId, "members.user": userId },
{ $set: { "members.$.role": role } },
);
res.redirect("/admin?msg=member_role_updated");
} catch (err) {
console.error("Member role error:", err);
res.redirect("/admin?msg=error_member");
}
});
// Remove a member from the active collection. The last collection admin cannot leave.
router.post("/members/remove", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const { userId } = req.body;
if (!mongoose.Types.ObjectId.isValid(userId)) return res.redirect("/admin");
const coll = await Collection.findById(res.locals.activeCollectionId);
const member = (coll?.members || []).find((m: any) => String(m.user) === String(userId));
if (!member) return res.redirect("/admin?msg=error_member_not_found");
if (member.role === "admin" && !hasAnotherCollectionAdmin(coll, userId)) {
return res.redirect("/admin?msg=error_last_admin");
}
await Collection.updateOne(
{ _id: res.locals.activeCollectionId },
{ $pull: { members: { user: userId } } },
);
// If they were browsing this collection, let the middleware pick another one
await User.updateOne(
{ _id: userId, lastActiveCollectionId: res.locals.activeCollectionId },
{ $set: { lastActiveCollectionId: null } },
);
await User.updateOne(
{ _id: userId, homeCollectionId: res.locals.activeCollectionId },
{ $set: { homeCollectionId: null } },
);
res.redirect("/admin?msg=member_removed");
} catch (err) {
console.error("Member remove error:", err);
res.redirect("/admin?msg=error_member");
}
});
// Reset a member's password. Refused when the target is the instance admin OR
// belongs to any other collection: resetting a shared user's password would let
// this collection's admin impersonate them and take over their roles elsewhere.
router.post("/members/reset-password", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const { userId } = req.body;
if (!mongoose.Types.ObjectId.isValid(userId)) return res.redirect("/admin");
const isMember = await Collection.findOne({
_id: res.locals.activeCollectionId,
"members.user": userId,
});
const target: any = await User.findById(userId);
if (!isMember || !target || target.isAdmin) {
return res.redirect("/admin?msg=error_member_not_found");
}
const otherMembership = await Collection.findOne({
_id: { $ne: res.locals.activeCollectionId },
"members.user": userId,
});
if (otherMembership && !res.locals.user.isAdmin) {
return res.redirect("/admin?msg=error_shared_member_reset");
}
const password = createPassword();
const hashedPassword = await bcrypt.hash(password, 10);
await User.updateOne(
{ _id: userId },
{ $set: { password: hashedPassword, lastChange: new Date() } },
);
const data = await loadCollectionAdminData(res.locals.activeCollectionId);
res.render("admin", {
...data,
user: res.locals.user,
// A name is a value, not markup: the view escapes it on the way out, and letting
// i18next escape it first would print the entities instead of the apostrophe.
successMessage: req.t("messages.password_reset_success", {
name: target.username,
interpolation: { escapeValue: false },
}),
newPassword: password,
});
} catch (err) {
console.error("Member reset error:", err);
res.redirect("/admin?msg=error_member");
}
});
// ============ COLLECTION SHARING (collection-admin scope) ============
// Public, account-free read-only browsing via routes/shareRoutes.ts. A collection can
// have several independent links at once (e.g. one scoped to Vinyls, one to CDs) -
// each with its own token, so disabling/regenerating/deleting one never touches
// the others.
/**
* Builds a shareLinks[].scope array from a create/edit submission. Checking no type
* boxes at all means "whole collection" - there is no separate on/off toggle for
* scope itself. A format checked without its type box (e.g. JS failed to auto-check
* it - see the checkbox script in views/admin.ejs) still counts: the type is implied
* by having any format selected under it, so a submission is never silently dropped.
*/
function parseShareScope(body: any): { pluginId: string; formats: string[] }[] {
const rawTypes = body.scopeTypes;
const checkedTypes: string[] = Array.isArray(rawTypes) ? rawTypes : rawTypes ? [rawTypes] : [];
const impliedTypes = Object.keys(body)
.filter((k) => k.startsWith("scopeFormats_"))
.filter((k) => (Array.isArray(body[k]) ? body[k].length > 0 : !!body[k]))
.map((k) => k.slice("scopeFormats_".length));
const selectedTypes = [...new Set([...checkedTypes, ...impliedTypes])];
return selectedTypes
.map((id: string) => {
const plugin = registry.get(id);
if (!plugin) return null;
const rawFormats = body[`scopeFormats_${id}`];
const submitted: string[] = Array.isArray(rawFormats) ? rawFormats : rawFormats ? [rawFormats] : [];
const validFormats = new Set((plugin.formats || []).map((f: any) => f.value));
const formats = submitted.filter((f: string) => validFormats.has(f));
return { pluginId: id, formats };
})
.filter((entry): entry is { pluginId: string; formats: string[] } => !!entry);
}
// The share links page: every link of the collection with what it opens to, and the
// sheets that create and edit them. The actions below all come back here.
router.get("/sharing", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const collectionDoc = await Collection.findById(res.locals.activeCollectionId).lean();
res.render("sharing", {
collectionDoc,
// Absolute, as on the admin page: a baseUrl-relative path cannot be pasted elsewhere.
siteOrigin: `${getPublicProtocol(req)}://${req.get("host")}`,
user: res.locals.user,
});
} catch (err) {
console.error("Sharing page error:", err);
res.redirect("/admin?msg=error_share");
}
});
// Create a new share link with the submitted scope (and optional label).
router.post("/share/create", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const label = String(req.body.label || "").trim().slice(0, 60);
const scope = parseShareScope(req.body);
await Collection.updateOne(
{ _id: res.locals.activeCollectionId },
{ $push: { shareLinks: { token: generateShareToken(), label, enabled: true, scope } } },
);
res.redirect("/admin/sharing?msg=share_link_created");
} catch (err) {
console.error("Share create error:", err);
res.redirect("/admin/sharing?msg=error_share");
}
});
// Flip one link's enabled state (disabling keeps its token - re-enabling reuses the
// same link/QR code instead of forcing a reprint).
router.post("/share/:token/toggle", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const coll: any = await Collection.findOne(
{ _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token },
{ "shareLinks.$": 1 },
);
const link = coll?.shareLinks?.[0];
if (!link) return res.redirect("/admin/sharing?msg=error_share");
await Collection.updateOne(
{ _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token },
{ $set: { "shareLinks.$.enabled": !link.enabled } },
);
res.redirect(`/admin/sharing?msg=${link.enabled ? "share_disabled" : "share_enabled"}`);
} catch (err) {
console.error("Share toggle error:", err);
res.redirect("/admin/sharing?msg=error_share");
}
});
// Update an existing link's scope and/or label in place.
router.post("/share/:token/scope", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const label = String(req.body.label || "").trim().slice(0, 60);
const scope = parseShareScope(req.body);
const result = await Collection.updateOne(
{ _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token },
{ $set: { "shareLinks.$.scope": scope, "shareLinks.$.label": label } },
);
if (result.matchedCount === 0) return res.redirect("/admin/sharing?msg=error_share");
res.redirect("/admin/sharing?msg=share_scope_updated");
} catch (err) {
console.error("Share scope error:", err);
res.redirect("/admin/sharing?msg=error_share");
}
});
// Rotate a link's token - instantly invalidates whatever URL/QR code is already out
// there, without touching this collection's other links.
router.post("/share/:token/regenerate", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const result = await Collection.updateOne(
{ _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token },
{ $set: { "shareLinks.$.token": generateShareToken(), "shareLinks.$.enabled": true } },
);
if (result.matchedCount === 0) return res.redirect("/admin/sharing?msg=error_share");
res.redirect("/admin/sharing?msg=share_regenerated");
} catch (err) {
console.error("Share regenerate error:", err);
res.redirect("/admin/sharing?msg=error_share");
}
});
// Remove a link entirely.
router.post("/share/:token/delete", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
// The token is named in the filter as well as in the $pull, the way the routes above
// do it: a token belonging to another collection would otherwise pull nothing while
// still reporting a success, since the schema's timestamps count every update as a
// modification whether or not anything came out of the array.
const result = await Collection.updateOne(
{ _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token },
{ $pull: { shareLinks: { token: req.params.token } } },
);
if (result.matchedCount === 0) return res.redirect("/admin/sharing?msg=error_share");
res.redirect("/admin/sharing?msg=share_link_deleted");
} catch (err) {
console.error("Share delete error:", err);
res.redirect("/admin/sharing?msg=error_share");
}
});
// Server-rendered QR PNG so a link's token never has to be handed to a
// third-party QR-image API - it stays entirely within this instance.
router.get("/share/:token/qr.png", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
// $elemMatch, because a collection holds several links: named separately, the two
// conditions can be met by two different ones, and the QR of a link someone just
// disabled would keep being served as long as any other link is still on.
const coll = await Collection.findOne(
{
_id: res.locals.activeCollectionId,
shareLinks: { $elemMatch: { token: req.params.token, enabled: true } },
},
{ _id: 1 },
);
if (!coll) return res.status(404).send(req.t("errors.not_found"));
const url = `${getPublicProtocol(req)}://${req.get("host")}${BASE_URL}/share/${req.params.token}`;
const png = await QRCode.toBuffer(url, { type: "png", width: 320, margin: 1 });
res.set("Content-Type", "image/png");
res.send(png);
} catch (err) {
console.error("Share QR error:", err);
res.status(500).send(req.t("errors.generic_server_error"));
}
});
// ============ INSTANCE USERS ============
// Reset password (POST)
router.post("/reset-password", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const { userId } = req.body;
const userToUpdate = await User.findById(userId);
// Instance admins are peers: none may reset another instance admin's
// password (that would let them hijack the account). Resetting your own is
// still allowed.
if (
userToUpdate &&
userToUpdate.isAdmin &&
userToUpdate._id.toString() !== res.locals.user._id.toString()
) {
return res.redirect("/admin/instance?msg=reset_admin_error");
}
if (userToUpdate) {
const password = createPassword();
const hashedPassword = await bcrypt.hash(password, 10);
await User.updateOne(
{ _id: userId },
{ $set: { password: hashedPassword, lastChange: new Date() } },
);
// Reload data for the view after change.
const data = await loadInstanceAdminData();
res.render("admin-instance", {
...data,
user: res.locals.user,
successMessage: req.t("messages.password_reset_success", {
name: userToUpdate.username,
interpolation: { escapeValue: false },
}),
newPassword: password,
apiKeyStatus: registry.getApiKeyStatus(),
});
} else {
res.redirect("/admin/instance");
}
} catch (err) {
console.error(err);
res.redirect("/admin");
}
});
// Delete user (POST) - instance level; also drops every collection membership.
router.post("/delete-user", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
if (req.body.userId === res.locals.user._id.toString())
return res.redirect("/admin/instance?msg=delete_self_error");
// Instance admins are peers: none may delete another instance admin.
const target = await User.findById(req.body.userId);
if (target && target.isAdmin)
return res.redirect("/admin/instance?msg=delete_admin_error");
await User.findByIdAndDelete(req.body.userId);
await Collection.updateMany({}, { $pull: { members: { user: req.body.userId } } });
console.log(`[ADMIN] User deleted: ${req.body.userId} by ${res.locals.user?.email}`);
res.redirect("/admin/instance?msg=user_deleted");
} catch (err) {
console.error("[ADMIN] User deletion error:", err);
res.redirect("/admin/instance");
}
});
router.post("/block-ip", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
const { ipAddress } = req.body;
const exists = await BlockedIP.findOne({ ip: ipAddress });
if (!exists) await BlockedIP.create({ ip: ipAddress });
console.log(`[ADMIN] IP blocked: ${ipAddress} by ${res.locals.user?.email}${exists ? ' (already blocked)' : ''}`);
res.redirect("/admin/instance?msg=ip_blocked");
} catch (err) {
console.error("[ADMIN] block-ip error:", err);
res.redirect("/admin/instance");
}
});
router.post("/unblock-ip", requireAuth, requireAdmin, async (req: any, res: any) => {
try {
await BlockedIP.findByIdAndDelete(req.body.ipId);
console.log(`[ADMIN] IP unblocked: ${req.body.ipId} by ${res.locals.user?.email}`);
res.redirect("/admin/instance?msg=ip_unblocked");
} catch (err) {
console.error("[ADMIN] unblock-ip error:", err);
res.redirect("/admin/instance");
}
});
// Delete the N most recent login logs (instance level, JSON API like delete-last-items).
router.post("/delete-last-logs", requireAuth, requireAdmin, async (req: any, res: any) => {
const n = parseInt(req.body.count);
if (!n || n < 1) return res.status(400).json({ error: "Invalid count" });
try {
const logs = await LoginLog.find().sort({ timestamp: -1 }).limit(n).select("_id");
const ids = logs.map((l) => l._id);
const result = await LoginLog.deleteMany({ _id: { $in: ids } });
res.json({ deleted: result.deletedCount });
} catch (err: any) {
console.error("[ERR] delete-last-logs:", err.message);
res.status(500).json({ error: err.message });
}
});
// COLLECTION INFO PAGE EDITOR (see core/collectionInfo.ts). The page it writes is
// served by core/routes/collectionInfoRoute.ts and is readable by share visitors, so
// only a collection admin gets to write it.
router.get("/collection-info", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const collection = await Collection.findById(res.locals.activeCollectionId).lean();
if (!collection) return res.redirect("/admin");
const info = collectionInfoOf(collection);
res.render("admin-collection-info", {
user: res.locals.user,
collectionName: (collection as any).name,
info,
infoHtml: renderMarkdown(info.body),
maxTitle: MAX_COLLECTION_INFO_TITLE,
maxBody: MAX_COLLECTION_INFO_BODY,
maxImages: MAX_COLLECTION_INFO_IMAGES,
successMessage: req.query.msg ? req.t(`messages.${req.query.msg}`) : null,
});
} catch (err) {
console.error("[ERR] Collection info editor:", err);
res.status(500).send(req.t("errors.generic_server_error"));
}
});
router.post("/collection-info", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const collectionId = res.locals.activeCollectionId;
const previous = collectionInfoOf(await Collection.findById(collectionId).lean());
const info = collectionInfoFromForm(req.body);
await Collection.updateOne(
{ _id: collectionId },
{ $set: { info: { ...info, updated_at: new Date() } } },
);
// An upload dropped from the page is nothing's image any more. The release is
// guarded against every other reference (items, other collections), so a picture
// used twice stays where it is.
const removed = previous.images.filter(image => !info.images.includes(image));
if (removed.length > 0) {
try {
await deleteUnusedManagedItemImages(removed);
} catch (cleanupError) {
console.warn("[COLLECTION INFO] Image cleanup failed:", cleanupError);
}
}
res.redirect("/admin/collection-info?msg=saved");
} catch (err) {
console.error("[ERR] Collection info save:", err);
res.status(500).send(req.t("errors.generic_server_error"));
}
});
// Live preview of the editor's text. Rendered here rather than in the browser so the
// preview and the page itself can never drift apart: both read core/markdown.ts.
router.post("/collection-info/preview", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
const body = typeof req.body?.body === "string" ? req.body.body : "";
res.json({ html: renderMarkdown(body.slice(0, MAX_COLLECTION_INFO_BODY)) });
});
router.get("/personnalisation", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const settings = res.locals.settings;
res.render("personnalisation", {
presets: PRESETS,
// Scan mode is only worth offering when an active module has a source that can
// read a query as one exact item (see ExternalSource.exactQuery).
scanModeAvailable: registry.getAll().some(p =>
settings?.modules?.[p.collectionType] && pluginSources(p).some(s => typeof s.exactQuery === "function")
),
});
} catch (err) {
console.error(err);
res.status(500).send("ERR");
}
});
router.post(
"/personnalisation/save",
requireAuth,
requireCollectionRole("admin"),
async (req: any, res: any) => {
try {
const {
homePreset,
navbarShortcuts,
statsWidgets,
aspectRatioClass,
} = req.body;
const shortcuts = Array.isArray(navbarShortcuts)
? navbarShortcuts
: navbarShortcuts
? [navbarShortcuts]
: [];
const stats = Array.isArray(statsWidgets)
? statsWidgets
: statsWidgets
? [statsWidgets]
: [];
const validFastAdd = [""].concat(
registry.getAll().flatMap(p => (p.fastAddOptions || []).map(o => o.value))
);
const fastAdd = validFastAdd.includes(req.body.fastAdd)
? req.body.fastAdd
: "";
// Ends up in a class attribute on every card, so only the offered frames pass.
const cardAspect = (CARD_ASPECT_RATIOS as readonly string[]).includes(aspectRatioClass)
? aspectRatioClass
: "aspect-square";
const update: Record<string, any> = {
"theme.home.preset": homePreset,
aspectRatioClass: cardAspect,
navbarShortcuts: shortcuts,
statsWidgets: stats,
fastAdd: fastAdd,
instantAdd: req.body.instantAdd === "on",
};
for (const p of registry.getAll()) {
const preset = req.body[`${p.collectionType}Preset`];
if (preset) update[`theme.${p.collectionType}.preset`] = preset;
}
await Settings.findOneAndUpdate(
{ collection: res.locals.activeCollectionId },
{ $set: update },
{ upsert: true },
);
res.redirect("/admin/personnalisation?msg=saved");
} catch (err) {
console.error("[ERR] perso save", err);
res.status(500).send("[ERR] perso save failed.");
}
},
);
router.post("/modules/save", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const moduleKeys = registry.getAll().map(p => p.collectionType);
if (!moduleKeys.some(key => req.body[`${key}Active`] === "on")) {
return res.redirect("/admin?msg=error_no_module");
}
const update: Record<string, any> = {};
for (const key of moduleKeys) {
update[`modules.${key}`] = req.body[`${key}Active`] === "on";
}
update.mergeDuplicates = req.body.mergeDuplicates === "on";
// Plugin-scoped settings (⚙ per module): pluginSetting_<pluginId>_<key>
for (const p of registry.getAll()) {
for (const opt of p.settings || []) {
update[`pluginSettings.${p.id}.${opt.key}`] = req.body[`pluginSetting_${p.id}_${opt.key}`] === "on";
}
}
// Order the collection prefers its sources in: sourceOrder_<pluginId>, a comma-separated
// list of ids. Filtered against what the plugin declares, so a stale form or a hand-made
// request cannot store an id that means nothing, and deduplicated, since a repeated id
// would silently drop whichever source it displaced.
for (const p of registry.getAll()) {
const raw = req.body[`sourceOrder_${p.id}`];
if (typeof raw !== "string") continue;
const declared = new Set(pluginSources(p).map(s => s.id));
const ids = [...new Set(raw.split(",").map((id: string) => id.trim()).filter((id: string) => declared.has(id)))];
if (ids.length > 0) update[`sourceOrder.${p.id}`] = ids;
}
await Settings.findOneAndUpdate(
{ collection: res.locals.activeCollectionId },
{ $set: update },
{ upsert: true },
);
res.redirect("/admin?msg=saved");
} catch (err) {
console.error("[ERR] modules save", err);
res.status(500).send("[ERR] modules save failed.");
}
});
router.post("/visibility/save", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => {
try {
const { applyToAdmin, hiddenItems, hiddenGenres, hiddenTypes } = req.body || {};
let parsedItems = [];
if (hiddenItems) {
try {
parsedItems = JSON.parse(hiddenItems);
} catch (e) {
parsedItems = [];
}
}
const applyToAdminVal =
applyToAdmin === "on" || applyToAdmin === "true" || applyToAdmin === true;
const update = {
"visibility.applyToAdmin": applyToAdminVal,
"visibility.hiddenItems": parsedItems,
"visibility.hiddenGenres": Array.isArray(hiddenGenres)
? hiddenGenres
: hiddenGenres
? [hiddenGenres]
: [],
"visibility.hiddenTypes": Array.isArray(hiddenTypes)
? hiddenTypes
: hiddenTypes
? [hiddenTypes]
: [],
};
await Settings.findOneAndUpdate(
{ collection: res.locals.activeCollectionId },
{ $set: update },
{ upsert: true },
);
res.redirect("/admin?msg=saved");
} catch (err) {
console.error("[ERR] visibility save", err);
res.status(500).send("[ERR] visibility save failed.");
}
});
router.get(
"/api/search-collection",
requireAuth,
requireCollectionRole("admin"),
async (req: any, res: any) => {
try {
const { q } = req.query;
const trimmedQ = typeof q === 'string' ? q.trim() : '';
if (!trimmedQ) return res.json([]);
const regex = new RegExp(escapeRegExp(trimmedQ), 'i');
const searchOr: any[] = [
{ title: regex },
{ artist: regex },
{ author: regex },
{ director: regex },
{ barcode: regex },
{ 'tracklist.title': regex }
];
if (mongoose.Types.ObjectId.isValid(trimmedQ)) {
searchOr.push({ _id: trimmedQ });
}
const items = await Item.find({
collection: res.locals.activeCollectionId,
$or: searchOr
}).limit(10).select('_id title artist author director kind cover_image format format_type platform media_type').lean();
res.json(items);
} catch (err) {
console.error("[ERR] search collection", err);
res.status(500).json({ error: "Search failed" });
}
},
);
router.get(
"/api/search-image-universal",
requireAuth,
requireCollectionRole("editor"),
async (req: any, res: any) => {
let { q, type } = req.query;
q = typeof q === 'string' ? q.trim() : '';
const pluginId = typeof req.query.plugin === 'string' ? req.query.plugin : '';
try {
// The picker names its plugin outright. `type` is what it used to send and is still
// accepted: the route is a public endpoint of the instance, and an old page left
// open in a tab must not start coming back empty.
//
// Nothing falls back to the legacy plugin any more. It used to, which is how a
// custom plugin asking for its own images was handed music's album covers: a
// plugin with no image source of its own now gets none rather than someone else's.
const plugin = (pluginId ? registry.get(pluginId) : undefined)
|| registry.getAll().find(p => p.imageSearchType === type);
if (!plugin) {
return res.json([]);
}
// Every image source of the plugin at once, merged and deduplicated.
const urls = await gatherImages(plugin, q, { language: req.language }, res.locals.settings);
console.log(`[SEARCH] ${plugin.id} found: ${urls.length} images`);
res.json(urls);
} catch (err: any) {
console.error("[ERR] search image universal:", err.message);
res.status(500).json({ error: "[ERR] connexion error" });
}
},
);
router.post(
"/delete-last-items",
requireAuth,
requireCollectionRole("admin"),
async (req: any, res: any) => {
const { count, kind } = req.body;
const n = parseInt(count);
if (!n || n < 1) return res.status(400).json({ error: "Invalid count" });
const allowedKinds = registry.getAll().map(p => p.kind);
if (!allowedKinds.includes(kind))
return res.status(400).json({ error: "Invalid kind" });
try {
// Counted the way the grid counts: "the last 3 items" means the last 3 lines someone
// can see, and a show leaves with its seasons rather than counting as several.
const items = await Item.find({
collection: res.locals.activeCollectionId,
kind,
parent: { $exists: false }
})
.sort({ added_at: -1, _id: -1 })
.limit(n)
.select("_id");
const deleted = await deleteItemsAndContents(items.map((i) => i._id));
res.json({ deleted });
} catch (err: any) {
console.error("[ERR] delete-last-items:", err.message);
res.status(500).json({ error: err.message });
}
},
);
router.post(
"/refresh-all/:pluginId",
requireAuth,
requireCollectionRole("admin"),
async (req: any, res: any) => {
const { pluginId } = req.params;
const { mode = "all" } = req.body;
const plugin = registry.get(pluginId);
if (!plugin) return res.status(404).json({ error: "Plugin not found" });
if (!canRefresh(plugin)) return res.status(400).json({ error: "Plugin does not support refresh" });
try {
const idField = plugin.externalIdField || '_id';
// An item qualifies on either reference: the plugin's own id field, which is what
// everything added before sources existed carries, or the stored source pair, which
// is the only thing an item filled in from another database has. Selecting on the id
// field alone is what would leave those out of every bulk run, silently.
let query: any = {
collection: res.locals.activeCollectionId,
$or: [
{ [idField]: { $exists: true, $ne: null } },
{ source_id: { $exists: true, $nin: [null, ''] } }
]
};
if (plugin.matchesLegacyItems) {
query.$and = [{ $or: [{ kind: plugin.kind }, { kind: { $exists: false } }] }];
} else {
query.kind = plugin.kind;
}
if (mode === "missing") {
query.$and = query.$and || [];
query.$and.push({
$or: [
{ genre: { $exists: false } },
{ genre: "" },
{ genre: null },
{ genres: { $exists: false } },
{ genres: { $size: 0 } },
{ styles: { $exists: false } },
{ styles: { $size: 0 } }
]
});
}
const items = await Item.find(query).lean();
if (items.length === 0) return res.json({ success: true, count: 0 });
res.status(202).json({ success: true, total: items.length });
(async () => {
const io = req.app.get("io");
let current = 0;
for (const item of items) {
current++;
let success = false;
let retries = 0;
while (!success && retries < 3) {
try {
if (io && retries === 0) {
io.emit("refresh_all_progress", {
current,
total: items.length,
title: `${item[plugin.creatorField]} - ${item.title}`,
});
}
const refreshedData = await refreshPatchFor(plugin, item, req);
// "missing" mode only backfills genre metadata, never clobber cover/description/
// publisher/etc that the user may have edited by hand.
let dataToApply = refreshedData;
if (mode === "missing") {
dataToApply = {};
for (const k of ["genre", "genres", "styles"]) {
if (refreshedData[k] !== undefined) dataToApply[k] = refreshedData[k];
}
}
// Same realignment as the single-item refresh: a new cover replaces the old
// one inside the gallery instead of pushing it down into it. Copied rather
// than mutated, since in the full mode this is the plugin's own return value.
const update = { ...dataToApply };
const replacedCover = alignImagesAfterRefresh(item, update);
// Written even when the provider changed nothing, so the date says when the
// item was last checked rather than when it last happened to differ.
//
// Through the discriminator model rather than the base one: the plugin's own
// paths (developer, publisher, tracklist...) do not exist on the base schema,
// and strict mode drops them without a word. A bulk run reported every item
// as refreshed while writing back only the fields the base schema shares.
// The single-item route sidesteps this by casting through `kind` in its filter.
const RefreshModel = mongoose.model(plugin.kind);
await RefreshModel.updateOne({ _id: item._id }, { $set: { ...update, ...syncStamp() } });
if (replacedCover) {
try {
await deleteUnusedManagedItemImages([replacedCover]);
} catch (cleanupError) {
console.warn('[ITEM IMAGE] Post-refresh cleanup failed:', cleanupError);
}
}
success = true;
await new Promise((r) => setTimeout(r, plugin.bulkRefreshDelayMs ?? 500));
} catch (err: any) {
retries++;
console.error(
`[ERR] Refresh bulk ID for ${plugin.id} (Attempt ${retries}):`,
err.message,
);
// Nothing about this item can change between attempts: retrying only
// stretches the run by 6 seconds per item for the same failure.
if (err instanceof PermanentRefreshError) break;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
if (io) io.emit("refresh_all_finished", { count: current });
})();
} catch (err: any) {
console.error("[ERR] Bulk refresh route:", err.message);
if (!res.headersSent) res.status(500).json({ error: err.message });
}
}
);
export = router;