first commit
This commit is contained in:
commit
a74fe7a388
42 files changed
+6058
No files matched your search
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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 기준이며, 복잡한 공공문서에서 한컴과 조판이 다를 수 있습니다.
|
||||||
@@ -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:
|
||||||
Generated
+3442
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Vendored
+33
@@ -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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user