import express from "express"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import multer from "multer"; import sharp from "sharp"; import { S3Client, PutObjectCommand, DeleteObjectCommand, } from "@aws-sdk/client-s3"; import pool from "../lib/db.js"; const router = express.Router(); // JWT 설정 const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026"; const JWT_EXPIRES_IN = "30d"; // Multer 설정 (메모리 저장) const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, // 10MB fileFilter: (req, file, cb) => { if (file.mimetype.startsWith("image/")) { cb(null, true); } else { cb(new Error("이미지 파일만 업로드 가능합니다."), false); } }, }); // S3 클라이언트 (RustFS) const s3Client = new S3Client({ endpoint: process.env.RUSTFS_ENDPOINT, region: "us-east-1", credentials: { accessKeyId: process.env.RUSTFS_ACCESS_KEY, secretAccessKey: process.env.RUSTFS_SECRET_KEY, }, forcePathStyle: true, }); const BUCKET = process.env.RUSTFS_BUCKET || "fromis-9"; // 토큰 검증 미들웨어 export const authenticateToken = (req, res, next) => { const authHeader = req.headers["authorization"]; const token = authHeader && authHeader.split(" ")[1]; if (!token) { return res.status(401).json({ error: "인증이 필요합니다." }); } jwt.verify(token, JWT_SECRET, (err, user) => { if (err) { return res.status(403).json({ error: "유효하지 않은 토큰입니다." }); } req.user = user; next(); }); }; // 관리자 로그인 router.post("/login", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res .status(400) .json({ error: "아이디와 비밀번호를 입력해주세요." }); } const [users] = await pool.query( "SELECT * FROM admin_users WHERE username = ?", [username] ); if (users.length === 0) { return res .status(401) .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); } const user = users[0]; const isValidPassword = await bcrypt.compare(password, user.password_hash); if (!isValidPassword) { return res .status(401) .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); } const token = jwt.sign( { id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } ); res.json({ message: "로그인 성공", token, user: { id: user.id, username: user.username }, }); } catch (error) { console.error("로그인 오류:", error); res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." }); } }); // 토큰 검증 엔드포인트 router.get("/verify", authenticateToken, (req, res) => { res.json({ valid: true, user: req.user }); }); // 초기 관리자 계정 생성 router.post("/init", async (req, res) => { try { const [existing] = await pool.query( "SELECT COUNT(*) as count FROM admin_users" ); if (existing[0].count > 0) { return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." }); } const password = "auddnek0403!"; const passwordHash = await bcrypt.hash(password, 10); await pool.query( "INSERT INTO admin_users (username, password_hash) VALUES (?, ?)", ["admin", passwordHash] ); res.json({ message: "관리자 계정이 생성되었습니다." }); } catch (error) { console.error("계정 생성 오류:", error); res.status(500).json({ error: "계정 생성 중 오류가 발생했습니다." }); } }); // ==================== 앨범 관리 API ==================== // 앨범 생성 router.post( "/albums", authenticateToken, upload.single("cover"), async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const data = JSON.parse(req.body.data); const { title, album_type, album_type_short, release_date, folder_name, description, tracks, } = data; // 필수 필드 검증 if (!title || !album_type || !release_date || !folder_name) { return res .status(400) .json({ error: "필수 필드를 모두 입력해주세요." }); } let coverUrl = null; // 커버 이미지 업로드 if (req.file) { // WebP 변환 (원본 크기, 무손실) const processedImage = await sharp(req.file.buffer) .webp({ lossless: true }) .toBuffer(); const coverKey = `album/${folder_name}/cover.webp`; await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: coverKey, Body: processedImage, ContentType: "image/webp", }) ); coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`; } // 앨범 삽입 const [albumResult] = await connection.query( `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_url, description) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ title, album_type, album_type_short || null, release_date, folder_name, coverUrl, description || null, ] ); const albumId = albumResult.insertId; // 트랙 삽입 if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ albumId, track.track_number, track.title, track.duration || null, track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, track.arranger || null, track.lyrics || null, track.music_video_url || null, ] ); } } await connection.commit(); res.json({ message: "앨범이 생성되었습니다.", albumId }); } catch (error) { await connection.rollback(); console.error("앨범 생성 오류:", error); res.status(500).json({ error: "앨범 생성 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // 앨범 수정 router.put( "/albums/:id", authenticateToken, upload.single("cover"), async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const albumId = req.params.id; const data = JSON.parse(req.body.data); const { title, album_type, album_type_short, release_date, folder_name, description, tracks, } = data; // 기존 앨범 조회 const [existingAlbums] = await connection.query( "SELECT * FROM albums WHERE id = ?", [albumId] ); if (existingAlbums.length === 0) { return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } let coverUrl = existingAlbums[0].cover_url; // WebP 변환 (원본 크기, 무손실) if (req.file) { const processedImage = await sharp(req.file.buffer) .webp({ lossless: true }) .toBuffer(); const coverKey = `album/${folder_name}/cover.webp`; await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: coverKey, Body: processedImage, ContentType: "image/webp", }) ); coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`; } // 앨범 업데이트 await connection.query( `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_url = ?, description = ? WHERE id = ?`, [ title, album_type, album_type_short || null, release_date, folder_name, coverUrl, description || null, albumId, ] ); // 기존 트랙 삭제 후 새 트랙 삽입 await connection.query("DELETE FROM tracks WHERE album_id = ?", [ albumId, ]); if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ albumId, track.track_number, track.title, track.duration || null, track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, track.arranger || null, track.lyrics || null, track.music_video_url || null, ] ); } } await connection.commit(); res.json({ message: "앨범이 수정되었습니다." }); } catch (error) { await connection.rollback(); console.error("앨범 수정 오류:", error); res.status(500).json({ error: "앨범 수정 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // 앨범 삭제 router.delete("/albums/:id", authenticateToken, async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const albumId = req.params.id; // 기존 앨범 조회 const [existingAlbums] = await connection.query( "SELECT * FROM albums WHERE id = ?", [albumId] ); if (existingAlbums.length === 0) { return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } const album = existingAlbums[0]; // RustFS에서 커버 이미지 삭제 if (album.cover_url && album.folder_name) { try { await s3Client.send( new DeleteObjectCommand({ Bucket: BUCKET, Key: `album/${album.folder_name}/cover.webp`, }) ); } catch (s3Error) { console.error("S3 삭제 오류:", s3Error); } } // 트랙 삭제 await connection.query("DELETE FROM tracks WHERE album_id = ?", [albumId]); // 앨범 삭제 await connection.query("DELETE FROM albums WHERE id = ?", [albumId]); await connection.commit(); res.json({ message: "앨범이 삭제되었습니다." }); } catch (error) { await connection.rollback(); console.error("앨범 삭제 오류:", error); res.status(500).json({ error: "앨범 삭제 중 오류가 발생했습니다." }); } finally { connection.release(); } }); export default router;