Bri-Tunes/src/routes/admin.js

543 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const express = require('express');
const multer = require('multer');
const { z } = require('zod');
const { requireUser, requireAdmin, requireVip } = require('../middleware/auth');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
const imageService = require('../services/image');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
const AUDIO_EXT = new Set(['.mp3', '.m4a', '.aac', '.ogg', '.oga', '.flac', '.wav', '.opus', '.webm']);
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const sub = file.fieldname === 'audio' ? 'audio' : 'covers';
cb(null, path.join(mediaDir, sub));
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${crypto.randomUUID()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB per file
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (file.fieldname === 'audio' && !AUDIO_EXT.has(ext)) return cb(new Error('Unsupported audio format.'));
if (file.fieldname === 'cover' && !IMAGE_EXT.has(ext)) return cb(new Error('Unsupported cover image format.'));
cb(null, true);
},
});
const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]);
const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]);
const VISIBILITY_VALUES = ['public', 'logged_in', 'vip', 'private'];
const VISIBILITY_RANK = { public: 0, logged_in: 1, vip: 2, private: 3 };
// For uploads, all text fields are optional — we'll fill blanks from ID3 tags.
// The edit form enforces title/artist via a stricter schema below.
const songUploadSchema = z.object({
title: z.string().trim().max(200).optional().or(z.literal('')),
artist: z.string().trim().max(200).optional().or(z.literal('')),
album: z.string().trim().max(200).optional().or(z.literal('')),
genre: z.string().trim().max(80).optional().or(z.literal('')),
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
});
const songEditSchema = z.object({
title: z.string().trim().min(1).max(200),
artist: z.string().trim().min(1).max(200),
album: z.string().trim().max(200).optional().or(z.literal('')),
genre: z.string().trim().max(80).optional().or(z.literal('')),
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
});
const playlistSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(2000).optional().or(z.literal('')),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
});
// Parse embedded tags (ID3, Vorbis comments, etc.) from an audio file.
// Returns a plain object; every field may be null if not present in the file.
async function extractAudioMetadata(absPath) {
const result = {
duration: null, title: null, artist: null, album: null,
genre: null, year: null, picture: null,
};
try {
const { parseFile } = await import('music-metadata');
const meta = await parseFile(absPath);
if (meta.format?.duration) result.duration = Math.round(meta.format.duration);
const c = meta.common || {};
if (c.title) result.title = String(c.title).trim() || null;
if (c.artist) result.artist = String(c.artist).trim() || null;
else if (Array.isArray(c.artists) && c.artists.length) result.artist = String(c.artists[0]).trim() || null;
if (c.album) result.album = String(c.album).trim() || null;
if (Array.isArray(c.genre) && c.genre.length) result.genre = String(c.genre[0]).trim() || null;
if (c.year && Number.isFinite(Number(c.year))) result.year = Number(c.year);
if (Array.isArray(c.picture) && c.picture.length) {
const pic = c.picture[0];
if (pic && pic.data && pic.format) {
result.picture = { data: pic.data, mime: String(pic.format) };
}
}
} catch (err) {
console.warn('[admin] could not parse audio metadata:', err.message);
}
return result;
}
// Turn an image MIME type into a reasonable file extension.
function extForMime(mime) {
switch ((mime || '').toLowerCase()) {
case 'image/jpeg':
case 'image/jpg': return '.jpg';
case 'image/png': return '.png';
case 'image/webp': return '.webp';
case 'image/gif': return '.gif';
default: return '.jpg';
}
}
// Process any cover source (multer file or embedded picture buffer) into a
// 1000×1000 JPEG and save it to media/covers/. Returns the relative path.
// Pass { multerFile } or { pictureBuffer, pictureMime }.
async function saveCover({ multerFile, pictureBuffer, pictureMime } = {}) {
let tempAbs = null;
let ownTemp = false;
if (multerFile) {
tempAbs = multerFile.path; // multer already wrote it to disk
} else if (pictureBuffer) {
// Write raw bytes to a temp file so sharp can read it
const tempExt = extForMime(pictureMime);
tempAbs = path.join(mediaDir, 'covers', `tmp-${crypto.randomUUID()}${tempExt}`);
fs.writeFileSync(tempAbs, pictureBuffer);
ownTemp = true;
} else {
return null;
}
const finalName = `${crypto.randomUUID()}.jpg`;
const finalAbs = path.join(mediaDir, 'covers', finalName);
try {
await imageService.processCoverImage(tempAbs, finalAbs);
} finally {
// Always remove the temp/multer source file
unlinkQuiet(tempAbs);
}
return path.posix.join('covers', finalName);
}
// Derive a fallback title from the uploaded filename (strip extension, tidy up).
function titleFromFilename(originalName) {
const base = path.basename(originalName, path.extname(originalName));
return base.replace(/[._-]+/g, ' ').trim() || 'Untitled';
}
function relMedia(file, sub) {
return path.posix.join(sub, path.basename(file.path));
}
function unlinkQuiet(absPath) {
fs.unlink(absPath, () => {});
}
function canManage(item, user) {
if (user.role === 'admin') return true;
return (item.uploadedBy != null && item.uploadedBy === user.id) ||
(item.createdBy != null && item.createdBy === user.id);
}
function forbidden(next) {
const err = new Error('Not authorized.');
err.status = 403;
err.expose = true;
next(err);
}
// Resolve the genre field from the genre select + optional "add new" text input.
// genre_new (non-empty) wins; else use the select value unless it's the __new__ sentinel.
function resolveGenre(body) {
const newVal = (body.genre_new || '').trim();
const selVal = body.genre === '__new__' ? '' : (body.genre || '');
return { ...body, genre: newVal || selVal };
}
module.exports = function adminRoutes(csrfProtection) {
const router = express.Router();
router.use(requireVip);
// ---------- Songs ----------
router.get('/', (req, res) => res.redirect('/mymusic/songs'));
router.get('/songs', (req, res) => {
const songList = songs.listByUser(req.session.user.id);
const playlistList = playlists.listByUser(req.session.user.id);
res.render('mymusic/songs', { title: 'My Music · Songs', songs: songList, playlists: playlistList });
});
router.post('/songs/bulk-add', csrfProtection, (req, res, next) => {
try {
const playlistId = Number(req.body.playlistId);
const songIds = [].concat(req.body['songIds[]'] || []).map(Number).filter(Boolean);
const playlist = playlists.findById(playlistId);
if (!playlist || !canManage(playlist, req.session.user)) {
return res.status(403).json({ error: 'Forbidden' });
}
for (const songId of songIds) {
const song = songs.findById(songId);
if (!song) continue;
playlists.addTrack(playlistId, songId);
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) {
playlists.setVisibility(playlistId, song.visibility);
}
}
res.json({ ok: true, added: songIds.length });
} catch (err) { next(err); }
});
router.post('/songs/bulk-visibility', csrfProtection, (req, res, next) => {
try {
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
let visibility = req.body.visibility;
if (!VISIBILITY_VALUES.includes(visibility)) return res.status(400).json({ error: 'Invalid visibility' });
if (visibility === 'vip' && !canSetVip) return res.status(403).json({ error: 'VIP permission required' });
const songIds = [].concat(req.body['songIds[]'] || []).map(Number).filter(Boolean);
let updated = 0;
for (const songId of songIds) {
const song = songs.findById(songId);
if (!song || !canManage(song, req.session.user)) continue;
songs.update(songId, {
title: song.title, artist: song.artist,
album: song.album || null, genre: song.genre || null,
year: song.year ?? null, visibility,
});
updated++;
}
res.json({ ok: true, updated, visibility });
} catch (err) { next(err); }
});
router.get('/songs/new', (req, res) => {
res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres() });
});
router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
try {
const body = resolveGenre(req.body);
const parsed = songUploadSchema.safeParse(body);
if (!parsed.success || !req.files || !req.files.audio || !req.files.audio[0]) {
const errors = {};
if (!parsed.success) for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.';
if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path);
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
return res.status(400).render('mymusic/song_form', { title: 'New song', song: null, errors, values: body, genres: songs.listGenres() });
}
const audioFile = req.files.audio[0];
const coverFile = req.files.cover?.[0];
// Read embedded tags. Form values win; tags fill any blanks.
const tags = await extractAudioMetadata(audioFile.path);
const pick = (formVal, tagVal) => {
const s = (formVal == null ? '' : String(formVal)).trim();
return s || tagVal || null;
};
const merged = {
title: pick(parsed.data.title, tags.title) || titleFromFilename(audioFile.originalname),
artist: pick(parsed.data.artist, tags.artist) || 'Unknown Artist',
album: pick(parsed.data.album, tags.album),
genre: pick(parsed.data.genre, tags.genre),
year: parsed.data.year ?? tags.year ?? null,
};
// If the user didn't upload a cover but the file has embedded art, use it.
let coverPath = null;
if (coverFile) {
try {
coverPath = await saveCover({ multerFile: coverFile });
} catch (err) {
console.warn('[admin] failed to process uploaded cover:', err.message);
unlinkQuiet(coverFile.path);
}
} else if (tags.picture) {
try {
coverPath = await saveCover({ pictureBuffer: tags.picture.data, pictureMime: tags.picture.mime });
} catch (err) {
console.warn('[admin] failed to write embedded cover:', err.message);
}
}
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
songs.create({
title: merged.title,
artist: merged.artist,
album: merged.album,
genre: merged.genre,
year: merged.year,
durationSeconds: tags.duration,
audioPath: relMedia(audioFile, 'audio'),
coverPath,
visibility,
isNsfw: parsed.data.nsfw,
uploadedBy: req.session.user.id,
});
req.flash('success', 'Song uploaded. Tags were read from the file — review and edit if needed.');
res.redirect('/mymusic/songs');
} catch (err) { next(err); }
});
router.get('/songs/:id/edit', (req, res, next) => {
const song = songs.findById(Number(req.params.id));
if (!song) return next();
if (!canManage(song, req.session.user)) return forbidden(next);
res.render('mymusic/song_form', {
title: 'Edit song', song, errors: {},
values: { ...song }, genres: songs.listGenres(),
});
});
router.post('/songs/:id', songFields, csrfProtection, async (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = songs.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
const body = resolveGenre(req.body);
const parsed = songEditSchema.safeParse(body);
if (!parsed.success) {
const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: body, genres: songs.listGenres() });
}
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
songs.update(id, {
title: parsed.data.title,
artist: parsed.data.artist,
album: parsed.data.album || null,
genre: parsed.data.genre || null,
year: parsed.data.year ?? null,
visibility,
isNsfw: parsed.data.nsfw,
});
const coverFile = req.files?.cover?.[0];
if (coverFile) {
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
try {
const newCoverPath = await saveCover({ multerFile: coverFile });
songs.setCover(id, newCoverPath);
} catch (err) {
console.warn('[admin] failed to process cover on edit:', err.message);
}
}
req.flash('success', 'Song updated.');
res.redirect('/mymusic/songs');
} catch (err) { next(err); }
});
router.post('/songs/:id/delete', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = songs.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
songs.remove(id);
// Best-effort cleanup of media.
if (existing.audioPath) unlinkQuiet(path.join(mediaDir, existing.audioPath));
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
req.flash('success', 'Song deleted.');
res.redirect('/mymusic/songs');
} catch (err) { next(err); }
});
// ---------- Playlists ----------
router.get('/playlists', (req, res) => {
const playlistList = playlists.listByUser(req.session.user.id);
res.render('mymusic/playlists', { title: 'My Music · Playlists', playlists: playlistList });
});
router.get('/playlists/new', (req, res) => {
const allSongs = songs.listForManagement(req.session.user.id);
const preselectIds = (req.query.songs || '').split(',').map(Number).filter(Boolean);
res.render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' }, preselectIds });
});
router.post('/playlists', playlistFields, csrfProtection, async (req, res, next) => {
try {
const user = req.session.user;
const parsed = playlistSchema.safeParse(req.body);
if (!parsed.success) {
const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
const allSongs = songs.listForManagement(user.id);
return res.status(400).render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body, preselectIds: [] });
}
const coverFile = req.files?.cover?.[0];
let coverPath = null;
if (coverFile) {
try {
coverPath = await saveCover({ multerFile: coverFile });
} catch (err) {
console.warn('[admin] failed to process playlist cover:', err.message);
}
}
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
const created = playlists.create({
title: parsed.data.title,
description: parsed.data.description || null,
coverPath,
visibility,
isNsfw: parsed.data.nsfw,
createdBy: req.session.user.id,
});
const preselectIds = [].concat(req.body['preselectIds[]'] || []).map(Number).filter(Boolean);
for (const songId of preselectIds) {
const song = songs.findById(songId);
if (!song) continue;
playlists.addTrack(created.id, songId);
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[created.visibility]) {
playlists.setVisibility(created.id, song.visibility);
}
}
req.flash('success', 'Playlist created.');
res.redirect(`/mymusic/playlists/${created.id}/edit`);
} catch (err) { next(err); }
});
router.get('/playlists/:id/edit', (req, res, next) => {
const id = Number(req.params.id);
const playlist = playlists.findById(id);
if (!playlist) return next();
if (!canManage(playlist, req.session.user)) return forbidden(next);
const allSongs = songs.listForManagement(req.session.user.id);
res.render('mymusic/playlist_form', {
title: 'Edit playlist',
playlist,
tracks: playlists.getTracks(id),
allSongs,
errors: {},
values: { ...playlist },
preselectIds: [],
});
});
router.post('/playlists/:id', playlistFields, csrfProtection, async (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = playlists.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
const user = req.session.user;
const parsed = playlistSchema.safeParse(req.body);
if (!parsed.success) {
const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
const allSongs = songs.listForManagement(user.id);
return res.status(400).render('mymusic/playlist_form', {
title: 'Edit playlist', playlist: existing,
tracks: playlists.getTracks(id), allSongs,
errors, values: req.body,
});
}
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
playlists.update(id, {
title: parsed.data.title,
description: parsed.data.description || null,
visibility,
isNsfw: parsed.data.nsfw,
});
const coverFile = req.files?.cover?.[0];
if (coverFile) {
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
try {
const newCoverPath = await saveCover({ multerFile: coverFile });
playlists.setCover(id, newCoverPath);
} catch (err) {
console.warn('[admin] failed to process playlist cover on edit:', err.message);
}
}
req.flash('success', 'Playlist updated.');
res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); }
});
router.post('/playlists/:id/add-track', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const songId = Number(req.body.song_id);
const playlist = playlists.findById(id);
const song = songs.findById(songId);
if (!playlist || !song) return next();
if (!canManage(playlist, req.session.user)) return forbidden(next);
playlists.addTrack(id, songId);
// Auto-upgrade playlist visibility if the song is more restrictive.
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) {
playlists.setVisibility(id, song.visibility);
}
res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); }
});
router.post('/playlists/:id/remove-track', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const songId = Number(req.body.song_id);
const playlist = playlists.findById(id);
if (!playlist) return next();
if (!canManage(playlist, req.session.user)) return forbidden(next);
playlists.removeTrack(id, songId);
res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); }
});
router.post('/playlists/:id/reorder', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const playlist = playlists.findById(id);
if (!playlist) return next();
if (!canManage(playlist, req.session.user)) return forbidden(next);
const order = String(req.body.order || '')
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
playlists.setOrder(id, order);
res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); }
});
router.post('/playlists/:id/delete', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = playlists.findById(id);
if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next);
playlists.remove(id);
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
req.flash('success', 'Playlist deleted.');
res.redirect('/mymusic/playlists');
} catch (err) { next(err); }
});
return router;
};