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