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

+51
View File
@@ -0,0 +1,51 @@
import './loadEnv.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function env(name: string, fallback?: string): string {
const value = process.env[name] ?? fallback;
if (value === undefined) {
throw new Error(`Missing required env: ${name}`);
}
return value;
}
export const config = {
port: Number(process.env.PORT ?? 3000),
jwtSecret: env('JWT_SECRET', 'dev-only-change-me'),
publicUrl: env('PUBLIC_URL', 'http://localhost:3000').replace(/\/$/, ''),
internalUrl: env('INTERNAL_URL', 'http://localhost:3000').replace(/\/$/, ''),
onlyOfficeUrl: env('ONLYOFFICE_URL', 'http://localhost:8080').replace(/\/$/, ''),
onlyOfficeInternalUrl: env(
'ONLYOFFICE_INTERNAL_URL',
process.env.ONLYOFFICE_URL ?? 'http://localhost:8080',
).replace(/\/$/, ''),
sessionTtlMs: Number(process.env.SESSION_TTL_MS ?? 900_000),
tempDir: env('TEMP_DIR', '/tmp/sejong'),
maxUploadMb: Number(process.env.MAX_UPLOAD_MB ?? 50),
publicDir: path.resolve(__dirname, '../public'),
};
/** Extensions handled by OnlyOffice (not HWP — that stays client-side via rhwp). */
export const OFFICE_EXTENSIONS = new Set([
'docx',
'xlsx',
'pptx',
'odt',
'ods',
'odp',
'doc',
'xls',
'ppt',
'rtf',
'txt',
'pdf',
'csv',
'dotx',
'xltx',
'potx',
]);
export const HWP_EXTENSIONS = new Set(['hwp', 'hwpx']);
+65
View File
@@ -0,0 +1,65 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import path from 'node:path';
import fs from 'node:fs';
import { config } from './config.js';
import { sessionsRouter } from './routes/sessions.js';
import { documentsRouter } from './routes/documents.js';
import {
ensureTempRoot,
startSweeper,
wipeTempRoot,
} from './services/sessionStore.js';
async function main() {
await wipeTempRoot();
await ensureTempRoot();
const app = express();
app.use(
cors({
origin: true,
credentials: true,
}),
);
app.use(cookieParser());
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
onlyOfficeUrl: config.onlyOfficeUrl,
sessionTtlMs: config.sessionTtlMs,
});
});
app.use('/api/sessions', sessionsRouter);
app.use('/api/documents', documentsRouter);
if (fs.existsSync(config.publicDir)) {
app.use(express.static(config.publicDir));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api')) {
next();
return;
}
res.sendFile(path.join(config.publicDir, 'index.html'));
});
}
startSweeper();
app.listen(config.port, () => {
console.log(`[sejong-office] listening on :${config.port}`);
console.log(`[sejong-office] tempDir=${config.tempDir}`);
console.log(`[sejong-office] publicUrl=${config.publicUrl}`);
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+10
View File
@@ -0,0 +1,10 @@
import dotenv from 'dotenv';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../..',
);
dotenv.config({ path: path.join(rootDir, '.env') });
dotenv.config();
@@ -0,0 +1,21 @@
import type { RequestHandler } from 'express';
import { getSession, touchSession } from '../services/sessionStore.js';
const COOKIE = 'sejong_sid';
export { COOKIE as SESSION_COOKIE };
export const requireSession: RequestHandler = (req, res, next) => {
const sessionId =
(req.cookies?.[COOKIE] as string | undefined) ??
(req.header('x-session-id') as string | undefined);
if (!sessionId || !getSession(sessionId)) {
res.status(401).json({ error: 'session_required' });
return;
}
touchSession(sessionId);
res.locals.sessionId = sessionId;
next();
};
+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);
+105
View File
@@ -0,0 +1,105 @@
import { config } from '../config.js';
import type { SessionDoc } from '../types/session.js';
import { signOnlyOfficePayload } from '../utils/jwt.js';
export function buildEditorConfig(doc: SessionDoc, userId: string) {
const contentUrl = `${config.internalUrl}/api/documents/${doc.docId}/content?token=${doc.token}`;
const callbackUrl = `${config.internalUrl}/api/documents/${doc.docId}/callback`;
const document = {
fileType: doc.fileType,
key: doc.key,
title: doc.filename,
url: contentUrl,
permissions: {
edit: true,
download: true,
print: true,
},
};
const editorConfig = {
callbackUrl,
mode: 'edit' as const,
lang: 'ko',
user: {
id: userId,
name: 'Guest',
},
customization: {
forcesave: true,
goback: false,
},
};
const payload = {
documentType: doc.documentType,
document,
editorConfig,
};
const token = signOnlyOfficePayload(payload);
return {
documentType: doc.documentType,
document,
editorConfig,
token,
documentServerUrl: config.onlyOfficeUrl,
};
}
export async function convertToOriginalFormat(
sourceUrl: string,
fromExt: string,
toExt: string,
): Promise<Buffer | null> {
if (fromExt === toExt) return null;
const convertUrl = `${config.onlyOfficeInternalUrl}/ConvertService.ashx`;
const key = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const body = {
async: false,
filetype: fromExt,
key,
outputtype: toExt,
url: sourceUrl,
};
const token = signOnlyOfficePayload(body);
try {
const res = await fetch(convertUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ ...body, token }),
});
if (!res.ok) {
console.error('[convert] HTTP', res.status);
return null;
}
const data = (await res.json()) as {
error?: number;
fileUrl?: string;
endConvert?: boolean;
};
if (data.error || !data.fileUrl) {
console.error('[convert] failed', data);
return null;
}
const fileRes = await fetch(data.fileUrl);
if (!fileRes.ok) return null;
return Buffer.from(await fileRes.arrayBuffer());
} catch (err) {
console.error('[convert] error', err);
return null;
}
}
@@ -0,0 +1,160 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../config.js';
import type { Session, SessionDoc } from '../types/session.js';
import { documentTypeFor, mimeFor } from '../utils/formats.js';
const sessions = new Map<string, Session>();
function now(): number {
return Date.now();
}
function makeKey(docId: string, version = 0): string {
return `${docId}_${version}_${randomBytes(4).toString('hex')}`;
}
export async function ensureTempRoot(): Promise<void> {
await fs.mkdir(config.tempDir, { recursive: true });
}
export async function wipeTempRoot(): Promise<void> {
try {
await fs.rm(config.tempDir, { recursive: true, force: true });
} catch {
/* ignore */
}
await ensureTempRoot();
sessions.clear();
}
export function createSession(): Session {
const sessionId = uuidv4();
const t = now();
const session: Session = {
sessionId,
lastSeenAt: t,
createdAt: t,
docs: new Map(),
};
sessions.set(sessionId, session);
return session;
}
export function getSession(sessionId: string): Session | undefined {
return sessions.get(sessionId);
}
export function touchSession(sessionId: string): boolean {
const session = sessions.get(sessionId);
if (!session) return false;
session.lastSeenAt = now();
return true;
}
export async function deleteSession(sessionId: string): Promise<boolean> {
const session = sessions.get(sessionId);
if (!session) return false;
const dir = path.join(config.tempDir, sessionId);
sessions.delete(sessionId);
await fs.rm(dir, { recursive: true, force: true });
return true;
}
export async function addDocument(
sessionId: string,
originalFilename: string,
fileType: string,
buffer: Buffer,
): Promise<SessionDoc> {
const session = sessions.get(sessionId);
if (!session) {
throw new Error('SESSION_NOT_FOUND');
}
const docId = uuidv4();
const token = randomBytes(24).toString('hex');
const dir = path.join(config.tempDir, sessionId, docId);
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, `document.${fileType}`);
await fs.writeFile(filePath, buffer);
const t = now();
const doc: SessionDoc = {
sessionId,
docId,
key: makeKey(docId),
filename: originalFilename,
originalFilename,
mime: mimeFor(fileType),
fileType,
documentType: documentTypeFor(fileType),
path: filePath,
token,
lastSeenAt: t,
createdAt: t,
};
session.docs.set(docId, doc);
session.lastSeenAt = t;
return doc;
}
export function getDocument(docId: string): SessionDoc | undefined {
for (const session of sessions.values()) {
const doc = session.docs.get(docId);
if (doc) return doc;
}
return undefined;
}
export async function overwriteDocument(
doc: SessionDoc,
buffer: Buffer,
fileType?: string,
): Promise<void> {
const ext = fileType ?? doc.fileType;
if (ext !== doc.fileType) {
throw new Error('FILETYPE_MISMATCH');
}
await fs.writeFile(doc.path, buffer);
doc.lastSeenAt = now();
doc.key = makeKey(doc.docId, now());
const session = sessions.get(doc.sessionId);
if (session) session.lastSeenAt = doc.lastSeenAt;
}
export async function deleteDocument(docId: string): Promise<boolean> {
for (const session of sessions.values()) {
const doc = session.docs.get(docId);
if (!doc) continue;
session.docs.delete(docId);
await fs.rm(path.dirname(doc.path), { recursive: true, force: true });
return true;
}
return false;
}
export async function sweepExpired(): Promise<number> {
const cutoff = now() - config.sessionTtlMs;
let removed = 0;
for (const [id, session] of sessions) {
if (session.lastSeenAt < cutoff) {
await deleteSession(id);
removed += 1;
}
}
return removed;
}
export function startSweeper(intervalMs = 60_000): NodeJS.Timeout {
return setInterval(() => {
void sweepExpired().then((n) => {
if (n > 0) {
console.log(`[session] swept ${n} expired session(s)`);
}
});
}, intervalMs);
}
+23
View File
@@ -0,0 +1,23 @@
export type DocumentType = 'word' | 'cell' | 'slide' | 'pdf';
export type SessionDoc = {
sessionId: string;
docId: string;
key: string;
filename: string;
originalFilename: string;
mime: string;
fileType: string;
documentType: DocumentType;
path: string;
token: string;
lastSeenAt: number;
createdAt: number;
};
export type Session = {
sessionId: string;
lastSeenAt: number;
createdAt: number;
docs: Map<string, SessionDoc>;
};
+20
View File
@@ -0,0 +1,20 @@
const HANGUL_RE = /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7A3]/;
/**
* Multer/busboy often decodes multipart filenames as Latin-1.
* UTF-8 Hangul then appears as mojibake (e.g. 오…).
* Prefer an explicit UTF-8 `filename` form field from the client when present.
*/
export function decodeUploadFilename(name: string): string {
if (!name) return name;
if (HANGUL_RE.test(name)) return name;
try {
const decoded = Buffer.from(name, 'latin1').toString('utf8');
if (decoded.includes('\uFFFD')) return name;
if (HANGUL_RE.test(decoded)) return decoded;
return name;
} catch {
return name;
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { DocumentType } from '../types/session.js';
const WORD = new Set([
'doc',
'docm',
'docx',
'dot',
'dotm',
'dotx',
'epub',
'fodt',
'htm',
'html',
'odt',
'ott',
'rtf',
'txt',
'xml',
]);
const CELL = new Set([
'csv',
'fods',
'ods',
'ots',
'xls',
'xlsb',
'xlsm',
'xlsx',
'xlt',
'xltm',
'xltx',
]);
const SLIDE = new Set([
'fodp',
'odp',
'otp',
'pot',
'potm',
'potx',
'pps',
'ppsm',
'ppsx',
'ppt',
'pptm',
'pptx',
]);
const PDF = new Set(['pdf', 'djvu', 'xps', 'oxps']);
export function extensionOf(filename: string): string {
const idx = filename.lastIndexOf('.');
if (idx < 0) return '';
return filename.slice(idx + 1).toLowerCase();
}
export function documentTypeFor(ext: string): DocumentType {
if (PDF.has(ext)) return 'pdf';
if (CELL.has(ext)) return 'cell';
if (SLIDE.has(ext)) return 'slide';
if (WORD.has(ext)) return 'word';
return 'word';
}
export function mimeFor(ext: string): string {
const map: Record<string, string> = {
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
pdf: 'application/pdf',
odt: 'application/vnd.oasis.opendocument.text',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
odp: 'application/vnd.oasis.opendocument.presentation',
doc: 'application/msword',
xls: 'application/vnd.ms-excel',
ppt: 'application/vnd.ms-powerpoint',
rtf: 'application/rtf',
txt: 'text/plain',
csv: 'text/csv',
};
return map[ext] ?? 'application/octet-stream';
}
+31
View File
@@ -0,0 +1,31 @@
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export function signOnlyOfficePayload(payload: Record<string, unknown>): string {
return jwt.sign(payload, config.jwtSecret, { algorithm: 'HS256' });
}
export function verifyOnlyOfficeToken(token: string): Record<string, unknown> {
return jwt.verify(token, config.jwtSecret, {
algorithms: ['HS256'],
}) as Record<string, unknown>;
}
/** Extract JWT from Authorization header or body.token (OnlyOffice callback styles). */
export function extractToken(req: {
headers: { authorization?: string };
body?: { token?: string };
query?: { token?: string };
}): string | null {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
return auth.slice(7);
}
if (typeof req.body?.token === 'string') {
return req.body.token;
}
if (typeof req.query?.token === 'string') {
return req.query.token;
}
return null;
}