first commit

This commit is contained in:
ysj7705 committed 2026-07-15 17:29:25 +09:00
commit a74fe7a388
42 files changed
+6058

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
import { Router } from 'express';
import multer from 'multer';
import fs from 'node:fs/promises';
import { config, OFFICE_EXTENSIONS } from '../config.js';
import { requireSession } from '../middleware/session.js';
import {
addDocument,
deleteDocument,
getDocument,
overwriteDocument,
} from '../services/sessionStore.js';
import {
buildEditorConfig,
convertToOriginalFormat,
} from '../services/onlyOffice.js';
import { extensionOf } from '../utils/formats.js';
import { decodeUploadFilename } from '../utils/filename.js';
import { extractToken, verifyOnlyOfficeToken } from '../utils/jwt.js';
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: config.maxUploadMb * 1024 * 1024 },
});
export const documentsRouter = Router();
documentsRouter.post(
'/',
requireSession,
upload.single('file'),
async (req, res) => {
const file = req.file;
if (!file) {
res.status(400).json({ error: 'file_required' });
return;
}
// Prefer explicit UTF-8 field from client; fall back to multer name fix
const fromField =
typeof req.body?.filename === 'string' ? req.body.filename.trim() : '';
const originalFilename =
fromField || decodeUploadFilename(file.originalname);
const fileType = extensionOf(originalFilename);
if (!OFFICE_EXTENSIONS.has(fileType)) {
res.status(400).json({
error: 'unsupported_type',
message:
'이 API는 오피스/PDF용입니다. HWP/HWPX는 클라이언트(rhwp)에서 처리하세요.',
});
return;
}
try {
const doc = await addDocument(
res.locals.sessionId as string,
originalFilename,
fileType,
file.buffer,
);
res.json({
docId: doc.docId,
filename: doc.filename,
fileType: doc.fileType,
documentType: doc.documentType,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'upload_failed' });
}
},
);
documentsRouter.get('/:docId/editor-config', requireSession, (req, res) => {
const doc = getDocument(req.params.docId);
if (!doc || doc.sessionId !== res.locals.sessionId) {
res.status(404).json({ error: 'not_found' });
return;
}
const configPayload = buildEditorConfig(doc, res.locals.sessionId as string);
res.json(configPayload);
});
documentsRouter.get('/:docId/content', async (req, res) => {
const doc = getDocument(req.params.docId);
const token = typeof req.query.token === 'string' ? req.query.token : '';
if (!doc || !token || token !== doc.token) {
res.status(403).json({ error: 'forbidden' });
return;
}
try {
const data = await fs.readFile(doc.path);
res.setHeader('Content-Type', doc.mime);
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(doc.filename)}`,
);
res.send(data);
} catch {
res.status(404).json({ error: 'not_found' });
}
});
documentsRouter.post('/:docId/callback', async (req, res) => {
const doc = getDocument(req.params.docId);
if (!doc) {
res.json({ error: 0 });
return;
}
try {
const token = extractToken(req);
if (token) {
verifyOnlyOfficeToken(token);
}
} catch (err) {
console.error('[callback] jwt invalid', err);
res.status(403).json({ error: 1 });
return;
}
const body = req.body as {
status?: number;
url?: string;
filetype?: string;
token?: string;
};
// Unwrap JWT body if OnlyOffice sent signed payload only
let status = body.status;
let fileUrl = body.url;
let filetype = body.filetype;
if (body.token && status === undefined) {
try {
const payload = verifyOnlyOfficeToken(body.token) as {
status?: number;
url?: string;
filetype?: string;
};
status = payload.status;
fileUrl = payload.url;
filetype = payload.filetype;
} catch {
/* use body fields */
}
}
if (status === 2 || status === 6) {
if (!fileUrl) {
res.json({ error: 1 });
return;
}
try {
const downloaded = await fetch(fileUrl);
if (!downloaded.ok) {
console.error('[callback] download failed', downloaded.status);
res.json({ error: 1 });
return;
}
let buffer: Buffer = Buffer.from(await downloaded.arrayBuffer());
const returnedType = (filetype ?? doc.fileType).toLowerCase();
if (returnedType !== doc.fileType) {
const converted = await convertToOriginalFormat(
fileUrl,
returnedType,
doc.fileType,
);
if (!converted) {
console.error(
`[callback] refused save: got ${returnedType}, need ${doc.fileType}`,
);
res.json({ error: 1 });
return;
}
buffer = Buffer.from(converted);
}
await overwriteDocument(doc, buffer, doc.fileType);
} catch (err) {
console.error('[callback] save error', err);
res.json({ error: 1 });
return;
}
}
res.json({ error: 0 });
});
documentsRouter.get('/:docId/download', requireSession, async (req, res) => {
const doc = getDocument(req.params.docId);
if (!doc || doc.sessionId !== res.locals.sessionId) {
res.status(404).json({ error: 'not_found' });
return;
}
try {
const data = await fs.readFile(doc.path);
res.setHeader('Content-Type', doc.mime);
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(doc.filename)}`,
);
res.send(data);
} catch {
res.status(404).json({ error: 'not_found' });
}
});
documentsRouter.delete('/:docId', requireSession, async (req, res) => {
const doc = getDocument(req.params.docId);
if (!doc || doc.sessionId !== res.locals.sessionId) {
res.status(404).json({ error: 'not_found' });
return;
}
await deleteDocument(doc.docId);
res.json({ ok: true });
});
+67
View File
@@ -0,0 +1,67 @@
import { Router } from 'express';
import {
createSession,
deleteSession,
getSession,
touchSession,
} from '../services/sessionStore.js';
import { requireSession, SESSION_COOKIE } from '../middleware/session.js';
export const sessionsRouter = Router();
/** Create session, or reuse existing cookie session so in-app navigation keeps docs. */
sessionsRouter.post('/', (req, res) => {
const existingId = req.cookies?.[SESSION_COOKIE] as string | undefined;
if (existingId && getSession(existingId)) {
touchSession(existingId);
res.json({ sessionId: existingId, reused: true });
return;
}
const session = createSession();
res.cookie(SESSION_COOKIE, session.sessionId, {
httpOnly: true,
sameSite: 'lax',
path: '/',
});
res.json({ sessionId: session.sessionId, reused: false });
});
sessionsRouter.get('/me', (req, res) => {
const existingId = req.cookies?.[SESSION_COOKIE] as string | undefined;
if (!existingId || !getSession(existingId)) {
res.status(401).json({ error: 'session_required' });
return;
}
touchSession(existingId);
res.json({ sessionId: existingId });
});
sessionsRouter.post('/heartbeat', requireSession, (req, res) => {
const sessionId = res.locals.sessionId as string;
touchSession(sessionId);
res.json({ ok: true });
});
async function endSessionHandler(
req: import('express').Request,
res: import('express').Response,
) {
const sessionId =
(req.cookies?.[SESSION_COOKIE] as string | undefined) ??
(req.header('x-session-id') as string | undefined) ??
(typeof req.body?.sessionId === 'string' ? req.body.sessionId : undefined);
if (!sessionId) {
res.json({ ok: true });
return;
}
await deleteSession(sessionId);
res.clearCookie(SESSION_COOKIE, { path: '/' });
res.json({ ok: true });
}
sessionsRouter.delete('/', endSessionHandler);
/** sendBeacon / keepalive 호환 */
sessionsRouter.post('/end', endSessionHandler);