409 lines
11 KiB
JavaScript
409 lines
11 KiB
JavaScript
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;
|