feat(admin): add add-music api

This commit is contained in:
2026-05-25 19:45:22 +07:00
parent 9d7114e2f1
commit 88dcaea7af
12 changed files with 265 additions and 1 deletions
+3
View File
@@ -6,3 +6,6 @@ JWT_REFRESH_SECRET=isi_random_string_lain
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CLIENT_URL=http://localhost:5173
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@noctune.local
ADMIN_PASSWORD=userpass
+2 -1
View File
@@ -17,7 +17,8 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.13.0",
"morgan": "^1.10.0"
"morgan": "^1.10.0",
"multer": "^2.0.2"
},
"devDependencies": {
"nodemon": "^3.1.2"
+76
View File
@@ -35,6 +35,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.10.1
multer:
specifier: ^2.0.2
version: 2.1.1
devDependencies:
nodemon:
specifier: ^3.1.2
@@ -59,6 +62,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
@@ -96,6 +102,13 @@ packages:
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -112,6 +125,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -416,6 +433,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multer@2.1.1:
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
engines: {node: '>= 10.16.0'}
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
@@ -483,6 +504,10 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@@ -542,6 +567,13 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
@@ -566,6 +598,9 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
@@ -573,6 +608,9 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
@@ -611,6 +649,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
append-field@1.0.0: {}
array-flatten@1.1.1: {}
balanced-match@4.0.4: {}
@@ -652,6 +692,12 @@ snapshots:
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
@@ -676,6 +722,13 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -979,6 +1032,13 @@ snapshots:
ms@2.1.3: {}
multer@2.1.1:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
type-is: 1.6.18
negotiator@0.6.3: {}
nodemon@3.1.14:
@@ -1038,6 +1098,12 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@3.6.0:
dependencies:
picomatch: 2.3.2
@@ -1119,6 +1185,12 @@ snapshots:
statuses@2.0.2: {}
streamsearch@1.1.0: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
@@ -1140,10 +1212,14 @@ snapshots:
media-typer: 0.3.0
mime-types: 2.1.35
typedarray@0.0.6: {}
undefsafe@2.0.5: {}
unpipe@1.0.0: {}
util-deprecate@1.0.2: {}
utils-merge@1.0.1: {}
vary@1.1.2: {}
+1
View File
@@ -17,6 +17,7 @@ app.use(helmet());
app.use(express.json({ limit: '2mb' }));
app.use(cookieParser());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use('/uploads', express.static('uploads'));
app.use('/api', routes);
app.use((req, res) => {
+30
View File
@@ -0,0 +1,30 @@
import Track from '../models/Track.js';
export const adminController = {
async addTrack(req, res, next) {
try {
const { title, artist, album, genre } = req.body;
if (!title || !artist || !album || !genre) {
return res
.status(400)
.json({ success: false, message: 'All fields are required' });
}
const coverUrl = req.file ? `/uploads/covers/${req.file.filename}` : '';
const track = await Track.create({
title,
artist,
album,
genre,
coverUrl,
createdBy: req.userId,
});
return res.json({ success: true, data: track });
} catch (error) {
return next(error);
}
},
};
+18
View File
@@ -0,0 +1,18 @@
import User from '../models/User.js';
export const requireAdmin = async (req, res, next) => {
try {
if (!req.userId) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const user = await User.findById(req.userId).select('role').lean();
if (!user || user.role !== 'admin') {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
return next();
} catch (error) {
return next(error);
}
};
+43
View File
@@ -0,0 +1,43 @@
import mongoose from 'mongoose';
const trackSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
maxlength: 120,
},
artist: {
type: String,
required: true,
trim: true,
maxlength: 120,
},
album: {
type: String,
required: true,
trim: true,
maxlength: 120,
},
genre: {
type: String,
required: true,
trim: true,
maxlength: 60,
},
coverUrl: {
type: String,
trim: true,
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
},
{
timestamps: true,
}
);
export default mongoose.model('Track', trackSchema);
+17
View File
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { adminController } from '../controllers/adminController.js';
import { auth } from '../middlewares/auth.js';
import { requireAdmin } from '../middlewares/roles.js';
import { uploadCover } from '../utils/multer.js';
const router = Router();
router.post(
'/tracks',
auth,
requireAdmin,
uploadCover.single('cover'),
adminController.addTrack
);
export default router;
+2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import authRoutes from './auths.js';
import adminRoutes from './admins.js';
const router = Router();
@@ -14,5 +15,6 @@ router.get('/health', (req, res) => {
});
router.use('/auth', authRoutes);
router.use('/admin', adminRoutes);
export default router;
+2
View File
@@ -1,12 +1,14 @@
import 'dotenv/config';
import app from './app.js';
import { connectDb } from './config/db.js';
import { ensureAdminUser } from './services/adminBootstrap.js';
const port = Number(process.env.PORT) || 3000;
const startServer = async () => {
try {
await connectDb();
await ensureAdminUser();
app.listen(port, () => {
process.stdout.write(`Server listening on port ${port}\n`);
});
+37
View File
@@ -0,0 +1,37 @@
import bcrypt from 'bcryptjs';
import User from '../models/User.js';
const normalize = (value) => value.trim().toLowerCase();
export const ensureAdminUser = async () => {
const username = process.env.ADMIN_USERNAME;
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!username || !email || !password) {
return;
}
const normalizedUsername = normalize(username);
const normalizedEmail = normalize(email);
const existing = await User.findOne({
$or: [{ username: normalizedUsername }, { email: normalizedEmail }],
}).lean();
if (existing) {
if (existing.role !== 'admin') {
await User.updateOne({ _id: existing._id }, { role: 'admin' });
}
return;
}
const passwordHash = await bcrypt.hash(password, 10);
await User.create({
name: 'Admin',
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
role: 'admin',
});
};
+34
View File
@@ -0,0 +1,34 @@
import fs from 'fs';
import path from 'path';
import multer from 'multer';
const coversDir = path.join(process.cwd(), 'uploads', 'covers');
if (!fs.existsSync(coversDir)) {
fs.mkdirSync(coversDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, coversDir);
},
filename: (req, file, cb) => {
const safeName = file.originalname.replace(/\s+/g, '-');
cb(null, `${Date.now()}-${safeName}`);
},
});
const imageFilter = (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('Only JPG, PNG, or WEBP images are allowed'));
}
return cb(null, true);
};
export const uploadCover = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 },
});