릴리스: v0.1.6 MariaDB 개발 환경 및 저장소 설정 정리
This commit is contained in:
725
backend/src/db.js
Normal file
725
backend/src/db.js
Normal file
@@ -0,0 +1,725 @@
|
||||
const path = require('path')
|
||||
const mysql = require('mysql2/promise')
|
||||
const { Low } = require('lowdb')
|
||||
const { JSONFile } = require('lowdb/node')
|
||||
|
||||
const DB_CLIENT = process.env.DB_CLIENT || 'lowdb'
|
||||
|
||||
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306
|
||||
const DB_USER = process.env.DB_USER || 'root'
|
||||
const DB_PASSWORD = process.env.DB_PASSWORD || ''
|
||||
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
|
||||
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
|
||||
|
||||
const LOWDB_PATH = path.join(__dirname, '..', 'data', 'db.json')
|
||||
|
||||
let lowdbPromise = null
|
||||
let poolPromise = null
|
||||
let initPromise = null
|
||||
|
||||
function now() {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function defaultData() {
|
||||
return {
|
||||
users: [],
|
||||
games: [
|
||||
{ id: 'example-game', name: '예시 게임', thumbnailSrc: '', createdAt: now() },
|
||||
{ id: 'another-game', name: '다른 예시 게임', thumbnailSrc: '', createdAt: now() },
|
||||
],
|
||||
gameImages: [
|
||||
{ id: 'img-1', gameId: 'example-game', src: '/uploads/seeds/example1.png', label: '샘플 1', createdAt: now() },
|
||||
{ id: 'img-2', gameId: 'example-game', src: '/uploads/seeds/example2.png', label: '샘플 2', createdAt: now() },
|
||||
],
|
||||
customItems: [],
|
||||
tierLists: [],
|
||||
gameSuggestions: [],
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
if (!value) return fallback
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (e) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function serializeJson(value) {
|
||||
return JSON.stringify(value || [])
|
||||
}
|
||||
|
||||
function mapUserRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
nickname: row.nickname || '',
|
||||
isAdmin: !!row.is_admin,
|
||||
avatarSrc: row.avatar_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
function mapGameRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
function mapGameItemRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
gameId: row.game_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
function mapTierListRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
authorId: row.author_id,
|
||||
gameId: row.game_id,
|
||||
title: row.title,
|
||||
description: row.description || '',
|
||||
isPublic: !!row.is_public,
|
||||
groups: parseJson(row.groups_json, []),
|
||||
pool: parseJson(row.pool_json, []),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
async function getLowdb() {
|
||||
if (lowdbPromise) return lowdbPromise
|
||||
lowdbPromise = (async () => {
|
||||
const adapter = new JSONFile(LOWDB_PATH)
|
||||
const db = new Low(adapter, defaultData())
|
||||
await db.read()
|
||||
db.data ||= defaultData()
|
||||
db.data.users ||= []
|
||||
db.data.games ||= []
|
||||
db.data.gameImages ||= []
|
||||
db.data.customItems ||= []
|
||||
db.data.tierLists ||= []
|
||||
db.data.gameSuggestions ||= []
|
||||
await db.write()
|
||||
return db
|
||||
})()
|
||||
return lowdbPromise
|
||||
}
|
||||
|
||||
async function createPool() {
|
||||
const rootConnection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
port: DB_PORT,
|
||||
user: DB_USER,
|
||||
password: DB_PASSWORD,
|
||||
multipleStatements: true,
|
||||
})
|
||||
|
||||
await rootConnection.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||
)
|
||||
await rootConnection.end()
|
||||
|
||||
return mysql.createPool({
|
||||
host: DB_HOST,
|
||||
port: DB_PORT,
|
||||
user: DB_USER,
|
||||
password: DB_PASSWORD,
|
||||
database: DB_NAME,
|
||||
connectionLimit: DB_CONNECTION_LIMIT,
|
||||
charset: 'utf8mb4',
|
||||
})
|
||||
}
|
||||
|
||||
async function getPool() {
|
||||
if (!poolPromise) {
|
||||
poolPromise = createPool()
|
||||
}
|
||||
return poolPromise
|
||||
}
|
||||
|
||||
async function query(sql, params = []) {
|
||||
const pool = await getPool()
|
||||
const [rows] = await pool.execute(sql, params)
|
||||
return rows
|
||||
}
|
||||
|
||||
async function ensureMariaSchema() {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
nickname VARCHAR(80) NOT NULL DEFAULT '',
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id VARCHAR(120) PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS game_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_game_items_game_id (game_id),
|
||||
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS custom_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
owner_id VARCHAR(64) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_custom_items_owner_id (owner_id),
|
||||
CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS tierlists (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
author_id VARCHAR(64) NOT NULL,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
||||
groups_json LONGTEXT NOT NULL,
|
||||
pool_json LONGTEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_tierlists_author_id (author_id),
|
||||
INDEX idx_tierlists_game_id (game_id),
|
||||
INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at),
|
||||
CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tierlists_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS game_suggestions (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const countRows = await query('SELECT COUNT(*) AS count FROM games')
|
||||
if (Number(countRows[0]?.count || 0) === 0) {
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO games (id, name, thumbnail_src, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?),
|
||||
(?, ?, ?, ?)
|
||||
`,
|
||||
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt]
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO game_items (id, game_id, src, label, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
'img-1',
|
||||
'example-game',
|
||||
'/uploads/seeds/example1.png',
|
||||
'샘플 1',
|
||||
createdAt,
|
||||
'img-2',
|
||||
'example-game',
|
||||
'/uploads/seeds/example2.png',
|
||||
'샘플 2',
|
||||
createdAt,
|
||||
]
|
||||
)
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
async function ensureData() {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
await ensureMariaSchema()
|
||||
return
|
||||
}
|
||||
await getLowdb()
|
||||
}
|
||||
|
||||
async function countUsers() {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query('SELECT COUNT(*) AS count FROM users')
|
||||
return Number(rows[0]?.count || 0)
|
||||
}
|
||||
const db = await getLowdb()
|
||||
return db.data.users.length
|
||||
}
|
||||
|
||||
async function findUserByEmail(email) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return { ...mapUserRow(row), passwordHash: row.password_hash }
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
return db.data.users.find((user) => user.email === email) || null
|
||||
}
|
||||
|
||||
async function findUserById(id) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
const user = db.data.users.find((entry) => entry.id === id)
|
||||
if (!user) return null
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
avatarSrc: user.avatarSrc || '',
|
||||
createdAt: Number(user.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser({ id, email, nickname, passwordHash, isAdmin }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt]
|
||||
)
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
const user = {
|
||||
id,
|
||||
email,
|
||||
nickname: nickname || '',
|
||||
passwordHash,
|
||||
isAdmin: !!isAdmin,
|
||||
avatarSrc: '',
|
||||
createdAt: now(),
|
||||
}
|
||||
db.data.users.push(user)
|
||||
await db.write()
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
if (typeof avatarSrc === 'string') {
|
||||
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
|
||||
} else {
|
||||
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
|
||||
}
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
const user = db.data.users.find((entry) => entry.id === id)
|
||||
if (!user) return null
|
||||
user.nickname = nickname || ''
|
||||
if (typeof avatarSrc === 'string') user.avatarSrc = avatarSrc
|
||||
await db.write()
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
async function listGames() {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games ORDER BY created_at ASC, name ASC')
|
||||
return rows.map(mapGameRow)
|
||||
}
|
||||
const db = await getLowdb()
|
||||
return db.data.games.map((game) => ({
|
||||
id: game.id,
|
||||
name: game.name,
|
||||
thumbnailSrc: game.thumbnailSrc || '',
|
||||
createdAt: Number(game.createdAt),
|
||||
}))
|
||||
}
|
||||
|
||||
async function findGameById(id) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games WHERE id = ? LIMIT 1', [id])
|
||||
return mapGameRow(rows[0])
|
||||
}
|
||||
const db = await getLowdb()
|
||||
const game = db.data.games.find((entry) => entry.id === id)
|
||||
if (!game) return null
|
||||
return {
|
||||
id: game.id,
|
||||
name: game.name,
|
||||
thumbnailSrc: game.thumbnailSrc || '',
|
||||
createdAt: Number(game.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
async function listGameItems(gameId) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query(
|
||||
'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC',
|
||||
[gameId]
|
||||
)
|
||||
return rows.map(mapGameItemRow)
|
||||
}
|
||||
const db = await getLowdb()
|
||||
return db.data.gameImages
|
||||
.filter((item) => item.gameId === gameId)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
gameId: item.gameId,
|
||||
src: item.src,
|
||||
label: item.label,
|
||||
createdAt: Number(item.createdAt),
|
||||
}))
|
||||
}
|
||||
|
||||
async function getGameDetail(gameId) {
|
||||
const game = await findGameById(gameId)
|
||||
if (!game) return null
|
||||
const items = await listGameItems(gameId)
|
||||
return { game, items }
|
||||
}
|
||||
|
||||
async function createGame({ id, name }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
await query('INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?)', [id, name, '', now()])
|
||||
return findGameById(id)
|
||||
}
|
||||
const db = await getLowdb()
|
||||
db.data.games.push({ id, name, thumbnailSrc: '', createdAt: now() })
|
||||
await db.write()
|
||||
return findGameById(id)
|
||||
}
|
||||
|
||||
async function updateGameThumbnail(gameId, thumbnailSrc) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId])
|
||||
return findGameById(gameId)
|
||||
}
|
||||
const db = await getLowdb()
|
||||
const game = db.data.games.find((entry) => entry.id === gameId)
|
||||
if (!game) return null
|
||||
game.thumbnailSrc = thumbnailSrc
|
||||
await db.write()
|
||||
return findGameById(gameId)
|
||||
}
|
||||
|
||||
async function createGameItem({ id, gameId, src, label }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const createdAt = now()
|
||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
gameId,
|
||||
src,
|
||||
label,
|
||||
createdAt,
|
||||
])
|
||||
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
|
||||
return mapGameItemRow(rows[0])
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
const item = { id, gameId, src, label, createdAt: now() }
|
||||
db.data.gameImages.push(item)
|
||||
await db.write()
|
||||
return {
|
||||
id: item.id,
|
||||
gameId: item.gameId,
|
||||
src: item.src,
|
||||
label: item.label,
|
||||
createdAt: Number(item.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
async function createCustomItem({ id, ownerId, src, label }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const createdAt = now()
|
||||
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
ownerId,
|
||||
src,
|
||||
label,
|
||||
createdAt,
|
||||
])
|
||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
const item = { id, ownerId, src, label, origin: 'custom', createdAt: now() }
|
||||
db.data.customItems.push(item)
|
||||
await db.write()
|
||||
return item
|
||||
}
|
||||
|
||||
async function createGameSuggestion({ id, name }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const createdAt = now()
|
||||
await query('INSERT INTO game_suggestions (id, name, created_at) VALUES (?, ?, ?)', [id, name, createdAt])
|
||||
return { id, name, createdAt }
|
||||
}
|
||||
const db = await getLowdb()
|
||||
const suggestion = { id, name, createdAt: now() }
|
||||
db.data.gameSuggestions.push(suggestion)
|
||||
await db.write()
|
||||
return suggestion
|
||||
}
|
||||
|
||||
async function listPublicTierLists(gameId) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const params = []
|
||||
let whereClause = 'WHERE t.is_public = 1'
|
||||
if (gameId) {
|
||||
whereClause += ' AND t.game_id = ?'
|
||||
params.push(gameId)
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.game_id,
|
||||
t.title,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.author_id,
|
||||
u.nickname,
|
||||
u.email
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
${whereClause}
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT 50
|
||||
`,
|
||||
params
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
gameId: row.game_id,
|
||||
title: row.title,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
authorId: row.author_id,
|
||||
authorName: row.nickname || row.email,
|
||||
}))
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
return db.data.tierLists
|
||||
.filter((tierList) => tierList.isPublic && (!gameId || tierList.gameId === gameId))
|
||||
.sort((a, b) => Number(b.updatedAt) - Number(a.updatedAt))
|
||||
.slice(0, 50)
|
||||
.map((tierList) => {
|
||||
const author = db.data.users.find((user) => user.id === tierList.authorId)
|
||||
return {
|
||||
id: tierList.id,
|
||||
gameId: tierList.gameId,
|
||||
title: tierList.title,
|
||||
createdAt: Number(tierList.createdAt),
|
||||
updatedAt: Number(tierList.updatedAt),
|
||||
authorId: tierList.authorId,
|
||||
authorName: author?.nickname || author?.email || '알 수 없음',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function listUserTierLists(userId) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, game_id, title, created_at, updated_at, is_public
|
||||
FROM tierlists
|
||||
WHERE author_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
gameId: row.game_id,
|
||||
title: row.title,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
isPublic: !!row.is_public,
|
||||
}))
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
return db.data.tierLists
|
||||
.filter((tierList) => tierList.authorId === userId)
|
||||
.sort((a, b) => Number(b.updatedAt) - Number(a.updatedAt))
|
||||
.map((tierList) => ({
|
||||
id: tierList.id,
|
||||
gameId: tierList.gameId,
|
||||
title: tierList.title,
|
||||
createdAt: Number(tierList.createdAt),
|
||||
updatedAt: Number(tierList.updatedAt),
|
||||
isPublic: !!tierList.isPublic,
|
||||
}))
|
||||
}
|
||||
|
||||
async function findTierListById(id) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||
FROM tierlists
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
return mapTierListRow(rows[0])
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
const tierList = db.data.tierLists.find((entry) => entry.id === id)
|
||||
if (!tierList) return null
|
||||
return {
|
||||
id: tierList.id,
|
||||
authorId: tierList.authorId,
|
||||
gameId: tierList.gameId,
|
||||
title: tierList.title,
|
||||
description: tierList.description || '',
|
||||
isPublic: !!tierList.isPublic,
|
||||
groups: Array.isArray(tierList.groups) ? tierList.groups : [],
|
||||
pool: Array.isArray(tierList.pool) ? tierList.pool : [],
|
||||
createdAt: Number(tierList.createdAt),
|
||||
updatedAt: Number(tierList.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
const existing = id ? await findTierListById(id) : null
|
||||
|
||||
if (existing) {
|
||||
await query(
|
||||
`
|
||||
UPDATE tierlists
|
||||
SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||
)
|
||||
return findTierListById(existing.id)
|
||||
}
|
||||
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO tierlists (
|
||||
id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||
)
|
||||
return findTierListById(id)
|
||||
}
|
||||
|
||||
const db = await getLowdb()
|
||||
const existing = db.data.tierLists.find((entry) => entry.id === id)
|
||||
if (existing) {
|
||||
existing.title = title
|
||||
existing.description = description || ''
|
||||
existing.isPublic = !!isPublic
|
||||
existing.groups = groups
|
||||
existing.pool = pool
|
||||
existing.updatedAt = now()
|
||||
await db.write()
|
||||
return findTierListById(existing.id)
|
||||
}
|
||||
|
||||
const tierList = {
|
||||
id,
|
||||
authorId,
|
||||
gameId,
|
||||
title,
|
||||
description: description || '',
|
||||
isPublic: !!isPublic,
|
||||
groups,
|
||||
pool,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
db.data.tierLists.push(tierList)
|
||||
await db.write()
|
||||
return findTierListById(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DB_CLIENT,
|
||||
DB_NAME,
|
||||
ensureData,
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
listGames,
|
||||
findGameById,
|
||||
listGameItems,
|
||||
getGameDetail,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
createCustomItem,
|
||||
createGameSuggestion,
|
||||
listPublicTierLists,
|
||||
listUserTierLists,
|
||||
findTierListById,
|
||||
saveTierList,
|
||||
}
|
||||
13
backend/src/middleware/auth.js
Normal file
13
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session || !req.session.userId) return res.status(401).json({ error: 'unauthorized' })
|
||||
next()
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.session || !req.session.userId) return res.status(401).json({ error: 'unauthorized' })
|
||||
if (!req.session.isAdmin) return res.status(403).json({ error: 'forbidden' })
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, requireAdmin }
|
||||
|
||||
64
backend/src/routes/admin.js
Normal file
64
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const path = require('path')
|
||||
const express = require('express')
|
||||
const multer = require('multer')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findGameById,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
return `${Date.now()}-${nanoid()}${safeExt}`
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'games')),
|
||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||
}),
|
||||
limits: { fileSize: 6 * 1024 * 1024 },
|
||||
})
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const exists = await findGameById(parsed.data.id)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
|
||||
res.json({ game })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const updated = await updateGameThumbnail(req.params.gameId, `/uploads/games/${req.file.filename}`)
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/images', requireAdmin, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const schema = z.object({ label: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const item = await createGameItem({
|
||||
id: nanoid(),
|
||||
gameId: game.id,
|
||||
src: `/uploads/games/${req.file.filename}`,
|
||||
label: parsed.data.label,
|
||||
})
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
114
backend/src/routes/auth.js
Normal file
114
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const express = require('express')
|
||||
const path = require('path')
|
||||
const bcrypt = require('bcryptjs')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const multer = require('multer')
|
||||
const {
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
return `${Date.now()}-${nanoid()}${safeExt}`
|
||||
}
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
})
|
||||
|
||||
const profileSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(40),
|
||||
})
|
||||
|
||||
router.post('/signup', async (req, res) => {
|
||||
const parsed = signupSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { email, password } = parsed.data
|
||||
const exists = await findUserByEmail(email)
|
||||
if (exists) return res.status(409).json({ error: 'email_taken' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const isAdmin = (await countUsers()) === 0
|
||||
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
|
||||
|
||||
req.session.userId = user.id
|
||||
req.session.isAdmin = !!user.isAdmin
|
||||
res.json(user)
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const parsed = signupSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { email, password } = parsed.data
|
||||
const user = await findUserByEmail(email)
|
||||
if (!user) return res.status(401).json({ error: 'invalid_credentials' })
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
|
||||
|
||||
req.session.userId = user.id
|
||||
req.session.isAdmin = !!user.isAdmin
|
||||
res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
avatarSrc: user.avatarSrc || '',
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/logout', async (req, res) => {
|
||||
if (!req.session) return res.json({ ok: true })
|
||||
req.session.destroy(() => res.json({ ok: true }))
|
||||
})
|
||||
|
||||
router.get('/me', async (req, res) => {
|
||||
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.json({ user: null })
|
||||
res.json({ user })
|
||||
})
|
||||
|
||||
router.get('/meta', async (req, res) => {
|
||||
res.json({ hasUsers: (await countUsers()) > 0 })
|
||||
})
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')),
|
||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||
}),
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
})
|
||||
|
||||
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {
|
||||
const parsed = profileSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const nextAvatarSrc = req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
|
||||
const updated = await updateUserProfile({
|
||||
id: user.id,
|
||||
nickname: parsed.data.nickname,
|
||||
avatarSrc: nextAvatarSrc,
|
||||
})
|
||||
|
||||
res.json({ user: updated })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
31
backend/src/routes/games.js
Normal file
31
backend/src/routes/games.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const express = require('express')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const { listGames, getGameDetail, createGameSuggestion } = require('../db')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames()
|
||||
res.json({ games })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(req.params.gameId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ game: detail.game, items: detail.items })
|
||||
})
|
||||
|
||||
router.post('/suggest', async (req, res) => {
|
||||
const schema = z.object({ name: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const suggestion = await createGameSuggestion({
|
||||
id: nanoid(),
|
||||
name: parsed.data.name,
|
||||
})
|
||||
res.json({ suggestion })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
151
backend/src/routes/tierlists.js
Normal file
151
backend/src/routes/tierlists.js
Normal file
@@ -0,0 +1,151 @@
|
||||
const express = require('express')
|
||||
const path = require('path')
|
||||
const multer = require('multer')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findTierListById,
|
||||
listPublicTierLists,
|
||||
listUserTierLists,
|
||||
saveTierList,
|
||||
createCustomItem,
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function normalizePoolItem(item) {
|
||||
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
||||
if (item.src.startsWith('/uploads/')) return item
|
||||
|
||||
try {
|
||||
const url = new URL(item.src)
|
||||
if (url.pathname.startsWith('/uploads/')) {
|
||||
return { ...item, src: url.pathname }
|
||||
}
|
||||
} catch (e) {
|
||||
return item
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
function normalizeTierList(tierList) {
|
||||
return {
|
||||
...tierList,
|
||||
pool: Array.isArray(tierList.pool) ? tierList.pool.map(normalizePoolItem) : [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
return `${Date.now()}-${nanoid()}${safeExt}`
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'custom')),
|
||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||
}),
|
||||
limits: { fileSize: 6 * 1024 * 1024 },
|
||||
})
|
||||
|
||||
const tierListUpsertSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
gameId: z.string().min(1),
|
||||
title: z.string().min(1).max(120),
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
isPublic: z.boolean().default(false),
|
||||
groups: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
pool: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
src: z.string().min(1),
|
||||
label: z.string().min(1).max(60),
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
router.get('/public', async (req, res) => {
|
||||
const gameId = req.query.gameId
|
||||
const lists = await listPublicTierLists(gameId)
|
||||
res.json({ tierLists: lists })
|
||||
})
|
||||
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
const lists = await listUserTierLists(req.session.userId)
|
||||
res.json({ tierLists: lists })
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const t = await findTierListById(req.params.id)
|
||||
if (!t) return res.status(404).json({ error: 'not_found' })
|
||||
if (!t.isPublic) {
|
||||
if (!req.session || req.session.userId !== t.authorId) return res.status(403).json({ error: 'forbidden' })
|
||||
}
|
||||
res.json({ tierList: normalizeTierList(t) })
|
||||
})
|
||||
|
||||
router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const item = await createCustomItem({
|
||||
id: nanoid(),
|
||||
ownerId: req.session.userId,
|
||||
src: `/uploads/custom/${req.file.filename}`,
|
||||
label: parsed.data.label,
|
||||
})
|
||||
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const payload = parsed.data
|
||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||
|
||||
let existing = null
|
||||
if (payload.id) existing = await findTierListById(payload.id)
|
||||
|
||||
if (existing) {
|
||||
if (existing.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
||||
const updated = await saveTierList({
|
||||
id: existing.id,
|
||||
authorId: existing.authorId,
|
||||
gameId: existing.gameId,
|
||||
title: payload.title,
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
groups: payload.groups,
|
||||
pool: normalizedPool,
|
||||
})
|
||||
return res.json({ tierList: normalizeTierList(updated) })
|
||||
}
|
||||
|
||||
const created = await saveTierList({
|
||||
id: nanoid(),
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
title: payload.title,
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
groups: payload.groups,
|
||||
pool: normalizedPool,
|
||||
})
|
||||
res.json({ tierList: normalizeTierList(created) })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Reference in New Issue
Block a user