first commit
This commit is contained in:
commit
a74fe7a388
42 files changed
+6058
No files matched your search
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user