릴리스: v0.1.6 MariaDB 개발 환경 및 저장소 설정 정리

This commit is contained in:
DUCK JIN
2026-03-19 14:42:30 +09:00
committed by zenn
commit 6d2c063425
52 changed files with 9346 additions and 0 deletions

341
backend/data/db.json Normal file
View File

@@ -0,0 +1,341 @@
{
"users": [
{
"id": "nsTJTtyrDHSqfmRu5glSN",
"email": "zenn.message@gmail.com",
"passwordHash": "$2b$10$DxSEaZctF5u8A5rYDQRZOu4rkRNEaCytX0m0raQn6Fwjx.G0h9k8K",
"isAdmin": true,
"createdAt": 1773887700454,
"avatarSrc": "/uploads/avatars/1773889900538-fef80900076ae-스크린샷 2026-03-19 오전 11.38.05.png"
}
],
"games": [
{
"id": "example-game",
"name": "예시 게임",
"createdAt": 1773887395598
},
{
"id": "another-game",
"name": "다른 예시 게임",
"createdAt": 1773887395598
},
{
"id": "stellasora",
"name": "스텔라소라",
"createdAt": 1773887727309,
"thumbnailSrc": "/uploads/games/1773890252955-fbdff9a881edf-스크린샷 2026-03-19 오전 11.23.48(2).png"
}
],
"gameImages": [
{
"id": "img-1",
"gameId": "example-game",
"src": "/uploads/seeds/example1.png",
"label": "샘플 1",
"createdAt": 1773887395598
},
{
"id": "img-2",
"gameId": "example-game",
"src": "/uploads/seeds/example2.png",
"label": "샘플 2",
"createdAt": 1773887395598
},
{
"id": "dVx9t49q3CbKe_dnY9Zit",
"gameId": "stellasora",
"src": "/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp",
"label": "치토세",
"createdAt": 1773887834727
},
{
"id": "Tf3MgX_4Pk4YZszOTfGEQ",
"gameId": "stellasora",
"src": "/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp",
"label": "프리지아",
"createdAt": 1773887843356
},
{
"id": "ceaMtFXf7Jq-83f66T-UM",
"gameId": "stellasora",
"src": "/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp",
"label": "도나",
"createdAt": 1773887854174
},
{
"id": "2odX6CHJOyFroPZ8s57oe",
"gameId": "stellasora",
"src": "/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp",
"label": "후유카",
"createdAt": 1773887863905
},
{
"id": "OfDKYixqM12e57ZAAcGlW",
"gameId": "stellasora",
"src": "/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp",
"label": "라루(크리스마스)",
"createdAt": 1773887879064
},
{
"id": "P1BOwa2sMv_YbAOkhMPVS",
"gameId": "stellasora",
"src": "/uploads/games/1773890202889-54aedfa2cea6e-스크린샷 2026-03-16 오후 6.35.30.png",
"label": "기본이미지?",
"createdAt": 1773890202906
}
],
"tierLists": [
{
"id": "PiAKeKNvjJb68IPYiUv-r",
"authorId": "nsTJTtyrDHSqfmRu5glSN",
"gameId": "stellasora",
"title": "새 티어표1",
"isPublic": false,
"groups": [
{
"id": "gS",
"name": "S",
"itemIds": [
"dVx9t49q3CbKe_dnY9Zit"
]
},
{
"id": "gA",
"name": "A",
"itemIds": [
"ceaMtFXf7Jq-83f66T-UM"
]
},
{
"id": "gB",
"name": "B",
"itemIds": [
"2odX6CHJOyFroPZ8s57oe",
"OfDKYixqM12e57ZAAcGlW"
]
},
{
"id": "gC",
"name": "C",
"itemIds": [
"Tf3MgX_4Pk4YZszOTfGEQ"
]
},
{
"id": "gD",
"name": "D",
"itemIds": []
}
],
"pool": [
{
"id": "dVx9t49q3CbKe_dnY9Zit",
"src": "http://localhost:5179/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp",
"label": "치토세",
"origin": "game"
},
{
"id": "Tf3MgX_4Pk4YZszOTfGEQ",
"src": "http://localhost:5179/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp",
"label": "프리지아",
"origin": "game"
},
{
"id": "ceaMtFXf7Jq-83f66T-UM",
"src": "http://localhost:5179/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp",
"label": "도나",
"origin": "game"
},
{
"id": "2odX6CHJOyFroPZ8s57oe",
"src": "http://localhost:5179/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp",
"label": "후유카",
"origin": "game"
},
{
"id": "OfDKYixqM12e57ZAAcGlW",
"src": "http://localhost:5179/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp",
"label": "라루(크리스마스)",
"origin": "game"
}
],
"createdAt": 1773888446012,
"updatedAt": 1773888446013
},
{
"id": "F1qD3tWgW1aPJkLPjeWWA",
"authorId": "nsTJTtyrDHSqfmRu5glSN",
"gameId": "stellasora",
"title": "새 티어표1",
"isPublic": true,
"groups": [
{
"id": "gS",
"name": "S",
"itemIds": [
"dVx9t49q3CbKe_dnY9Zit"
]
},
{
"id": "gA",
"name": "A",
"itemIds": [
"ceaMtFXf7Jq-83f66T-UM"
]
},
{
"id": "gB",
"name": "B",
"itemIds": [
"2odX6CHJOyFroPZ8s57oe",
"OfDKYixqM12e57ZAAcGlW"
]
},
{
"id": "gC",
"name": "C",
"itemIds": [
"Tf3MgX_4Pk4YZszOTfGEQ"
]
},
{
"id": "gD",
"name": "D",
"itemIds": [
"c-1773888464832-e963464a5f73e8"
]
}
],
"pool": [
{
"id": "dVx9t49q3CbKe_dnY9Zit",
"src": "http://localhost:5179/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp",
"label": "치토세",
"origin": "game"
},
{
"id": "Tf3MgX_4Pk4YZszOTfGEQ",
"src": "http://localhost:5179/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp",
"label": "프리지아",
"origin": "game"
},
{
"id": "ceaMtFXf7Jq-83f66T-UM",
"src": "http://localhost:5179/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp",
"label": "도나",
"origin": "game"
},
{
"id": "2odX6CHJOyFroPZ8s57oe",
"src": "http://localhost:5179/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp",
"label": "후유카",
"origin": "game"
},
{
"id": "OfDKYixqM12e57ZAAcGlW",
"src": "http://localhost:5179/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp",
"label": "라루(크리스마스)",
"origin": "game"
},
{
"id": "c-1773888464832-e963464a5f73e8",
"src": "blob:http://localhost:5174/10e01324-bbc9-403f-bfe4-0dd7e47eace7",
"label": "Chixia-head-xxl.webp",
"origin": "custom"
}
],
"createdAt": 1773888448774,
"updatedAt": 1773890284754,
"description": ""
},
{
"id": "zq8qdUD6q541v5l6X0wAg",
"authorId": "nsTJTtyrDHSqfmRu5glSN",
"gameId": "stellasora",
"title": "스텔라소라 개쩜",
"description": "설명 이렇게 적어봄 이건 개쩌는 티어표임",
"isPublic": true,
"groups": [
{
"id": "gS",
"name": "S",
"itemIds": [
"dVx9t49q3CbKe_dnY9Zit",
"ceaMtFXf7Jq-83f66T-UM"
]
},
{
"id": "gA",
"name": "A",
"itemIds": [
"Tf3MgX_4Pk4YZszOTfGEQ"
]
},
{
"id": "gC",
"name": "C",
"itemIds": [
"OfDKYixqM12e57ZAAcGlW"
]
},
{
"id": "gB",
"name": "B",
"itemIds": [
"2odX6CHJOyFroPZ8s57oe"
]
},
{
"id": "gD",
"name": "쓰레기",
"itemIds": [
"P1BOwa2sMv_YbAOkhMPVS"
]
}
],
"pool": [
{
"id": "dVx9t49q3CbKe_dnY9Zit",
"src": "http://localhost:5179/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp",
"label": "치토세",
"origin": "game"
},
{
"id": "Tf3MgX_4Pk4YZszOTfGEQ",
"src": "http://localhost:5179/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp",
"label": "프리지아",
"origin": "game"
},
{
"id": "ceaMtFXf7Jq-83f66T-UM",
"src": "http://localhost:5179/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp",
"label": "도나",
"origin": "game"
},
{
"id": "2odX6CHJOyFroPZ8s57oe",
"src": "http://localhost:5179/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp",
"label": "후유카",
"origin": "game"
},
{
"id": "OfDKYixqM12e57ZAAcGlW",
"src": "http://localhost:5179/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp",
"label": "라루(크리스마스)",
"origin": "game"
},
{
"id": "P1BOwa2sMv_YbAOkhMPVS",
"src": "http://localhost:5179/uploads/games/1773890202889-54aedfa2cea6e-스크린샷 2026-03-16 오후 6.35.30.png",
"label": "기본이미지?",
"origin": "game"
}
],
"createdAt": 1773890445513,
"updatedAt": 1773890445513
}
],
"customItems": [],
"gameSuggestions": []
}

