From 88dcaea7af46fdffe21b386e0c5eb6390abf762c Mon Sep 17 00:00:00 2001 From: Bintang Murtifandy <21844@student.stembayo.sch.id> Date: Mon, 25 May 2026 19:45:22 +0700 Subject: [PATCH] feat(admin): add add-music api --- .env.example | 3 ++ package.json | 3 +- pnpm-lock.yaml | 76 ++++++++++++++++++++++++++++++ src/app.js | 1 + src/controllers/adminController.js | 30 ++++++++++++ src/middlewares/roles.js | 18 +++++++ src/models/Track.js | 43 +++++++++++++++++ src/routes/admins.js | 17 +++++++ src/routes/index.js | 2 + src/server.js | 2 + src/services/adminBootstrap.js | 37 +++++++++++++++ src/utils/multer.js | 34 +++++++++++++ 12 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/controllers/adminController.js create mode 100644 src/middlewares/roles.js create mode 100644 src/models/Track.js create mode 100644 src/routes/admins.js create mode 100644 src/services/adminBootstrap.js create mode 100644 src/utils/multer.js diff --git a/.env.example b/.env.example index 38bc917..e350a7d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index ae5927d..3f3e69c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28d033e..ca2d4f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app.js b/src/app.js index a51a0d6..6da6f51 100644 --- a/src/app.js +++ b/src/app.js @@ -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) => { diff --git a/src/controllers/adminController.js b/src/controllers/adminController.js new file mode 100644 index 0000000..d4c1737 --- /dev/null +++ b/src/controllers/adminController.js @@ -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); + } + }, +}; diff --git a/src/middlewares/roles.js b/src/middlewares/roles.js new file mode 100644 index 0000000..6e3f104 --- /dev/null +++ b/src/middlewares/roles.js @@ -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); + } +}; diff --git a/src/models/Track.js b/src/models/Track.js new file mode 100644 index 0000000..121bcc8 --- /dev/null +++ b/src/models/Track.js @@ -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); diff --git a/src/routes/admins.js b/src/routes/admins.js new file mode 100644 index 0000000..a146055 --- /dev/null +++ b/src/routes/admins.js @@ -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; diff --git a/src/routes/index.js b/src/routes/index.js index 16e63d1..c2e5171 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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; diff --git a/src/server.js b/src/server.js index 5f221a3..3f3f3d9 100644 --- a/src/server.js +++ b/src/server.js @@ -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`); }); diff --git a/src/services/adminBootstrap.js b/src/services/adminBootstrap.js new file mode 100644 index 0000000..1d4032a --- /dev/null +++ b/src/services/adminBootstrap.js @@ -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', + }); +}; diff --git a/src/utils/multer.js b/src/utils/multer.js new file mode 100644 index 0000000..2cb2ded --- /dev/null +++ b/src/utils/multer.js @@ -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 }, +});