feat: 첨부파일 다중 업로드, 관리자 전용 업로드, 파일유형별 다운로드 아이콘
- 업로드시 제목 입력 + 첨부파일 최대 3개 등록 가능 (posts/attachments 구조 도입) - 개별 첨부파일 다운로드 API 및 UI 추가 (일반/관리자 페이지 모두) - 파일 업로드는 관리자만 가능하도록 서버 인증 적용, 일반 페이지 업로드 버튼 제거 - 다운로드 아이콘을 파일 유형(문서/이미지/동영상/압축/코드)별로 색상·아이콘 구분 - 관리자 페이지 강조 버튼 hover 시 배경색 사라지던 CSS 버그 수정 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,48 +88,26 @@ async function initDb() {
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
saveDb();
|
||||
seedData();
|
||||
seedCategories();
|
||||
seedUsers();
|
||||
}
|
||||
|
||||
// ── 시드 데이터 ───────────────────────────────────────────────
|
||||
const SEED = [
|
||||
{ id:"f1", name:"2026년 1분기 마케팅 전략.pdf", type:"doc", ext:"PDF", size_bytes:4404019, size_label:"4.2 MB", modified_at:"2시간 전", author:"김민지", category:"doc", visibility:"team", downloads:142, starred:1, thumb:"doc" },
|
||||
{ id:"f2", name:"브랜드 가이드라인 v3.2.pdf", type:"doc", ext:"PDF", size_bytes:13421772, size_label:"12.8 MB", modified_at:"어제", author:"이서준", category:"doc", visibility:"public", downloads:891, starred:1, thumb:"doc" },
|
||||
{ id:"f3", name:"신제품 키비주얼.png", type:"img", ext:"PNG", size_bytes:8493466, size_label:"8.1 MB", modified_at:"3일 전", author:"박하늘", category:"img", visibility:"team", downloads:56, starred:0, thumb:"img" },
|
||||
{ id:"f4", name:"온보딩 영상 (한국어).mp4", type:"vid", ext:"MP4", size_bytes:297795584, size_label:"284 MB", modified_at:"1주 전", author:"최유나", category:"vid", visibility:"public", downloads:312, starred:0, thumb:"vid" },
|
||||
{ id:"f5", name:"사용자 인터뷰 결과.docx", type:"doc", ext:"DOCX", size_bytes:1468006, size_label:"1.4 MB", modified_at:"5일 전", author:"정도현", category:"doc", visibility:"team", downloads:78, starred:1, thumb:"doc" },
|
||||
{ id:"f6", name:"디자인 시스템 컴포넌트.zip", type:"zip", ext:"ZIP", size_bytes:47840256, size_label:"45.6 MB", modified_at:"2주 전", author:"이서준", category:"zip", visibility:"team", downloads:234, starred:0, thumb:"zip" },
|
||||
{ id:"f7", name:"react-utils.ts", type:"code", ext:"TS", size_bytes:12288, size_label:"12 KB", modified_at:"어제", author:"김민지", category:"code", visibility:"public", downloads:47, starred:0, thumb:"code" },
|
||||
{ id:"f8", name:"Q4 실적 발표 자료.pptx", type:"doc", ext:"PPTX", size_bytes:29673267, size_label:"28.3 MB", modified_at:"4일 전", author:"한지원", category:"doc", visibility:"team", downloads:198, starred:1, thumb:"doc" },
|
||||
{ id:"f9", name:"회사소개 인포그래픽.png", type:"img", ext:"PNG", size_bytes:3355443, size_label:"3.2 MB", modified_at:"6일 전", author:"박하늘", category:"img", visibility:"public", downloads:421, starred:0, thumb:"img" },
|
||||
{ id:"f10", name:"기술 면접 질문 모음.pdf", type:"doc", ext:"PDF", size_bytes:913408, size_label:"892 KB", modified_at:"3주 전", author:"정도현", category:"doc", visibility:"team", downloads:612, starred:1, thumb:"doc" },
|
||||
{ id:"f11", name:"CES 2026 발표 영상.mov", type:"vid", ext:"MOV", size_bytes:1288490188, size_label:"1.2 GB", modified_at:"2주 전", author:"최유나", category:"vid", visibility:"public", downloads:89, starred:0, thumb:"vid" },
|
||||
{ id:"f12", name:"고객 데이터 샘플.csv", type:"code", ext:"CSV", size_bytes:2202009, size_label:"2.1 MB", modified_at:"1시간 전", author:"한지원", category:"code", visibility:"team", downloads:23, starred:0, thumb:"code" },
|
||||
{ id:"f13", name:"이메일 템플릿 모음.zip", type:"zip", ext:"ZIP", size_bytes:9123020, size_label:"8.7 MB", modified_at:"5일 전", author:"김민지", category:"zip", visibility:"team", downloads:156, starred:0, thumb:"zip" },
|
||||
{ id:"f14", name:"워크샵 사진 모음.zip", type:"zip", ext:"ZIP", size_bytes:245366784, size_label:"234 MB", modified_at:"1주 전", author:"박하늘", category:"zip", visibility:"team", downloads:67, starred:0, thumb:"zip" },
|
||||
];
|
||||
|
||||
function seedData() {
|
||||
const count = queryGet('SELECT COUNT(*) as c FROM files');
|
||||
if (count && count.c > 0) return;
|
||||
for (const f of SEED) {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO files (id,name,type,ext,size_bytes,size_label,modified_at,author,category,visibility,downloads,starred,thumb)
|
||||
VALUES (:id,:name,:type,:ext,:size_bytes,:size_label,:modified_at,:author,:category,:visibility,:downloads,:starred,:thumb)`,
|
||||
{ ':id':f.id, ':name':f.name, ':type':f.type, ':ext':f.ext, ':size_bytes':f.size_bytes,
|
||||
':size_label':f.size_label, ':modified_at':f.modified_at, ':author':f.author,
|
||||
':category':f.category, ':visibility':f.visibility, ':downloads':f.downloads,
|
||||
':starred':f.starred, ':thumb':f.thumb }
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
file_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
ext TEXT NOT NULL,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
size_label TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
downloads INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
}
|
||||
`);
|
||||
saveDb();
|
||||
console.log(`시드 데이터 ${SEED.length}개 삽입 완료`);
|
||||
seedCategories();
|
||||
}
|
||||
|
||||
// ── 카테고리·사용자 시드 ──────────────────────────────────────
|
||||
// ── 카테고리 시드 ─────────────────────────────────────────────
|
||||
function seedCategories() {
|
||||
const newIds = ['doc', 'img', 'vid', 'zip', 'code'];
|
||||
const existing = queryAll('SELECT id FROM categories');
|
||||
@@ -156,20 +134,6 @@ function seedCategories() {
|
||||
console.log('카테고리 마이그레이션 완료: 파일 유형 기반으로 변경');
|
||||
}
|
||||
|
||||
function seedUsers() {
|
||||
const count = queryGet('SELECT COUNT(*) as c FROM users');
|
||||
if (count && count.c > 0) return;
|
||||
const names = [...new Set(SEED.map(f => f.author))];
|
||||
names.forEach((name, i) => {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO users (id,name,role) VALUES (:id,:name,'member')`,
|
||||
{ ':id': `u${i + 1}`, ':name': name }
|
||||
);
|
||||
});
|
||||
saveDb();
|
||||
console.log(`사용자 시드 ${names.length}명 삽입 완료`);
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────
|
||||
function guessType(mimetype, ext) {
|
||||
if (!mimetype) return 'doc';
|
||||
@@ -190,7 +154,8 @@ function formatSize(bytes) {
|
||||
|
||||
function rowToFile(row) {
|
||||
if (!row) return null;
|
||||
return { ...row, starred: row.starred === 1 };
|
||||
const attachments = queryAll('SELECT * FROM attachments WHERE file_id = :id ORDER BY sort_order ASC', { ':id': row.id });
|
||||
return { ...row, starred: row.starred === 1, attachments };
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
@@ -287,24 +252,45 @@ app.get('/api/stats', (req, res) => {
|
||||
});
|
||||
|
||||
// ── API: 업로드 ───────────────────────────────────────────────
|
||||
app.post('/api/files/upload', upload.single('file'), (req, res) => {
|
||||
const f = req.file;
|
||||
if (!f) return res.status(400).json({ error: 'no file' });
|
||||
app.post('/api/files/upload', authMiddleware, upload.array('files', 3), (req, res) => {
|
||||
const uploadedFiles = req.files;
|
||||
if (!uploadedFiles || !uploadedFiles.length) return res.status(400).json({ error: '첨부파일이 필요합니다 (최대 3개)' });
|
||||
|
||||
const title = (req.body.title || '').trim();
|
||||
if (!title) return res.status(400).json({ error: '제목을 입력해주세요' });
|
||||
|
||||
const attachmentRows = uploadedFiles.map((f, i) => {
|
||||
const name = Buffer.from(f.originalname, 'latin1').toString('utf8');
|
||||
const ext = path.extname(name);
|
||||
return {
|
||||
id: randomUUID(), name, type: guessType(f.mimetype, ext),
|
||||
ext: ext.replace('.', '').toUpperCase() || 'FILE',
|
||||
size_bytes: f.size, size_label: formatSize(f.size),
|
||||
file_path: f.filename, sort_order: i,
|
||||
};
|
||||
});
|
||||
|
||||
const name = Buffer.from(f.originalname, 'latin1').toString('utf8');
|
||||
const ext = path.extname(name);
|
||||
const type = guessType(f.mimetype, ext);
|
||||
const extLabel = ext.replace('.', '').toUpperCase() || 'FILE';
|
||||
const id = randomUUID();
|
||||
const primary = attachmentRows[0];
|
||||
const totalBytes = attachmentRows.reduce((s, a) => s + a.size_bytes, 0);
|
||||
|
||||
db.run(
|
||||
`INSERT INTO files (id,name,type,ext,size_bytes,size_label,modified_at,author,category,visibility,downloads,starred,thumb,file_path)
|
||||
VALUES (:id,:name,:type,:ext,:size_bytes,:size_label,:modified_at,:author,:category,:visibility,0,0,:thumb,:file_path)`,
|
||||
{ ':id':id, ':name':name, ':type':type, ':ext':extLabel,
|
||||
':size_bytes':f.size, ':size_label':formatSize(f.size), ':modified_at':'방금',
|
||||
':author': req.body.author || '나', ':category': req.body.category || type,
|
||||
':visibility': req.body.visibility || 'team', ':thumb':type, ':file_path':f.filename }
|
||||
{ ':id':id, ':name':title, ':type':primary.type, ':ext':primary.ext,
|
||||
':size_bytes':totalBytes, ':size_label':formatSize(totalBytes), ':modified_at':'방금',
|
||||
':author': req.body.author || '나', ':category': req.body.category || primary.type,
|
||||
':visibility': req.body.visibility || 'team', ':thumb':primary.type, ':file_path':primary.file_path }
|
||||
);
|
||||
|
||||
for (const a of attachmentRows) {
|
||||
db.run(
|
||||
`INSERT INTO attachments (id,file_id,name,type,ext,size_bytes,size_label,file_path,downloads,sort_order)
|
||||
VALUES (:id,:file_id,:name,:type,:ext,:size_bytes,:size_label,:file_path,0,:sort_order)`,
|
||||
{ ':id':a.id, ':file_id':id, ':name':a.name, ':type':a.type, ':ext':a.ext,
|
||||
':size_bytes':a.size_bytes, ':size_label':a.size_label, ':file_path':a.file_path, ':sort_order':a.sort_order }
|
||||
);
|
||||
}
|
||||
saveDb();
|
||||
|
||||
const row = queryGet('SELECT * FROM files WHERE id = :id', { ':id': id });
|
||||
@@ -335,7 +321,20 @@ app.get('/api/files/:id/download', (req, res) => {
|
||||
if (!row.file_path) return res.status(404).json({ error: 'no file' });
|
||||
const fp = path.join(UPLOADS_DIR, row.file_path);
|
||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'file missing' });
|
||||
const primary = queryGet('SELECT name FROM attachments WHERE file_id = :id ORDER BY sort_order ASC LIMIT 1', { ':id': row.id });
|
||||
run('UPDATE files SET downloads = downloads + 1 WHERE id = :id', { ':id': req.params.id });
|
||||
res.download(fp, primary?.name || row.name);
|
||||
});
|
||||
|
||||
// ── API: 개별 첨부파일 다운로드 ───────────────────────────────
|
||||
app.get('/api/attachments/:id/download', (req, res) => {
|
||||
const row = queryGet('SELECT * FROM attachments WHERE id = :id', { ':id': req.params.id });
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
const fp = path.join(UPLOADS_DIR, row.file_path);
|
||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'file missing' });
|
||||
db.run('UPDATE attachments SET downloads = downloads + 1 WHERE id = :id', { ':id': row.id });
|
||||
db.run('UPDATE files SET downloads = downloads + 1 WHERE id = :id', { ':id': row.file_id });
|
||||
saveDb();
|
||||
res.download(fp, row.name);
|
||||
});
|
||||
|
||||
@@ -348,7 +347,8 @@ app.delete('/api/files', authMiddleware, (req, res) => {
|
||||
const params = {};
|
||||
ids.forEach((id, i) => { params[`:id${i}`] = id; });
|
||||
|
||||
const rows = queryAll(`SELECT file_path FROM files WHERE id IN (${placeholders})`, params);
|
||||
const rows = queryAll(`SELECT file_path FROM attachments WHERE file_id IN (${placeholders})`, params);
|
||||
db.run(`DELETE FROM attachments WHERE file_id IN (${placeholders})`, params);
|
||||
db.run(`DELETE FROM files WHERE id IN (${placeholders})`, params);
|
||||
saveDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user