refactor: API 서버 구조 개선 및 표준화
- 통합 에러 처리 시스템 구축: * utils/errorHandler.js: ApiError 클래스 및 에러 미들웨어 * 데이터베이스, 유효성 검사, 권한 에러 표준화 * 비동기 함수 래퍼 (asyncHandler) 추가 - 응답 포맷터 시스템 구축: * utils/responseFormatter.js: 일관된 API 응답 형식 * 성공, 페이지네이션, 인증, 파일업로드 등 전용 포맷터 * Express 응답 확장 미들웨어 - 유효성 검사 시스템 구축: * utils/validator.js: 스키마 기반 유효성 검사 * 필수 필드, 타입, 길이, 형식 검사 함수들 * 일반적인 스키마 정의 (사용자, 프로젝트, 작업보고서 등) - 코드 정리 및 표준화: * 삭제된 테이블 참조 제거 (work_report_audit_log 등) * 대문자 테이블명을 소문자로 통일 (Users -> users) * authController.js에 새로운 유틸리티 적용 예시 - 미들웨어 통합: * index.js에 에러 핸들러 및 응답 포맷터 적용 * 헬스체크 엔드포인트 개선
This commit is contained in:
@@ -2,38 +2,32 @@ const { getDb } = require('../dbPool');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const authService = require('../services/auth.service');
|
const authService = require('../services/auth.service');
|
||||||
|
const { ApiError, asyncHandler } = require('../utils/errorHandler');
|
||||||
|
const { validateSchema, schemas } = require('../utils/validator');
|
||||||
|
|
||||||
const login = async (req, res) => {
|
const login = asyncHandler(async (req, res) => {
|
||||||
try {
|
const { username, password } = req.body;
|
||||||
const { username, password } = req.body;
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const userAgent = req.headers['user-agent'];
|
||||||
const userAgent = req.headers['user-agent'];
|
|
||||||
|
|
||||||
if (!username || !password) {
|
// 유효성 검사
|
||||||
return res.status(400).json({ error: '사용자명과 비밀번호를 입력해주세요.' });
|
if (!username || !password) {
|
||||||
}
|
throw new ApiError('사용자명과 비밀번호를 입력해주세요.', 400);
|
||||||
|
|
||||||
const result = await authService.loginService(username, password, ipAddress, userAgent);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return res.status(result.status || 400).json({ error: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그인 성공 후, 모든 권한을 그룹장 대시보드로 통일
|
|
||||||
const user = result.data.user;
|
|
||||||
let redirectUrl = '/pages/dashboard/group-leader.html'; // 모든 사용자를 그룹장 대시보드로 리다이렉트
|
|
||||||
|
|
||||||
// 최종 응답에 redirectUrl을 포함하여 전달
|
|
||||||
res.json({
|
|
||||||
...result.data,
|
|
||||||
redirectUrl: redirectUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login controller error:', error);
|
|
||||||
res.status(500).json({ error: error.message || '서버 오류가 발생했습니다.' });
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
const result = await authService.loginService(username, password, ipAddress, userAgent);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ApiError(result.error, result.status || 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 성공 후, 모든 권한을 그룹장 대시보드로 통일
|
||||||
|
const user = result.data.user;
|
||||||
|
const redirectUrl = '/pages/dashboard/group-leader.html'; // 모든 사용자를 그룹장 대시보드로 리다이렉트
|
||||||
|
|
||||||
|
// 새로운 응답 포맷터 사용
|
||||||
|
res.auth(user, result.data.token, redirectUrl, '로그인 성공');
|
||||||
|
});
|
||||||
|
|
||||||
// ✅ 사용자 등록 기능 추가
|
// ✅ 사용자 등록 기능 추가
|
||||||
exports.register = async (req, res) => {
|
exports.register = async (req, res) => {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ const cors = require('cors');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
|
// 새로운 유틸리티들 import
|
||||||
|
const { errorMiddleware } = require('./utils/errorHandler');
|
||||||
|
const { responseMiddleware } = require('./utils/responseFormatter');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// 헬스체크와 개발용 엔드포인트는 CORS 이후에 등록
|
// 헬스체크와 개발용 엔드포인트는 CORS 이후에 등록
|
||||||
@@ -31,6 +36,9 @@ app.use(helmet({
|
|||||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
|
// ✅ 응답 포맷터 미들웨어 적용
|
||||||
|
app.use(responseMiddleware);
|
||||||
|
|
||||||
//개발용 CORS 설정 (수정됨)
|
//개발용 CORS 설정 (수정됨)
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: function (origin, callback) {
|
origin: function (origin, callback) {
|
||||||
@@ -92,13 +100,10 @@ app.get('/api/ping', (req, res) => {
|
|||||||
// ✅ 서버 상태 엔드포인트
|
// ✅ 서버 상태 엔드포인트
|
||||||
app.get('/api/status', (req, res) => {
|
app.get('/api/status', (req, res) => {
|
||||||
console.log('📊 Status 요청 받음!');
|
console.log('📊 Status 요청 받음!');
|
||||||
res.status(200).json({
|
res.health('running', {
|
||||||
status: 'running',
|
|
||||||
service: 'Hyungi API',
|
service: 'Hyungi API',
|
||||||
version: '2.1.0',
|
version: '2.1.0',
|
||||||
environment: process.env.NODE_ENV || 'development',
|
environment: process.env.NODE_ENV || 'development'
|
||||||
uptime: process.uptime(),
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -317,6 +322,8 @@ app.use('/api/tools', toolsRoute);
|
|||||||
// 📤 파일 업로드
|
// 📤 파일 업로드
|
||||||
app.use('/api', uploadBgRoutes);
|
app.use('/api', uploadBgRoutes);
|
||||||
|
|
||||||
|
// ===== 🚨 에러 핸들러 (모든 라우트 뒤에 위치) =====
|
||||||
|
app.use(errorMiddleware);
|
||||||
|
|
||||||
// ===== 🔍 API 정보 엔드포인트 =====
|
// ===== 🔍 API 정보 엔드포인트 =====
|
||||||
app.get('/api', (req, res) => {
|
app.get('/api', (req, res) => {
|
||||||
|
|||||||
@@ -268,28 +268,8 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => {
|
|||||||
// 개별 항목 삭제
|
// 개별 항목 삭제
|
||||||
const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [entry_id]);
|
const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [entry_id]);
|
||||||
|
|
||||||
// 감사 로그
|
// 감사 로그 (삭제된 테이블이므로 콘솔 로그로 대체)
|
||||||
try {
|
console.log(`[삭제 로그] 작업자: ${entry.worker_name}, 프로젝트: ${entry.project_name}, 작업시간: ${entry.work_hours}시간, 삭제자: ${deleted_by}`);
|
||||||
await conn.query(
|
|
||||||
`INSERT INTO work_report_audit_log
|
|
||||||
(action, report_id, old_values, changed_by, change_reason, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NOW())`,
|
|
||||||
[
|
|
||||||
'DELETE_SINGLE',
|
|
||||||
entry_id,
|
|
||||||
JSON.stringify({
|
|
||||||
worker_name: entry.worker_name,
|
|
||||||
project_name: entry.project_name,
|
|
||||||
work_hours: entry.work_hours,
|
|
||||||
report_date: entry.report_date
|
|
||||||
}),
|
|
||||||
deleted_by,
|
|
||||||
`개별 항목 삭제`
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} catch (auditErr) {
|
|
||||||
console.warn('감사 로그 추가 실패:', auditErr.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
callback(null, {
|
callback(null, {
|
||||||
@@ -968,17 +948,9 @@ const removeReportById = async (reportId, deletedByUserId) => {
|
|||||||
// 실제 삭제 작업
|
// 실제 삭제 작업
|
||||||
const [result] = await conn.query('DELETE FROM daily_work_reports WHERE report_id = ?', [reportId]);
|
const [result] = await conn.query('DELETE FROM daily_work_reports WHERE report_id = ?', [reportId]);
|
||||||
|
|
||||||
// 감사 로그 추가 (삭제된 항목이 있고, 삭제자가 명시된 경우)
|
// 감사 로그 (삭제된 테이블이므로 콘솔 로그로 대체)
|
||||||
if (reportInfo.length > 0 && deletedByUserId) {
|
if (reportInfo.length > 0 && deletedByUserId) {
|
||||||
try {
|
console.log(`[삭제 로그] 보고서 ID: ${reportId}, 삭제자: ${deletedByUserId}, 사유: Manual deletion by user`);
|
||||||
await conn.query(
|
|
||||||
`INSERT INTO work_report_audit_log (action, report_id, old_values, changed_by, change_reason) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
['DELETE', reportId, JSON.stringify(reportInfo[0]), deletedByUserId, 'Manual deletion by user']
|
|
||||||
);
|
|
||||||
} catch (auditErr) {
|
|
||||||
console.warn('감사 로그 추가 실패:', auditErr.message);
|
|
||||||
// 감사 로그 실패가 전체 트랜잭션을 롤백시키지는 않음
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
|
|||||||
@@ -786,7 +786,7 @@ router.delete('/users/:id', verifyToken, async (req, res) => {
|
|||||||
|
|
||||||
// 사용자 존재 확인
|
// 사용자 존재 확인
|
||||||
const [existing] = await connection.execute(
|
const [existing] = await connection.execute(
|
||||||
'SELECT username FROM Users WHERE user_id = ?',
|
'SELECT username FROM users WHERE user_id = ?',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -799,12 +799,12 @@ router.delete('/users/:id', verifyToken, async (req, res) => {
|
|||||||
|
|
||||||
// 소프트 삭제 (실제로는 비활성화)
|
// 소프트 삭제 (실제로는 비활성화)
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
'UPDATE Users SET is_active = FALSE, updated_at = NOW() WHERE user_id = ?',
|
'UPDATE users SET is_active = FALSE, updated_at = NOW() WHERE user_id = ?',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 또는 하드 삭제 (실제로 삭제)
|
// 또는 하드 삭제 (실제로 삭제)
|
||||||
// await connection.execute('DELETE FROM Users WHERE user_id = ?', [userId]);
|
// await connection.execute('DELETE FROM users WHERE user_id = ?', [userId]);
|
||||||
|
|
||||||
console.log(`[사용자 삭제] 대상: ${existing[0].username} - 삭제자: ${req.user.username}`);
|
console.log(`[사용자 삭제] 대상: ${existing[0].username} - 삭제자: ${req.user.username}`);
|
||||||
|
|
||||||
|
|||||||
143
api.hyungi.net/utils/errorHandler.js
Normal file
143
api.hyungi.net/utils/errorHandler.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// utils/errorHandler.js - 통합 에러 처리 유틸리티
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표준화된 에러 응답 생성
|
||||||
|
*/
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(message, statusCode = 500, errorCode = null) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
this.timestamp = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 응답 포맷터
|
||||||
|
*/
|
||||||
|
const formatErrorResponse = (error, req = null) => {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
error: error.message || '알 수 없는 오류가 발생했습니다.',
|
||||||
|
timestamp: error.timestamp || new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 환경에서만 상세 정보 포함
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
response.stack = error.stack;
|
||||||
|
response.errorCode = error.errorCode;
|
||||||
|
if (req) {
|
||||||
|
response.requestInfo = {
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
ip: req.ip
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 에러 처리
|
||||||
|
*/
|
||||||
|
const handleDatabaseError = (error, operation = 'database operation') => {
|
||||||
|
console.error(`[DB Error] ${operation}:`, error);
|
||||||
|
|
||||||
|
// 일반적인 DB 에러 코드 매핑
|
||||||
|
const errorMappings = {
|
||||||
|
'ER_DUP_ENTRY': { message: '중복된 데이터입니다.', statusCode: 409 },
|
||||||
|
'ER_NO_REFERENCED_ROW_2': { message: '참조된 데이터가 존재하지 않습니다.', statusCode: 400 },
|
||||||
|
'ER_ROW_IS_REFERENCED_2': { message: '다른 데이터에서 참조되고 있어 삭제할 수 없습니다.', statusCode: 409 },
|
||||||
|
'ER_BAD_FIELD_ERROR': { message: '잘못된 필드명입니다.', statusCode: 400 },
|
||||||
|
'ER_NO_SUCH_TABLE': { message: '테이블이 존재하지 않습니다.', statusCode: 500 },
|
||||||
|
'ECONNREFUSED': { message: '데이터베이스 연결에 실패했습니다.', statusCode: 503 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapping = errorMappings[error.code] || errorMappings[error.errno];
|
||||||
|
if (mapping) {
|
||||||
|
throw new ApiError(mapping.message, mapping.statusCode, error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 에러
|
||||||
|
throw new ApiError(`${operation} 중 오류가 발생했습니다.`, 500, error.code);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유효성 검사 에러 처리
|
||||||
|
*/
|
||||||
|
const handleValidationError = (field, value, rule) => {
|
||||||
|
const message = `${field} 필드가 유효하지 않습니다. (값: ${value}, 규칙: ${rule})`;
|
||||||
|
throw new ApiError(message, 400, 'VALIDATION_ERROR');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 에러 처리
|
||||||
|
*/
|
||||||
|
const handleAuthorizationError = (requiredLevel, userLevel) => {
|
||||||
|
const message = `접근 권한이 부족합니다. (필요: ${requiredLevel}, 현재: ${userLevel})`;
|
||||||
|
throw new ApiError(message, 403, 'AUTHORIZATION_ERROR');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 없음 에러 처리
|
||||||
|
*/
|
||||||
|
const handleNotFoundError = (resource, identifier = null) => {
|
||||||
|
const message = identifier
|
||||||
|
? `${resource}(${identifier})을(를) 찾을 수 없습니다.`
|
||||||
|
: `${resource}을(를) 찾을 수 없습니다.`;
|
||||||
|
throw new ApiError(message, 404, 'NOT_FOUND');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express 에러 핸들러 미들웨어
|
||||||
|
*/
|
||||||
|
const errorMiddleware = (error, req, res, next) => {
|
||||||
|
// ApiError가 아닌 경우 변환
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
error = new ApiError(error.message || '서버 내부 오류', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = formatErrorResponse(error, req);
|
||||||
|
|
||||||
|
// 로깅
|
||||||
|
if (error.statusCode >= 500) {
|
||||||
|
console.error('[Server Error]', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
url: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
user: req.user?.username || 'anonymous'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('[Client Error]', {
|
||||||
|
message: error.message,
|
||||||
|
url: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
user: req.user?.username || 'anonymous'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(error.statusCode).json(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비동기 함수 래퍼 (에러 자동 처리)
|
||||||
|
*/
|
||||||
|
const asyncHandler = (fn) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ApiError,
|
||||||
|
formatErrorResponse,
|
||||||
|
handleDatabaseError,
|
||||||
|
handleValidationError,
|
||||||
|
handleAuthorizationError,
|
||||||
|
handleNotFoundError,
|
||||||
|
errorMiddleware,
|
||||||
|
asyncHandler
|
||||||
|
};
|
||||||
188
api.hyungi.net/utils/responseFormatter.js
Normal file
188
api.hyungi.net/utils/responseFormatter.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// utils/responseFormatter.js - 통합 응답 포맷터
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 포맷터
|
||||||
|
*/
|
||||||
|
const successResponse = (data = null, message = '요청이 성공적으로 처리되었습니다.', meta = null) => {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data !== null) {
|
||||||
|
response.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
response.meta = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지네이션 응답 포맷터
|
||||||
|
*/
|
||||||
|
const paginatedResponse = (data, totalCount, page = 1, limit = 10, message = '데이터 조회 성공') => {
|
||||||
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
|
|
||||||
|
return successResponse(data, message, {
|
||||||
|
pagination: {
|
||||||
|
currentPage: parseInt(page),
|
||||||
|
totalPages,
|
||||||
|
totalCount: parseInt(totalCount),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPrevPage: page > 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스트 응답 포맷터
|
||||||
|
*/
|
||||||
|
const listResponse = (items, message = '목록 조회 성공') => {
|
||||||
|
return successResponse(items, message, {
|
||||||
|
count: items.length
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 응답 포맷터
|
||||||
|
*/
|
||||||
|
const createdResponse = (data, message = '데이터가 성공적으로 생성되었습니다.') => {
|
||||||
|
return successResponse(data, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 응답 포맷터
|
||||||
|
*/
|
||||||
|
const updatedResponse = (data = null, message = '데이터가 성공적으로 업데이트되었습니다.') => {
|
||||||
|
return successResponse(data, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 삭제 응답 포맷터
|
||||||
|
*/
|
||||||
|
const deletedResponse = (message = '데이터가 성공적으로 삭제되었습니다.') => {
|
||||||
|
return successResponse(null, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 응답 포맷터
|
||||||
|
*/
|
||||||
|
const statsResponse = (stats, period = null, message = '통계 조회 성공') => {
|
||||||
|
const meta = {};
|
||||||
|
if (period) {
|
||||||
|
meta.period = period;
|
||||||
|
}
|
||||||
|
meta.generatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
return successResponse(stats, message, meta);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 응답 포맷터
|
||||||
|
*/
|
||||||
|
const authResponse = (user, token, redirectUrl = null, message = '로그인 성공') => {
|
||||||
|
const data = {
|
||||||
|
user,
|
||||||
|
token
|
||||||
|
};
|
||||||
|
|
||||||
|
if (redirectUrl) {
|
||||||
|
data.redirectUrl = redirectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return successResponse(data, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 업로드 응답 포맷터
|
||||||
|
*/
|
||||||
|
const uploadResponse = (fileInfo, message = '파일 업로드 성공') => {
|
||||||
|
return successResponse({
|
||||||
|
filename: fileInfo.filename,
|
||||||
|
originalName: fileInfo.originalname,
|
||||||
|
size: fileInfo.size,
|
||||||
|
mimetype: fileInfo.mimetype,
|
||||||
|
path: fileInfo.path,
|
||||||
|
uploadedAt: new Date().toISOString()
|
||||||
|
}, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 헬스체크 응답 포맷터
|
||||||
|
*/
|
||||||
|
const healthResponse = (status = 'healthy', services = {}) => {
|
||||||
|
return successResponse({
|
||||||
|
status,
|
||||||
|
services,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
memory: process.memoryUsage(),
|
||||||
|
version: process.version
|
||||||
|
}, `서버 상태: ${status}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express 응답 확장 미들웨어
|
||||||
|
*/
|
||||||
|
const responseMiddleware = (req, res, next) => {
|
||||||
|
// 성공 응답 헬퍼들을 res 객체에 추가
|
||||||
|
res.success = (data, message, meta) => {
|
||||||
|
return res.json(successResponse(data, message, meta));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.paginated = (data, totalCount, page, limit, message) => {
|
||||||
|
return res.json(paginatedResponse(data, totalCount, page, limit, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.list = (items, message) => {
|
||||||
|
return res.json(listResponse(items, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.created = (data, message) => {
|
||||||
|
return res.status(201).json(createdResponse(data, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.updated = (data, message) => {
|
||||||
|
return res.json(updatedResponse(data, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.deleted = (message) => {
|
||||||
|
return res.json(deletedResponse(message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.stats = (stats, period, message) => {
|
||||||
|
return res.json(statsResponse(stats, period, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.auth = (user, token, redirectUrl, message) => {
|
||||||
|
return res.json(authResponse(user, token, redirectUrl, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.upload = (fileInfo, message) => {
|
||||||
|
return res.json(uploadResponse(fileInfo, message));
|
||||||
|
};
|
||||||
|
|
||||||
|
res.health = (status, services) => {
|
||||||
|
return res.json(healthResponse(status, services));
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
successResponse,
|
||||||
|
paginatedResponse,
|
||||||
|
listResponse,
|
||||||
|
createdResponse,
|
||||||
|
updatedResponse,
|
||||||
|
deletedResponse,
|
||||||
|
statsResponse,
|
||||||
|
authResponse,
|
||||||
|
uploadResponse,
|
||||||
|
healthResponse,
|
||||||
|
responseMiddleware
|
||||||
|
};
|
||||||
307
api.hyungi.net/utils/validator.js
Normal file
307
api.hyungi.net/utils/validator.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
// utils/validator.js - 유효성 검사 유틸리티
|
||||||
|
|
||||||
|
const { handleValidationError } = require('./errorHandler');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 필드 검사
|
||||||
|
*/
|
||||||
|
const required = (value, fieldName) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
handleValidationError(fieldName, value, 'required');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자열 길이 검사
|
||||||
|
*/
|
||||||
|
const stringLength = (value, fieldName, min = 0, max = Infinity) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
handleValidationError(fieldName, value, 'string type');
|
||||||
|
}
|
||||||
|
if (value.length < min || value.length > max) {
|
||||||
|
handleValidationError(fieldName, value, `length between ${min} and ${max}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 범위 검사
|
||||||
|
*/
|
||||||
|
const numberRange = (value, fieldName, min = -Infinity, max = Infinity) => {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
handleValidationError(fieldName, value, 'number type');
|
||||||
|
}
|
||||||
|
if (num < min || num > max) {
|
||||||
|
handleValidationError(fieldName, value, `number between ${min} and ${max}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정수 검사
|
||||||
|
*/
|
||||||
|
const integer = (value, fieldName) => {
|
||||||
|
const num = parseInt(value);
|
||||||
|
if (isNaN(num) || num.toString() !== value.toString()) {
|
||||||
|
handleValidationError(fieldName, value, 'integer');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 형식 검사
|
||||||
|
*/
|
||||||
|
const email = (value, fieldName) => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(value)) {
|
||||||
|
handleValidationError(fieldName, value, 'valid email format');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 형식 검사 (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
const dateFormat = (value, fieldName) => {
|
||||||
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (!dateRegex.test(value)) {
|
||||||
|
handleValidationError(fieldName, value, 'YYYY-MM-DD format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
handleValidationError(fieldName, value, 'valid date');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간 형식 검사 (HH:MM)
|
||||||
|
*/
|
||||||
|
const timeFormat = (value, fieldName) => {
|
||||||
|
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||||
|
if (!timeRegex.test(value)) {
|
||||||
|
handleValidationError(fieldName, value, 'HH:MM format');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 열거형 값 검사
|
||||||
|
*/
|
||||||
|
const enumValue = (value, fieldName, allowedValues) => {
|
||||||
|
if (!allowedValues.includes(value)) {
|
||||||
|
handleValidationError(fieldName, value, `one of: ${allowedValues.join(', ')}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배열 검사
|
||||||
|
*/
|
||||||
|
const arrayType = (value, fieldName, minLength = 0, maxLength = Infinity) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
handleValidationError(fieldName, value, 'array type');
|
||||||
|
}
|
||||||
|
if (value.length < minLength || value.length > maxLength) {
|
||||||
|
handleValidationError(fieldName, value, `array length between ${minLength} and ${maxLength}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 객체 검사
|
||||||
|
*/
|
||||||
|
const objectType = (value, fieldName) => {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
handleValidationError(fieldName, value, 'object type');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 강도 검사
|
||||||
|
*/
|
||||||
|
const passwordStrength = (value, fieldName) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
handleValidationError(fieldName, value, 'string type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const minLength = 8;
|
||||||
|
const hasUpperCase = /[A-Z]/.test(value);
|
||||||
|
const hasLowerCase = /[a-z]/.test(value);
|
||||||
|
const hasNumbers = /\d/.test(value);
|
||||||
|
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(value);
|
||||||
|
|
||||||
|
if (value.length < minLength) {
|
||||||
|
handleValidationError(fieldName, value, `minimum ${minLength} characters`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strengthChecks = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar];
|
||||||
|
const passedChecks = strengthChecks.filter(Boolean).length;
|
||||||
|
|
||||||
|
if (passedChecks < 3) {
|
||||||
|
handleValidationError(fieldName, value, 'at least 3 of: uppercase, lowercase, numbers, special characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스키마 기반 유효성 검사
|
||||||
|
*/
|
||||||
|
const validateSchema = (data, schema) => {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const [field, rules] of Object.entries(schema)) {
|
||||||
|
const value = data[field];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 필수 필드 검사
|
||||||
|
if (rules.required) {
|
||||||
|
required(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 값이 없고 필수가 아니면 다른 검사 스킵
|
||||||
|
if ((value === undefined || value === null || value === '') && !rules.required) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입별 검사
|
||||||
|
if (rules.type === 'string' && rules.minLength !== undefined && rules.maxLength !== undefined) {
|
||||||
|
stringLength(value, field, rules.minLength, rules.maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'number' && rules.min !== undefined && rules.max !== undefined) {
|
||||||
|
numberRange(value, field, rules.min, rules.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'integer') {
|
||||||
|
integer(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'email') {
|
||||||
|
email(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'date') {
|
||||||
|
dateFormat(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'time') {
|
||||||
|
timeFormat(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'array') {
|
||||||
|
arrayType(value, field, rules.minLength, rules.maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'object') {
|
||||||
|
objectType(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.enum) {
|
||||||
|
enumValue(value, field, rules.enum);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.password) {
|
||||||
|
passwordStrength(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 커스텀 검증 함수
|
||||||
|
if (rules.custom && typeof rules.custom === 'function') {
|
||||||
|
rules.custom(value, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const errorMessage = errors.map(e => `${e.field}: ${e.message}`).join(', ');
|
||||||
|
handleValidationError('validation', 'multiple fields', errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일반적인 스키마 정의들
|
||||||
|
*/
|
||||||
|
const schemas = {
|
||||||
|
// 사용자 생성
|
||||||
|
createUser: {
|
||||||
|
username: { required: true, type: 'string', minLength: 3, maxLength: 50 },
|
||||||
|
password: { required: true, password: true },
|
||||||
|
name: { required: true, type: 'string', minLength: 2, maxLength: 100 },
|
||||||
|
access_level: { required: true, enum: ['user', 'admin', 'system'] },
|
||||||
|
worker_id: { type: 'integer' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 사용자 업데이트
|
||||||
|
updateUser: {
|
||||||
|
name: { type: 'string', minLength: 2, maxLength: 100 },
|
||||||
|
access_level: { enum: ['user', 'admin', 'system'] },
|
||||||
|
worker_id: { type: 'integer' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 비밀번호 변경
|
||||||
|
changePassword: {
|
||||||
|
currentPassword: { required: true, type: 'string' },
|
||||||
|
newPassword: { required: true, password: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 일일 작업 보고서 생성
|
||||||
|
createDailyWorkReport: {
|
||||||
|
report_date: { required: true, type: 'date' },
|
||||||
|
worker_id: { required: true, type: 'integer' },
|
||||||
|
project_id: { required: true, type: 'integer' },
|
||||||
|
work_type_id: { required: true, type: 'integer' },
|
||||||
|
work_hours: { required: true, type: 'number', min: 0.1, max: 24 },
|
||||||
|
work_status_id: { type: 'integer' },
|
||||||
|
error_type_id: { type: 'integer' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 프로젝트 생성
|
||||||
|
createProject: {
|
||||||
|
project_name: { required: true, type: 'string', minLength: 2, maxLength: 200 },
|
||||||
|
description: { type: 'string', maxLength: 1000 },
|
||||||
|
start_date: { type: 'date' },
|
||||||
|
end_date: { type: 'date' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 작업자 생성
|
||||||
|
createWorker: {
|
||||||
|
worker_name: { required: true, type: 'string', minLength: 2, maxLength: 100 },
|
||||||
|
position: { type: 'string', maxLength: 100 },
|
||||||
|
department: { type: 'string', maxLength: 100 },
|
||||||
|
phone: { type: 'string', maxLength: 20 },
|
||||||
|
email: { type: 'email' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// 개별 검증 함수들
|
||||||
|
required,
|
||||||
|
stringLength,
|
||||||
|
numberRange,
|
||||||
|
integer,
|
||||||
|
email,
|
||||||
|
dateFormat,
|
||||||
|
timeFormat,
|
||||||
|
enumValue,
|
||||||
|
arrayType,
|
||||||
|
objectType,
|
||||||
|
passwordStrength,
|
||||||
|
|
||||||
|
// 스키마 검증
|
||||||
|
validateSchema,
|
||||||
|
schemas
|
||||||
|
};
|
||||||
@@ -4,96 +4,99 @@ services:
|
|||||||
# MariaDB 데이터베이스
|
# MariaDB 데이터베이스
|
||||||
db:
|
db:
|
||||||
image: mariadb:10.9
|
image: mariadb:10.9
|
||||||
container_name: fb_db
|
container_name: tkfb_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_ROOT_PASSWORD=hyungi_root_password_2025
|
- MYSQL_ROOT_PASSWORD=tkfb2024!
|
||||||
- MYSQL_DATABASE=hyungi
|
- MYSQL_DATABASE=hyungi
|
||||||
- MYSQL_USER=hyungi
|
- MYSQL_USER=hyungi_user
|
||||||
- MYSQL_PASSWORD=hyungi_password_2025
|
- MYSQL_PASSWORD=hyungi2024!
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/mysql
|
- db_data:/var/lib/mysql
|
||||||
- ./api.hyungi.net/migrations:/docker-entrypoint-initdb.d
|
- ./api.hyungi.net/migrations:/docker-entrypoint-initdb.d
|
||||||
ports:
|
ports:
|
||||||
- "20306:3306"
|
- "20306:3306"
|
||||||
networks:
|
|
||||||
- fb_network
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
timeout: 20s
|
timeout: 20s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
# API 서버 (Node.js)
|
# Node.js API 서버
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./api.hyungi.net
|
context: ./api.hyungi.net
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: fb_api
|
container_name: tkfb_api
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "20005:20005"
|
- "20005:3005"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- PORT=3005
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_USER=hyungi_user
|
||||||
|
- DB_PASSWORD=hyungi2024!
|
||||||
- DB_NAME=hyungi
|
- DB_NAME=hyungi
|
||||||
- DB_USER=hyungi
|
- DB_ROOT_PASSWORD=tkfb2024!
|
||||||
- DB_PASSWORD=hyungi_password_2025
|
- JWT_SECRET=tkfb_jwt_secret_2024_hyungi_secure_key
|
||||||
- DB_ROOT_PASSWORD=hyungi_root_password_2025
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./api.hyungi.net/public/img:/usr/src/app/public/img:ro
|
- ./api.hyungi.net/public/img:/usr/src/app/public/img:ro
|
||||||
- ./api.hyungi.net/uploads:/usr/src/app/uploads
|
- ./api.hyungi.net/uploads:/usr/src/app/uploads
|
||||||
- ./api.hyungi.net/logs:/usr/src/app/logs
|
- ./api.hyungi.net/logs:/usr/src/app/logs
|
||||||
networks:
|
- ./api.hyungi.net/routes:/usr/src/app/routes
|
||||||
- fb_network
|
- ./api.hyungi.net/controllers:/usr/src/app/controllers
|
||||||
|
- ./api.hyungi.net/models:/usr/src/app/models
|
||||||
|
- ./api.hyungi.net/index.js:/usr/src/app/index.js
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
# 웹 UI (Nginx)
|
# Web UI (Nginx)
|
||||||
web-ui:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./web-ui
|
context: ./web-ui
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: fb_web_ui
|
container_name: tkfb_web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "20000:80"
|
- "20000:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./web-ui:/usr/share/nginx/html:ro
|
- ./web-ui:/usr/share/nginx/html:ro
|
||||||
networks:
|
|
||||||
- fb_network
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
# FastAPI 브릿지
|
# FastAPI Bridge
|
||||||
fastapi-bridge:
|
fastapi:
|
||||||
build:
|
build:
|
||||||
context: ./fastapi-bridge
|
context: ./fastapi-bridge
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: fb_fastapi_bridge
|
container_name: tkfb_fastapi
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "20010:8000"
|
- "20008:8000"
|
||||||
environment:
|
environment:
|
||||||
- EXPRESS_API_URL=http://api:20005
|
- API_BASE_URL=http://api:3005
|
||||||
- NODE_ENV=production
|
depends_on:
|
||||||
|
- api
|
||||||
networks:
|
networks:
|
||||||
- fb_network
|
- tkfb_network
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# phpMyAdmin (DB 관리도구)
|
# phpMyAdmin
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
image: phpmyadmin/phpmyadmin:latest
|
image: phpmyadmin/phpmyadmin:latest
|
||||||
container_name: fb_phpmyadmin
|
container_name: tkfb_phpmyadmin
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -102,15 +105,16 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PMA_HOST=db
|
- PMA_HOST=db
|
||||||
- PMA_USER=root
|
- PMA_USER=root
|
||||||
- PMA_PASSWORD=hyungi_root_password_2025
|
- PMA_PASSWORD=tkfb2024!
|
||||||
- UPLOAD_LIMIT=50M
|
- UPLOAD_LIMIT=50M
|
||||||
networks:
|
networks:
|
||||||
- fb_network
|
- tkfb_network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
fb_network:
|
tkfb_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
name: tkfb_network
|
||||||
|
|||||||
Reference in New Issue
Block a user