릴리스: v1.4.33 가입 검증과 테마 기본값 정리
This commit is contained in:
@@ -575,6 +575,33 @@ async function findUserByEmail(email) {
|
||||
return { ...mapUserRow(row), passwordHash: row.password_hash }
|
||||
}
|
||||
|
||||
async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
const normalized = String(nickname || '').trim()
|
||||
if (!normalized) return null
|
||||
const rows = excludeUserId
|
||||
? await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[normalized, excludeUserId]
|
||||
)
|
||||
: await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
|
||||
LIMIT 1
|
||||
`,
|
||||
[normalized]
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return { ...mapUserRow(row), passwordHash: row.password_hash }
|
||||
}
|
||||
|
||||
async function findUserById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
@@ -2471,6 +2498,7 @@ module.exports = {
|
||||
closePool,
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
|
||||
48
backend/src/lib/user-validation.js
Normal file
48
backend/src/lib/user-validation.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const RESERVED_NICKNAME_KEYWORDS = [
|
||||
'admin',
|
||||
'administrator',
|
||||
'operator',
|
||||
'owner',
|
||||
'master',
|
||||
'staff',
|
||||
'system',
|
||||
'root',
|
||||
'support',
|
||||
'manager',
|
||||
'mod',
|
||||
'moderator',
|
||||
'official',
|
||||
'service',
|
||||
'team',
|
||||
'zenn',
|
||||
'운영자',
|
||||
'관리자',
|
||||
'오너',
|
||||
'마스터',
|
||||
'스태프',
|
||||
'시스템',
|
||||
'루트',
|
||||
'서포트',
|
||||
'매니저',
|
||||
'모더레이터',
|
||||
'공식',
|
||||
]
|
||||
|
||||
function normalizeNickname(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function isReservedNickname(value) {
|
||||
const normalized = normalizeNickname(value)
|
||||
if (!normalized) return false
|
||||
return RESERVED_NICKNAME_KEYWORDS.some((keyword) => normalized.includes(normalizeNickname(keyword)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVED_NICKNAME_KEYWORDS,
|
||||
normalizeNickname,
|
||||
isReservedNickname,
|
||||
}
|
||||
@@ -7,6 +7,8 @@ const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findUserById,
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findTopicById,
|
||||
findTopicItemById,
|
||||
listTopicItems,
|
||||
@@ -52,6 +54,7 @@ const {
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
const { isReservedNickname } = require('../lib/user-validation')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -962,6 +965,18 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
if (isReservedNickname(parsed.data.nickname)) {
|
||||
return res.status(400).json({ error: 'nickname_reserved' })
|
||||
}
|
||||
const duplicateEmail = await findUserByEmail(parsed.data.email)
|
||||
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
}
|
||||
const duplicateNickname = await findUserByNickname(parsed.data.nickname, targetUser.id)
|
||||
if (duplicateNickname) {
|
||||
return res.status(409).json({ error: 'nickname_taken' })
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await adminUpdateUser({
|
||||
id: targetUser.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ const multer = require('multer')
|
||||
const {
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserByNickname,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
@@ -13,11 +14,13 @@ const {
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||
const { isReservedNickname } = require('../lib/user-validation')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
nickname: z.string().trim().min(2).max(40),
|
||||
password: z.string().min(6),
|
||||
})
|
||||
|
||||
@@ -62,13 +65,16 @@ 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 { email, nickname, password } = parsed.data
|
||||
const exists = await findUserByEmail(email)
|
||||
if (exists) return res.status(409).json({ error: 'email_taken' })
|
||||
if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
const nicknameExists = await findUserByNickname(nickname)
|
||||
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const isAdmin = (await countUsers()) === 0
|
||||
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
|
||||
const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin })
|
||||
|
||||
try {
|
||||
await establishSession(req, user)
|
||||
@@ -79,7 +85,10 @@ router.post('/signup', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const parsed = signupSchema.safeParse(req.body)
|
||||
const parsed = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
}).safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { email, password } = parsed.data
|
||||
@@ -121,6 +130,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
||||
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id)
|
||||
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
|
||||
|
||||
const optimized = req.file
|
||||
? await writeOptimizedImage({
|
||||
|
||||
Reference in New Issue
Block a user