87
backend/index.js Normal file
View File

@@ -0,0 +1,87 @@
const path = require('path')
const fs = require('fs')
const express = require('express')
const cors = require('cors')
const session = require('express-session')
const FileStoreFactory = require('session-file-store')
const { ensureData } = require('./src/db')
const authRoutes = require('./src/routes/auth')
const gamesRoutes = require('./src/routes/games')
const tierListsRoutes = require('./src/routes/tierlists')
const adminRoutes = require('./src/routes/admin')
const app = express()
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
const SESSION_SECRET = process.env.SESSION_SECRET || 'dev-secret-change-me'
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === 'true'
const SESSION_COOKIE_SAME_SITE = process.env.SESSION_COOKIE_SAME_SITE || 'lax'
const TRUST_PROXY = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 1
const allowedOrigins = (process.env.CORS_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
const FileStore = FileStoreFactory(session)
;['uploads/avatars', 'uploads/games', 'uploads/custom', '.sessions'].forEach((relativePath) => {
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
})
app.set('trust proxy', TRUST_PROXY)
app.use(
cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true)
if (allowedOrigins.includes(origin)) return cb(null, true)
if (/^http:\/\/localhost:517\d$/.test(origin)) return cb(null, true) // 5170~5179
if (/^https?:\/\/127\.0\.0\.1:517\d$/.test(origin)) return cb(null, true)
if (origin === 'http://localhost:5173') return cb(null, true)
cb(new Error('not_allowed_by_cors'))
},
credentials: true,
})
)
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))
app.use(
session({
store: new FileStore({
path: path.join(__dirname, '.sessions'),
ttl: 60 * 60 * 24 * 30,
retries: 0,
}),
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: SESSION_COOKIE_SAME_SITE,
secure: SESSION_COOKIE_SECURE,
maxAge: 1000 * 60 * 60 * 24 * 30,
},
})
)
app.use('/uploads', express.static(path.join(__dirname, 'uploads')))
app.get('/health', (req, res) => res.json({ ok: true }))
app.use(async (req, res, next) => {
try {
await ensureData()
next()
} catch (e) {
res.status(500).json({ error: 'db_init_failed' })
}
})
app.use('/api/auth', authRoutes)
app.use('/api/games', gamesRoutes)
app.use('/api/tierlists', tierListsRoutes)
app.use('/api/admin', adminRoutes)
app.listen(PORT, () => {
console.log(`[backend] listening on http://localhost:${PORT}`)
})

