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

+16
View File
@@ -0,0 +1,16 @@
# Shared JWT secret (must match OnlyOffice Document Server)
JWT_SECRET=change-me-to-a-long-random-secret
# URLs
PUBLIC_URL=http://localhost:3000
INTERNAL_URL=http://backend:3000
ONLYOFFICE_URL=http://localhost:8080
ONLYOFFICE_INTERNAL_URL=http://onlyoffice
# Session
SESSION_TTL_MS=900000
PORT=3000
TEMP_DIR=/tmp/sejong
# Upload
MAX_UPLOAD_MB=50
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
packages/backend/public/
packages/backend/dist/
packages/frontend/dist/
.env
.env.local
*.log
.DS_Store
tmp/
.vscode/
.idea/
+34
View File
@@ -0,0 +1,34 @@
# Build frontend + backend
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
COPY packages/backend/package.json packages/backend/
COPY packages/frontend/package.json packages/frontend/
RUN npm install
COPY packages ./packages
RUN npm run build -w @sejong-office/frontend \
&& npm run build -w @sejong-office/backend
# Runtime
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV TEMP_DIR=/tmp/sejong
ENV PORT=3000
COPY package.json package-lock.json* ./
COPY packages/backend/package.json packages/backend/package.json
RUN npm install --omit=dev -w @sejong-office/backend && npm cache clean --force
COPY --from=build /app/packages/backend/dist ./packages/backend/dist
COPY --from=build /app/packages/backend/public ./packages/backend/public
WORKDIR /app/packages/backend
ENV TEMP_DIR=/tmp/sejong
RUN mkdir -p /tmp/sejong
EXPOSE 3000
CMD ["node", "dist/index.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sejong-office contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+68
View File
@@ -0,0 +1,68 @@
# sejong-office
임시 세션 기반 문서 편집 웹앱입니다.
- **오피스 / PDF** → [ONLYOFFICE Document Server](https://www.onlyoffice.com/)
- **HWP / HWPX** → [rhwp](https://github.com/edwardkim/rhwp) (`@rhwp/editor`)
서버에 문서를 **영구 저장하지 않습니다**. 오피스·PDF는 세션 동안만 tmpfs에 두고, 브라우저 종료·TTL로 삭제합니다. HWP/HWPX는 기본적으로 브라우저 메모리에서만 다룹니다. 편집 결과는 **다운로드**로만 가져가세요.
## 스택
- Vue 3 + Vite + TypeScript
- Express + TypeScript
- Docker Compose
## 빠른 시작 (Docker)
```bash
cp .env.example .env
# JWT_SECRET 을 긴 임의 문자열로 바꾸세요
docker compose up --build
```
- 앱: http://localhost:3000
- Document Server: http://localhost:8080
첫 Document Server 기동은 수 분 걸릴 수 있습니다.
## 로컬 개발
```bash
npm install
cp .env.example .env
# 터미널 1 — API (포트 3000)
npm run dev:backend
# 터미널 2 — Vite (포트 5173, /api 프록시)
npm run dev:frontend
```
OnlyOffice Document Server는 Docker로만 띄워도 됩니다.
```bash
docker compose up onlyoffice
```
## 소스 저장소 링크
프론트 [`packages/frontend/src/config/app.ts`](packages/frontend/src/config/app.ts) 의 `APP_GIT_URL` 에 Git URL을 넣으면 푸터에 **소스 코드** 링크가 표시됩니다.
## 라이선스 고지
앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 OnlyOffice(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다.
- 본 앱: MIT
- ONLYOFFICE Document Server Community: AGPL-3.0 (배포 시 의무 확인)
- rhwp: MIT — Hancom 상표와 무관한 독립 프로젝트
## 포맷 안내
| 확장자 | 엔진 | 저장 |
|--------|------|------|
| hwp, hwpx | rhwp | 원본 확장자로 export |
| pdf, docx, xlsx, pptx 등 | OnlyOffice | 원본 확장자 유지 시도 (`assemblyFormatAsOrigin`) |
rhwp는 v0.7.x 기준이며, 복잡한 공공문서에서 한컴과 조판이 다를 수 있습니다.
+37
View File
@@ -0,0 +1,37 @@
services:
backend:
build: .
ports:
- '3000:3000'
environment:
JWT_SECRET: ${JWT_SECRET:-change-me-to-a-long-random-secret}
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
INTERNAL_URL: ${INTERNAL_URL:-http://backend:3000}
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-http://localhost:8080}
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice}
SESSION_TTL_MS: ${SESSION_TTL_MS:-900000}
TEMP_DIR: /tmp/sejong
PORT: 3000
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-50}
tmpfs:
- /tmp/sejong:size=512m,mode=1777
depends_on:
- onlyoffice
restart: unless-stopped
onlyoffice:
image: onlyoffice/documentserver:8.3
ports:
- '8080:80'
environment:
JWT_ENABLED: 'true'
JWT_SECRET: ${JWT_SECRET:-change-me-to-a-long-random-secret}
JWT_HEADER: Authorization
JWT_IN_BODY: 'true'
volumes:
# Persist DS license/cache lightly; document content still lives in backend tmpfs
- onlyoffice_data:/var/www/onlyoffice/Data
restart: unless-stopped
volumes:
onlyoffice_data:
+3442
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "sejong-office",
"version": "0.1.0",
"private": true,
"description": "Ephemeral OnlyOffice + rhwp document editor",
"license": "MIT",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "npm run dev -w @sejong-office/backend & npm run dev -w @sejong-office/frontend",
"dev:backend": "npm run dev -w @sejong-office/backend",
"dev:frontend": "npm run dev -w @sejong-office/frontend",
"build": "npm run build -w @sejong-office/frontend && npm run build -w @sejong-office/backend",
"start": "npm run start -w @sejong-office/backend"
},
"engines": {
"node": ">=20"
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@sejong-office/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.9",
"@types/multer": "^1.4.12",
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
+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;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"]
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sejong Office</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@sejong-office/frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@rhwp/editor": "^0.7.18",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
}
}
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { bindSessionPageHideOnce } from '@/composables/useSessionLifecycle';
bindSessionPageHideOnce();
const route = useRoute();
/** Keep editor instances alive when visiting /licenses etc. */
const viewKey = computed(() => {
if (route.name === 'onlyoffice-editor') {
return `onlyoffice-${route.params.docId}`;
}
if (route.name === 'rhwp-editor') {
return 'rhwp-editor';
}
return String(route.fullPath);
});
</script>
<template>
<RouterView v-slot="{ Component }">
<KeepAlive :include="['OnlyOfficeEditorView', 'RhwpEditorView']" :max="6">
<component :is="Component" :key="viewKey" />
</KeepAlive>
</RouterView>
</template>
+89
View File
@@ -0,0 +1,89 @@
export class ApiError extends Error {
readonly code: string;
readonly status: number;
constructor(status: number, code: string, message?: string) {
super(message ?? code);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
async function parseJson<T>(res: Response): Promise<T> {
const body = (await res.json().catch(() => ({}))) as {
message?: string;
error?: string;
};
if (!res.ok) {
throw new ApiError(
res.status,
body.error ?? `http_${res.status}`,
body.message ?? body.error ?? `HTTP ${res.status}`,
);
}
return body as T;
}
export async function createSession(): Promise<{
sessionId: string;
reused?: boolean;
}> {
return parseJson(
await fetch('/api/sessions', { method: 'POST', credentials: 'include' }),
);
}
export async function heartbeat(): Promise<void> {
await fetch('/api/sessions/heartbeat', {
method: 'POST',
credentials: 'include',
});
}
export async function endSession(): Promise<void> {
await fetch('/api/sessions', {
method: 'DELETE',
credentials: 'include',
});
}
export async function uploadDocument(file: File): Promise<{
docId: string;
filename: string;
fileType: string;
documentType: string;
}> {
const form = new FormData();
form.append('file', file);
form.append('filename', file.name);
return parseJson(
await fetch('/api/documents', {
method: 'POST',
body: form,
credentials: 'include',
}),
);
}
export type EditorConfigResponse = {
documentType: string;
document: Record<string, unknown>;
editorConfig: Record<string, unknown>;
token: string;
documentServerUrl: string;
};
export async function fetchEditorConfig(
docId: string,
): Promise<EditorConfigResponse> {
return parseJson(
await fetch(`/api/documents/${docId}/editor-config`, {
credentials: 'include',
}),
);
}
export function downloadDocumentUrl(docId: string): string {
return `/api/documents/${docId}/download`;
}
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { APP_GIT_URL, APP_DISPLAY_NAME } from '@/config/app';
withDefaults(
defineProps<{
compact?: boolean;
}>(),
{ compact: false },
);
</script>
<template>
<footer class="footer" :class="{ 'footer--compact': compact }">
<div class="footer__inner">
<span class="footer__brand">{{ APP_DISPLAY_NAME }}</span>
<nav class="footer__nav" aria-label="부가 링크">
<a
v-if="APP_GIT_URL"
class="footer__link"
:href="APP_GIT_URL"
target="_blank"
rel="noopener noreferrer"
>
소스 코드
</a>
<RouterLink class="footer__link" to="/licenses">
오픈소스 라이선스
</RouterLink>
</nav>
</div>
</footer>
</template>
<style scoped>
.footer {
flex: 0 0 auto;
margin-top: auto;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 85%, transparent);
backdrop-filter: blur(8px);
}
.footer--compact .footer__inner {
padding: 0.4rem 1rem;
}
.footer__inner {
max-width: 960px;
margin: 0 auto;
padding: 1rem 1.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
align-items: center;
justify-content: space-between;
}
.footer--compact .footer__inner {
max-width: none;
}
.footer__brand {
font-size: 0.85rem;
color: var(--muted);
font-weight: 500;
}
.footer--compact .footer__brand,
.footer--compact .footer__link {
font-size: 0.75rem;
}
.footer__nav {
display: flex;
gap: 1.25rem;
}
.footer__link {
font-size: 0.85rem;
color: var(--ink);
text-decoration: none;
border-bottom: 1px solid transparent;
transition:
border-color 0.2s ease,
color 0.2s ease;
}
.footer__link:hover {
border-bottom-color: var(--accent);
color: var(--accent);
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import AppFooter from './AppFooter.vue';
withDefaults(
defineProps<{
/** Editor pages: fill viewport, compact footer */
compactFooter?: boolean;
}>(),
{ compactFooter: false },
);
</script>
<template>
<div class="shell" :class="{ 'shell--editor': compactFooter }">
<div class="shell__main">
<slot />
</div>
<AppFooter :compact="compactFooter" />
</div>
</template>
<style scoped>
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shell--editor {
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
.shell__main {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ApiError } from '@/api/client';
const props = defineProps<{
error: unknown;
context?: 'onlyoffice' | 'rhwp';
}>();
const code = computed(() => {
if (props.error instanceof ApiError) return props.error.code;
return 'unknown';
});
const title = computed(() => {
switch (code.value) {
case 'not_found':
return '문서를 찾을 수 없습니다';
case 'session_required':
return '세션이 만료되었습니다';
case 'unsupported_type':
return '지원하지 않는 파일 형식입니다';
default:
return '문서를 열 수 없습니다';
}
});
const detail = computed(() => {
switch (code.value) {
case 'not_found':
return '임시 세션이 끝났거나, 다른 탭에서 세션이 바뀌었을 수 있습니다. 서버에는 파일을 영구 보관하지 않으므로 홈에서 다시 열어 주세요. 편집 중이라면 가능하면 에디터의 파일 → 저장으로 먼저 받아 두세요.';
case 'session_required':
return '브라우저를 오래 두었거나 세션이 초기화되었습니다. 홈으로 돌아가 파일을 다시 선택해 주세요.';
default:
if (props.error instanceof Error && props.error.message) {
return props.error.message;
}
return '알 수 없는 오류가 발생했습니다. 홈에서 다시 시도해 주세요.';
}
});
</script>
<template>
<div class="err" role="alert">
<p class="err__eyebrow">{{ context === 'rhwp' ? 'HWP 에디터' : '문서 에디터' }}</p>
<h1 class="err__title">{{ title }}</h1>
<p class="err__detail">{{ detail }}</p>
<p v-if="code !== 'unknown'" class="err__code">오류 코드: {{ code }}</p>
<div class="err__actions">
<RouterLink class="err__btn err__btn--primary" to="/">홈으로 돌아가기</RouterLink>
<RouterLink class="err__btn" to="/licenses">라이선스 보기</RouterLink>
</div>
</div>
</template>
<style scoped>
.err {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 0.65rem;
max-width: 36rem;
margin: 0 auto;
padding: 2.5rem 1.5rem;
}
.err__eyebrow {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.err__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.75rem;
font-weight: 600;
color: var(--ink);
}
.err__detail {
margin: 0;
line-height: 1.6;
color: var(--muted);
font-size: 0.95rem;
}
.err__code {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
opacity: 0.8;
}
.err__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.75rem;
}
.err__btn {
display: inline-flex;
align-items: center;
padding: 0.55rem 1rem;
border-radius: 3px;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
border: 1px solid var(--border);
color: var(--ink);
background: var(--surface);
}
.err__btn--primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
</style>
@@ -0,0 +1,104 @@
import { onMounted, onUnmounted } from 'vue';
import { createSession, endSession, heartbeat } from '@/api/client';
const HWP_FILE_KEY = 'sejong_hwp_file';
let pageHideBound = false;
function onPageHide() {
navigator.sendBeacon(
'/api/sessions/end',
new Blob(['{}'], { type: 'application/json' }),
);
void fetch('/api/sessions', {
method: 'DELETE',
credentials: 'include',
keepalive: true,
});
clearHwpFile();
}
/** Register once for the whole SPA (survives keep-alive editor switches). */
export function bindSessionPageHideOnce() {
if (pageHideBound || typeof window === 'undefined') return;
pageHideBound = true;
window.addEventListener('pagehide', onPageHide);
}
export function storeHwpFile(file: File): Promise<void> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const buffer = reader.result as ArrayBuffer;
const bytes = new Uint8Array(buffer);
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
sessionStorage.setItem(
HWP_FILE_KEY,
JSON.stringify({
name: file.name,
base64: btoa(binary),
}),
);
resolve();
};
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
const raw = sessionStorage.getItem(HWP_FILE_KEY);
if (!raw) return null;
try {
const { name, base64 } = JSON.parse(raw) as {
name: string;
base64: string;
};
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return { name, buffer: bytes.buffer };
} catch {
return null;
}
}
export function clearHwpFile(): void {
sessionStorage.removeItem(HWP_FILE_KEY);
}
export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) {
let timer: ReturnType<typeof setInterval> | undefined;
async function ensureSession() {
// Reuses cookie session on the server — does not orphan open documents
await createSession();
}
onMounted(async () => {
bindSessionPageHideOnce();
try {
await ensureSession();
} catch (err) {
console.error('session create failed', err);
}
if (options?.enableHeartbeat !== false) {
timer = setInterval(() => {
void heartbeat().catch(() => undefined);
}, 30_000);
}
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
return { ensureSession, endSession, clearHwpFile };
}
+43
View File
@@ -0,0 +1,43 @@
/** 본 프로젝트 소스 저장소. 공개 후 URL 채우기. 비우면 푸터에서 링크 숨김 */
export const APP_GIT_URL = "https://git.sejong.dev/ysj7705/sejong-office";
export const APP_NAME = "sejong-office";
export const APP_DISPLAY_NAME = "Sejong Office";
/** HWP/HWPX — client-side rhwp */
export const HWP_EXTENSIONS = new Set(["hwp", "hwpx"]);
/** Office/PDF — OnlyOffice */
export const OFFICE_EXTENSIONS = new Set([
"docx",
"xlsx",
"pptx",
"odt",
"ods",
"odp",
"doc",
"xls",
"ppt",
"rtf",
"txt",
"pdf",
"csv",
"dotx",
"xltx",
"potx",
]);
export function extensionOf(filename: string): string {
const idx = filename.lastIndexOf(".");
if (idx < 0) return "";
return filename.slice(idx + 1).toLowerCase();
}
export function isHwp(filename: string): boolean {
return HWP_EXTENSIONS.has(extensionOf(filename));
}
export function isOffice(filename: string): boolean {
return OFFICE_EXTENSIONS.has(extensionOf(filename));
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import './styles.css';
createApp(App).use(router).mount('#app');
+33
View File
@@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/editor/onlyoffice/:docId',
name: 'onlyoffice-editor',
component: () => import('@/views/OnlyOfficeEditorView.vue'),
},
{
path: '/editor/rhwp',
name: 'rhwp-editor',
component: () => import('@/views/RhwpEditorView.vue'),
},
{
path: '/licenses',
name: 'licenses',
component: () => import('@/views/LicensesView.vue'),
},
],
scrollBehavior() {
return { top: 0 };
},
});
export default router;
+43
View File
@@ -0,0 +1,43 @@
:root {
--ink: #1c2b24;
--ink-soft: #2f4038;
--muted: #5c6b63;
--accent: #2f6b55;
--accent-soft: #d7ebe2;
--surface: #faf9f6;
--canvas: #eef1ee;
--border: #d0d7d2;
--font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
--font-display: 'Newsreader', 'IBM Plex Sans KR', serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
min-height: 100%;
font-family: var(--font-sans);
color: var(--ink);
background: var(--canvas);
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100%;
height: 100%;
}
button,
input {
font: inherit;
}
a {
color: inherit;
}
+221
View File
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import { APP_DISPLAY_NAME, isHwp, isOffice } from '@/config/app';
import { createSession, uploadDocument } from '@/api/client';
import { storeHwpFile } from '@/composables/useSessionLifecycle';
const router = useRouter();
const dragging = ref(false);
const error = ref('');
const busy = ref(false);
async function handleFile(file: File | undefined) {
if (!file || busy.value) return;
error.value = '';
busy.value = true;
try {
if (isHwp(file.name)) {
await storeHwpFile(file);
await createSession().catch(() => undefined);
await router.push({ name: 'rhwp-editor' });
return;
}
if (!isOffice(file.name)) {
error.value =
'지원하지 않는 형식입니다. docx, xlsx, pptx, pdf, hwp, hwpx 등을 올려 주세요.';
return;
}
await createSession();
const { docId } = await uploadDocument(file);
await router.push({ name: 'onlyoffice-editor', params: { docId } });
} catch (err) {
error.value =
err instanceof Error ? err.message : '파일을 열 수 없습니다.';
} finally {
busy.value = false;
}
}
function onDrop(e: DragEvent) {
dragging.value = false;
const file = e.dataTransfer?.files?.[0];
void handleFile(file);
}
function onInput(e: Event) {
const input = e.target as HTMLInputElement;
void handleFile(input.files?.[0]);
input.value = '';
}
</script>
<template>
<AppShell>
<main class="home">
<div class="home__bg" aria-hidden="true" />
<section class="hero">
<p class="hero__brand">{{ APP_DISPLAY_NAME }}</p>
<h1 class="hero__title">문서를 열고, 고치고, 가져가세요</h1>
<p class="hero__lead">
서버에 영구 저장하지 않습니다. 편집이 끝나면 다운로드하세요. 브라우저를
닫으면 임시 파일은 삭제됩니다.
</p>
<label
class="drop"
:class="{ 'drop--active': dragging, 'drop--busy': busy }"
@dragenter.prevent="dragging = true"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<input
class="drop__input"
type="file"
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv"
:disabled="busy"
@change="onInput"
/>
<span class="drop__label">
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
</span>
<span class="drop__hint">
오피스·PDF → OnlyOffice · HWP/HWPX → rhwp
</span>
</label>
<p v-if="error" class="hero__error" role="alert">{{ error }}</p>
</section>
</main>
</AppShell>
</template>
<style scoped>
.home {
position: relative;
flex: 1;
display: grid;
place-items: center;
padding: 3rem 1.25rem 2rem;
overflow: hidden;
}
.home__bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 10%, #c8e0d4 0%, transparent 55%),
radial-gradient(ellipse 70% 50% at 90% 80%, #d9cfc0 0%, transparent 50%),
linear-gradient(165deg, #f3f6f4 0%, #e8ebe6 45%, #f7f2ea 100%);
z-index: 0;
}
.home__bg::after {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%232d4a3e' fill-opacity='0.04'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
opacity: 0.9;
}
.hero {
position: relative;
z-index: 1;
width: min(560px, 100%);
text-align: center;
animation: rise 0.7s ease both;
}
.hero__brand {
font-family: var(--font-display);
font-size: clamp(2.4rem, 6vw, 3.4rem);
font-weight: 600;
letter-spacing: -0.02em;
color: var(--ink);
margin: 0 0 1rem;
line-height: 1.1;
}
.hero__title {
font-family: var(--font-sans);
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 0.65rem;
color: var(--ink-soft);
}
.hero__lead {
margin: 0 0 1.75rem;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.55;
}
.drop {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.45rem;
padding: 2.25rem 1.5rem;
border: 1.5px dashed color-mix(in srgb, var(--accent) 45%, var(--border));
border-radius: 4px;
background: color-mix(in srgb, var(--surface) 70%, transparent);
cursor: pointer;
transition:
border-color 0.25s ease,
background 0.25s ease,
transform 0.25s ease;
}
.drop:hover,
.drop--active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent-soft) 55%, var(--surface));
transform: translateY(-2px);
}
.drop--busy {
opacity: 0.7;
pointer-events: none;
}
.drop__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
overflow: hidden;
}
.drop__label {
font-weight: 600;
color: var(--ink);
}
.drop__hint {
font-size: 0.8rem;
color: var(--muted);
}
.hero__error {
margin: 1rem 0 0;
color: #9b2c2c;
font-size: 0.9rem;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import AppShell from '@/components/AppShell.vue';
import { APP_DISPLAY_NAME, APP_GIT_URL, APP_NAME } from '@/config/app';
const thirdParty = [
{ name: 'Vue', license: 'MIT', url: 'https://github.com/vuejs/core' },
{ name: 'Vue Router', license: 'MIT', url: 'https://github.com/vuejs/router' },
{ name: 'Vite', license: 'MIT', url: 'https://github.com/vitejs/vite' },
{ name: 'Express', license: 'MIT', url: 'https://github.com/expressjs/express' },
{ name: 'jsonwebtoken', license: 'MIT', url: 'https://github.com/auth0/node-jsonwebtoken' },
{ name: 'multer', license: 'MIT', url: 'https://github.com/expressjs/multer' },
{ name: 'uuid', license: 'MIT', url: 'https://github.com/uuidjs/uuid' },
];
</script>
<template>
<AppShell>
<main class="licenses">
<header class="licenses__header">
<RouterLink class="back" to="/">← 홈</RouterLink>
<h1>오픈소스 라이선스 · 고지</h1>
<p>
{{ APP_DISPLAY_NAME }}({{ APP_NAME }})에 포함된 주요 소프트웨어의 라이선스와
고지 사항입니다.
</p>
</header>
<section class="card">
<h2>본 앱</h2>
<p>라이선스: <strong>MIT</strong></p>
<p v-if="APP_GIT_URL">
소스 코드:
<a :href="APP_GIT_URL" target="_blank" rel="noopener noreferrer">{{
APP_GIT_URL
}}</a>
</p>
<p v-else class="muted">
소스 저장소 URL은 <code>APP_GIT_URL</code> 상수에 설정할 수 있습니다.
</p>
</section>
<section class="card">
<h2>ONLYOFFICE Document Server</h2>
<p>
Community Edition은 일반적으로
<strong>GNU AGPL v3</strong>로 배포됩니다. 서버에 Document Server를
배포·수정하는 경우 AGPL 의무(소스 제공 등)를 확인하세요.
</p>
<ul>
<li>
<a
href="https://www.onlyoffice.com/"
target="_blank"
rel="noopener noreferrer"
>onlyoffice.com</a
>
</li>
<li>
<a
href="https://github.com/ONLYOFFICE/DocumentServer"
target="_blank"
rel="noopener noreferrer"
>DocumentServer (GitHub)</a
>
</li>
</ul>
<p class="muted">
ONLYOFFICE는 Ascensio System SIA의 상표입니다. 본 프로젝트는 OnlyOffice와
공식 제휴 관계가 없습니다.
</p>
</section>
<section class="card">
<h2>rhwp (@rhwp/editor, @rhwp/core)</h2>
<p>라이선스: <strong>MIT</strong></p>
<p>
HWP/HWPX 뷰어·에디터 —
<a
href="https://github.com/edwardkim/rhwp"
target="_blank"
rel="noopener noreferrer"
>github.com/edwardkim/rhwp</a
>
</p>
<p class="muted">
“한글”, “한컴”, “HWP”, “HWPX”는 주식회사 한글과컴퓨터의 등록 상표입니다.
rhwp 및 본 앱은 한글과컴퓨터와 제휴·후원·승인 관계가 없는 독립 프로젝트입니다.
</p>
</section>
<section class="card">
<h2>기타 주요 의존성</h2>
<ul class="deps">
<li v-for="item in thirdParty" :key="item.name">
<a :href="item.url" target="_blank" rel="noopener noreferrer">{{
item.name
}}</a>
— {{ item.license }}
</li>
</ul>
</section>
</main>
</AppShell>
</template>
<style scoped>
.licenses {
flex: 1;
width: min(720px, 100%);
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
.licenses__header h1 {
font-family: var(--font-display);
font-size: 1.85rem;
margin: 0.5rem 0 0.5rem;
}
.licenses__header p {
color: var(--muted);
margin: 0 0 1.5rem;
line-height: 1.5;
}
.back {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
}
.card {
padding: 1.25rem 0;
border-top: 1px solid var(--border);
}
.card h2 {
font-size: 1.1rem;
margin: 0 0 0.65rem;
}
.card p,
.card li {
font-size: 0.92rem;
line-height: 1.55;
color: var(--ink-soft);
}
.card a {
color: var(--accent);
}
.muted {
color: var(--muted) !important;
font-size: 0.85rem !important;
}
.deps {
padding-left: 1.1rem;
margin: 0;
}
code {
font-size: 0.85em;
background: var(--accent-soft);
padding: 0.1em 0.35em;
border-radius: 3px;
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import EditorErrorState from '@/components/EditorErrorState.vue';
import { fetchEditorConfig } from '@/api/client';
import { useSessionLifecycle } from '@/composables/useSessionLifecycle';
defineOptions({ name: 'OnlyOfficeEditorView' });
const route = useRoute();
const router = useRouter();
const error = ref<unknown>(null);
const filename = ref('문서');
const frameHost = ref<HTMLElement | null>(null);
let editor: { destroyEditor?: () => void } | null = null;
let scriptEl: HTMLScriptElement | null = null;
let resizeObserver: ResizeObserver | null = null;
let initialized = false;
useSessionLifecycle({ enableHeartbeat: true });
function editorHeightPx(): number {
const host = frameHost.value;
if (host) {
const h = host.clientHeight;
if (h > 200) return h;
}
return Math.max(400, window.innerHeight - 120);
}
function syncIframeSize() {
const iframe = frameHost.value?.querySelector('iframe');
if (iframe) {
iframe.style.height = `${editorHeightPx()}px`;
iframe.style.width = '100%';
}
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (window.DocsAPI) {
resolve();
return;
}
scriptEl = document.createElement('script');
scriptEl.src = src;
scriptEl.async = true;
scriptEl.onload = () => resolve();
scriptEl.onerror = () =>
reject(new Error('OnlyOffice 스크립트를 불러오지 못했습니다.'));
document.head.appendChild(scriptEl);
});
}
async function initEditor() {
if (initialized) return;
const docId = route.params.docId as string;
error.value = null;
try {
const cfg = await fetchEditorConfig(docId);
filename.value = String(cfg.document.title ?? '문서');
const apiUrl = `${cfg.documentServerUrl}/web-apps/apps/api/documents/api.js`;
await loadScript(apiUrl);
await nextTick();
if (!window.DocsAPI) {
throw new Error('DocsAPI를 사용할 수 없습니다.');
}
const height = `${editorHeightPx()}px`;
editor = new window.DocsAPI.DocEditor('onlyoffice-placeholder', {
documentType: cfg.documentType,
document: cfg.document,
editorConfig: cfg.editorConfig,
token: cfg.token,
width: '100%',
height,
type: 'desktop',
});
initialized = true;
if (frameHost.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => syncIframeSize());
resizeObserver.observe(frameHost.value);
}
} catch (err) {
error.value = err;
}
}
onMounted(() => {
void initEditor();
});
onActivated(() => {
nextTick(() => syncIframeSize());
});
onUnmounted(() => {
resizeObserver?.disconnect();
resizeObserver = null;
editor?.destroyEditor?.();
editor = null;
initialized = false;
});
function goHome() {
void router.push({ name: 'home' });
}
</script>
<template>
<AppShell :compact-footer="true">
<div class="editor-page">
<header v-if="!error" class="bar">
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome">
← 홈
</button>
<div class="bar__meta">
<strong>{{ filename }}</strong>
<span>
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요.
<em>파일 → 저장</em>
</span>
</div>
</header>
<EditorErrorState v-if="error" :error="error" context="onlyoffice" />
<div v-show="!error" ref="frameHost" class="editor-frame">
<div id="onlyoffice-placeholder" class="editor-frame__inner" />
</div>
</div>
</AppShell>
</template>
<style scoped>
.editor-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.bar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
z-index: 2;
}
.bar__meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 12rem;
}
.bar__meta strong {
font-size: 0.95rem;
}
.bar__meta span {
font-size: 0.75rem;
color: var(--muted);
}
.bar__meta em {
font-style: normal;
font-weight: 600;
color: var(--accent);
white-space: nowrap;
}
.bar__btn {
border: none;
padding: 0.5rem 0.95rem;
border-radius: 3px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.bar__btn--ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--border);
}
.editor-frame {
flex: 1 1 auto;
min-height: 0;
position: relative;
width: 100%;
}
.editor-frame__inner {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.editor-frame__inner :deep(iframe),
.editor-frame :deep(iframe) {
width: 100% !important;
height: 100% !important;
border: none;
}
</style>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { createEditor, type RhwpEditor } from '@rhwp/editor';
import AppShell from '@/components/AppShell.vue';
import EditorErrorState from '@/components/EditorErrorState.vue';
import { ApiError } from '@/api/client';
import {
clearHwpFile,
loadHwpFile,
useSessionLifecycle,
} from '@/composables/useSessionLifecycle';
defineOptions({ name: 'RhwpEditorView' });
const router = useRouter();
const error = ref<unknown>(null);
const filename = ref('문서.hwp');
let editor: RhwpEditor | null = null;
let initialized = false;
useSessionLifecycle({ enableHeartbeat: true });
onMounted(async () => {
if (initialized) return;
const stored = loadHwpFile();
if (!stored) {
error.value = new ApiError(
404,
'not_found',
'열린 HWP 파일이 없습니다. 홈에서 다시 업로드해 주세요.',
);
return;
}
filename.value = stored.name;
error.value = null;
try {
editor = await createEditor('#rhwp-host', {
width: '100%',
height: '100%',
});
await editor.loadFile(stored.buffer, stored.name);
initialized = true;
} catch (err) {
error.value = err;
}
});
onUnmounted(() => {
editor?.destroy();
editor = null;
initialized = false;
});
function goHome() {
clearHwpFile();
void router.push({ name: 'home' });
}
</script>
<template>
<AppShell :compact-footer="true">
<div class="editor-page">
<header v-if="!error" class="bar">
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome">
← 홈
</button>
<div class="bar__meta">
<strong>{{ filename }}</strong>
<span>
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요.
<em>파일 → 저장</em>
(조판은 한컴과 다를 수 있습니다)
</span>
</div>
</header>
<EditorErrorState v-if="error" :error="error" context="rhwp" />
<div v-show="!error" id="rhwp-host" class="editor-frame" />
</div>
</AppShell>
</template>
<style scoped>
.editor-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.bar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.bar__meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 12rem;
}
.bar__meta strong {
font-size: 0.95rem;
}
.bar__meta span {
font-size: 0.75rem;
color: var(--muted);
}
.bar__meta em {
font-style: normal;
font-weight: 600;
color: var(--accent);
white-space: nowrap;
}
.bar__btn {
border: none;
padding: 0.5rem 0.95rem;
border-radius: 3px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.bar__btn--ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--border);
}
.editor-frame {
flex: 1 1 auto;
min-height: 0;
position: relative;
width: 100%;
}
.editor-frame :deep(iframe) {
width: 100% !important;
height: 100% !important;
border: none;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
/// <reference types="vite/client" />
declare module '@rhwp/editor' {
export type RhwpEditor = {
loadFile: (
data: ArrayBuffer | Uint8Array,
fileName?: string,
) => Promise<{ pageCount: number }>;
pageCount: () => Promise<number>;
getPageSvg: (page?: number) => Promise<string>;
exportHwp: () => Promise<Uint8Array>;
exportHwpx: () => Promise<Uint8Array>;
destroy: () => void;
};
export function createEditor(
container: string | HTMLElement,
options?: {
studioUrl?: string;
width?: string;
height?: string;
},
): Promise<RhwpEditor>;
}
interface Window {
DocsAPI?: {
DocEditor: new (
id: string,
config: Record<string, unknown>,
) => { destroyEditor?: () => void };
};
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'node:path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: path.resolve(__dirname, '../backend/public'),
emptyOutDir: true,
},
});