1782
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
backend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "DB_CLIENT=mariadb DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
"start": "DB_CLIENT=mariadb DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
"dev:lowdb": "nodemon --legacy-watch --watch index.js --watch src index.js",
"start:lowdb": "node index.js",
"migrate:lowdb": "DB_CLIENT=mariadb DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-lowdb-to-mariadb.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-session": "^1.19.0",
"lowdb": "^7.0.1",
"multer": "^2.1.1",
"mysql2": "^3.20.0",
"nanoid": "^5.1.7",
"session-file-store": "^1.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

View File

@@ -0,0 +1,103 @@
const fs = require('fs')
const path = require('path')
const {
ensureData,
findUserByEmail,
createUser,
findGameById,
createGame,
updateGameThumbnail,
createGameItem,
createCustomItem,
findTierListById,
saveTierList,
createGameSuggestion,
} = require('../src/db')
async function run() {
const sourcePath = path.join(__dirname, '..', 'data', 'db.json')
const raw = fs.readFileSync(sourcePath, 'utf8')
const data = JSON.parse(raw)
await ensureData()
for (const user of data.users || []) {
const existing = await findUserByEmail(user.email)
if (!existing) {
await createUser({
id: user.id,
email: user.email,
nickname: user.nickname || '',
passwordHash: user.passwordHash,
isAdmin: !!user.isAdmin,
})
}
}
for (const game of data.games || []) {
const existing = await findGameById(game.id)
if (!existing) {
await createGame({ id: game.id, name: game.name })
}
if (game.thumbnailSrc) {
await updateGameThumbnail(game.id, game.thumbnailSrc)
}
}
for (const item of data.gameImages || []) {
try {
await createGameItem({
id: item.id,
gameId: item.gameId,
src: item.src,
label: item.label,
})
} catch (e) {}
}
for (const suggestion of data.gameSuggestions || []) {
try {
await createGameSuggestion({ id: suggestion.id, name: suggestion.name })
} catch (e) {}
}
const seenCustomIds = new Set()
for (const tierList of data.tierLists || []) {
for (const item of tierList.pool || []) {
if (item.origin !== 'custom' || seenCustomIds.has(item.id)) continue
seenCustomIds.add(item.id)
try {
await createCustomItem({
id: item.id,
ownerId: tierList.authorId,
src: item.src,
label: item.label,
})
} catch (e) {}
}
}
for (const tierList of data.tierLists || []) {
const existing = await findTierListById(tierList.id)
if (!existing) {
await saveTierList({
id: tierList.id,
authorId: tierList.authorId,
gameId: tierList.gameId,
title: tierList.title,
description: tierList.description || '',
isPublic: !!tierList.isPublic,
groups: tierList.groups || [],
pool: tierList.pool || [],
})
}
}
console.log('migrate-lowdb-to-mariadb: done')
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

725
backend/src/db.js Normal file
View 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,
}

View 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 }

View 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
View 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

View 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

View 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

View File

@@ -0,0 +1,5 @@
이 폴더는 예시(시드) 이미지가 들어갈 위치입니다.
현재는 샘플 데이터가 `src/db.js``example1.png`, `example2.png`를 참조하도록 되어 있습니다.
원하면 실제 PNG 파일을 넣거나, 이후 프론트에서 관리자 업로드로 대체할 수 있습니다.