diff --git a/.env.example b/.env.example index 7062f4e..045a9ee 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -# Shared JWT secret (must match OnlyOffice Document Server) +# Shared JWT secret (must match Euro-Office 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 +ONLYOFFICE_INTERNAL_URL=http://documentserver # Session SESSION_TTL_MS=900000 diff --git a/Dockerfile b/Dockerfile index 02997a1..faaa383 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ 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 +COPY --from=build /app/packages/backend/templates ./packages/backend/templates WORKDIR /app/packages/backend ENV TEMP_DIR=/tmp/sejong diff --git a/README.md b/README.md index 49dc5a9..c4bdc8f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 임시 세션 기반 문서 편집 웹앱입니다. -- **오피스 / PDF** → [ONLYOFFICE Document Server](https://www.onlyoffice.com/) +- **오피스 / PDF** → [Euro-Office Document Server](https://github.com/Euro-Office/DocumentServer) - **HWP / HWPX** → [rhwp](https://github.com/edwardkim/rhwp) (`@rhwp/editor`) 서버에 문서를 **영구 저장하지 않습니다**. 오피스·PDF는 세션 동안만 tmpfs에 두고, 브라우저 종료·TTL로 삭제합니다. HWP/HWPX는 기본적으로 브라우저 메모리에서만 다룹니다. 편집 결과는 **다운로드**로만 가져가세요. @@ -40,10 +40,10 @@ npm run dev:backend npm run dev:frontend ``` -OnlyOffice Document Server는 Docker로만 띄워도 됩니다. +Euro-Office Document Server는 Docker로만 띄워도 됩니다. ```bash -docker compose up onlyoffice +docker compose up documentserver ``` ## 소스 저장소 링크 @@ -52,10 +52,10 @@ docker compose up onlyoffice ## 라이선스 고지 -앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 OnlyOffice(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다. +앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 Euro-Office(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다. - 본 앱: MIT -- ONLYOFFICE Document Server Community: AGPL-3.0 (배포 시 의무 확인) +- Euro-Office Document Server: AGPL-3.0 (배포 시 의무 확인) - rhwp: MIT — Hancom 상표와 무관한 독립 프로젝트 ## 포맷 안내 @@ -63,6 +63,6 @@ docker compose up onlyoffice | 확장자 | 엔진 | 저장 | |--------|------|------| | hwp, hwpx | rhwp | 원본 확장자로 export | -| pdf, docx, xlsx, pptx 등 | OnlyOffice | 원본 확장자 유지 시도 (`assemblyFormatAsOrigin`) | +| pdf, docx, xlsx, pptx 등 | Euro-Office | 원본 확장자 유지 시도 (`forcesave` + ConvertService) | rhwp는 v0.7.x 기준이며, 복잡한 공공문서에서 한컴과 조판이 다를 수 있습니다. diff --git a/docker-compose.yml b/docker-compose.yml index 762967a..93a1a8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: 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} + ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://documentserver} SESSION_TTL_MS: ${SESSION_TTL_MS:-900000} TEMP_DIR: /tmp/sejong PORT: 3000 @@ -16,11 +16,11 @@ services: tmpfs: - /tmp/sejong:size=512m,mode=1777 depends_on: - - onlyoffice + - documentserver restart: unless-stopped - onlyoffice: - image: onlyoffice/documentserver:8.3 + documentserver: + image: ghcr.io/euro-office/documentserver:v9.3.2 ports: - '8080:80' environment: @@ -30,8 +30,8 @@ services: JWT_IN_BODY: 'true' volumes: # Persist DS license/cache lightly; document content still lives in backend tmpfs - - onlyoffice_data:/var/www/onlyoffice/Data + - documentserver_data:/var/www/onlyoffice/Data restart: unless-stopped volumes: - onlyoffice_data: + documentserver_data: diff --git a/packages/backend/src/routes/documents.ts b/packages/backend/src/routes/documents.ts index be505fe..6259460 100644 --- a/packages/backend/src/routes/documents.ts +++ b/packages/backend/src/routes/documents.ts @@ -13,6 +13,10 @@ import { buildEditorConfig, convertToOriginalFormat, } from '../services/onlyOffice.js'; +import { + createBlankDocument, + isBlankType, +} from '../services/blankDocument.js'; import { extensionOf } from '../utils/formats.js'; import { decodeUploadFilename } from '../utils/filename.js'; import { extractToken, verifyOnlyOfficeToken } from '../utils/jwt.js'; @@ -24,6 +28,34 @@ const upload = multer({ export const documentsRouter = Router(); +documentsRouter.post('/new', requireSession, async (req, res) => { + const type = typeof req.body?.type === 'string' ? req.body.type.trim() : ''; + if (!isBlankType(type)) { + res.status(400).json({ + error: 'unsupported_type', + message: '지원 형식: docx, xlsx, pptx, pdf, hwpx', + }); + return; + } + + try { + const doc = await createBlankDocument( + res.locals.sessionId as string, + type, + ); + res.json({ + docId: doc.docId, + filename: doc.filename, + fileType: doc.fileType, + documentType: doc.documentType, + token: doc.token, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'create_failed' }); + } +}); + documentsRouter.post( '/', requireSession, diff --git a/packages/backend/src/services/blankDocument.ts b/packages/backend/src/services/blankDocument.ts new file mode 100644 index 0000000..7f03825 --- /dev/null +++ b/packages/backend/src/services/blankDocument.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { addDocument } from './sessionStore.js'; +import type { SessionDoc } from '../types/session.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const BLANK_TYPES = ['docx', 'xlsx', 'pptx', 'pdf', 'hwpx'] as const; +export type BlankType = (typeof BLANK_TYPES)[number]; + +export function isBlankType(value: string): value is BlankType { + return (BLANK_TYPES as readonly string[]).includes(value); +} + +/** Resolve templates dir for both tsx (src) and compiled dist. */ +async function templatesDir(): Promise { + const candidates = [ + path.resolve(__dirname, '../../templates'), + path.resolve(__dirname, '../templates'), + ]; + for (const dir of candidates) { + try { + await fs.access(dir); + return dir; + } catch { + /* try next */ + } + } + return candidates[0]; +} + +export async function createBlankDocument( + sessionId: string, + type: BlankType, +): Promise { + const dir = await templatesDir(); + const templatePath = path.join(dir, `blank.${type}`); + const buffer = await fs.readFile(templatePath); + const filename = `새문서.${type}`; + return addDocument(sessionId, filename, type, buffer); +} diff --git a/packages/backend/src/utils/formats.ts b/packages/backend/src/utils/formats.ts index 6aeed48..cbe9f56 100644 --- a/packages/backend/src/utils/formats.ts +++ b/packages/backend/src/utils/formats.ts @@ -78,6 +78,8 @@ export function mimeFor(ext: string): string { rtf: 'application/rtf', txt: 'text/plain', csv: 'text/csv', + hwp: 'application/x-hwp', + hwpx: 'application/hwp+zip', }; return map[ext] ?? 'application/octet-stream'; } diff --git a/packages/backend/templates/blank.docx b/packages/backend/templates/blank.docx new file mode 100644 index 0000000..5ad0f33 Binary files /dev/null and b/packages/backend/templates/blank.docx differ diff --git a/packages/backend/templates/blank.hwpx b/packages/backend/templates/blank.hwpx new file mode 100644 index 0000000..4e25d9f Binary files /dev/null and b/packages/backend/templates/blank.hwpx differ diff --git a/packages/backend/templates/blank.pdf b/packages/backend/templates/blank.pdf new file mode 100644 index 0000000..90eac1c --- /dev/null +++ b/packages/backend/templates/blank.pdf @@ -0,0 +1,18 @@ +%PDF-1.4 +1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj +2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj +3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>endobj +4 0 obj<< /Length 0 >>stream +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000229 00000 n +trailer<< /Size 5 /Root 1 0 R >> +startxref +279 +%%EOF diff --git a/packages/backend/templates/blank.pptx b/packages/backend/templates/blank.pptx new file mode 100644 index 0000000..84ba0ad Binary files /dev/null and b/packages/backend/templates/blank.pptx differ diff --git a/packages/backend/templates/blank.xlsx b/packages/backend/templates/blank.xlsx new file mode 100644 index 0000000..341fa57 Binary files /dev/null and b/packages/backend/templates/blank.xlsx differ diff --git a/packages/frontend/index.html b/packages/frontend/index.html index f860ad7..67dc159 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -7,12 +7,12 @@ diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index efb48b9..1ecdcc8 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -66,6 +66,39 @@ export async function uploadDocument(file: File): Promise<{ ); } +export type BlankDocumentType = 'docx' | 'xlsx' | 'pptx' | 'pdf' | 'hwpx'; + +export async function createBlankDocument(type: BlankDocumentType): Promise<{ + docId: string; + filename: string; + fileType: string; + documentType: string; + token: string; +}> { + return parseJson( + await fetch('/api/documents/new', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type }), + }), + ); +} + +export async function fetchDocumentContent( + docId: string, + token: string, +): Promise { + const res = await fetch( + `/api/documents/${docId}/content?token=${encodeURIComponent(token)}`, + { credentials: 'include' }, + ); + if (!res.ok) { + throw new ApiError(res.status, 'content_failed', '문서를 불러오지 못했습니다.'); + } + return res.arrayBuffer(); +} + export type EditorConfigResponse = { documentType: string; document: Record; diff --git a/packages/frontend/src/components/AppFooter.vue b/packages/frontend/src/components/AppFooter.vue deleted file mode 100644 index 9b8b27d..0000000 --- a/packages/frontend/src/components/AppFooter.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/packages/frontend/src/components/AppShell.vue b/packages/frontend/src/components/AppShell.vue index e62ddd1..b348021 100644 --- a/packages/frontend/src/components/AppShell.vue +++ b/packages/frontend/src/components/AppShell.vue @@ -1,21 +1,18 @@ diff --git a/packages/frontend/src/components/EditorToolbar.vue b/packages/frontend/src/components/EditorToolbar.vue new file mode 100644 index 0000000..7d65cc9 --- /dev/null +++ b/packages/frontend/src/components/EditorToolbar.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/packages/frontend/src/components/FileTypeIcon.vue b/packages/frontend/src/components/FileTypeIcon.vue new file mode 100644 index 0000000..57555ac --- /dev/null +++ b/packages/frontend/src/components/FileTypeIcon.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/frontend/src/composables/useOpenDocument.ts b/packages/frontend/src/composables/useOpenDocument.ts new file mode 100644 index 0000000..0ff9ce6 --- /dev/null +++ b/packages/frontend/src/composables/useOpenDocument.ts @@ -0,0 +1,84 @@ +import { ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { isHwp, isOffice } from '@/config/app'; +import { + createBlankDocument, + createSession, + fetchDocumentContent, + uploadDocument, + type BlankDocumentType, +} from '@/api/client'; +import { + storeHwpBuffer, + storeHwpFile, +} from '@/composables/useSessionLifecycle'; + +export function useOpenDocument() { + const router = useRouter(); + const busy = ref(false); + const error = ref(''); + + async function openDocument(file: File | undefined): Promise { + if (!file || busy.value) return false; + error.value = ''; + busy.value = true; + + try { + if (isHwp(file.name)) { + await storeHwpFile(file); + await createSession().catch(() => undefined); + await router.push({ name: 'rhwp-editor' }); + return true; + } + + if (!isOffice(file.name)) { + error.value = + '지원하지 않는 형식입니다. docx, xlsx, pptx, pdf, hwp, hwpx 등을 올려 주세요.'; + return false; + } + + await createSession(); + const { docId } = await uploadDocument(file); + await router.push({ name: 'onlyoffice-editor', params: { docId } }); + return true; + } catch (err) { + error.value = + err instanceof Error ? err.message : '파일을 열 수 없습니다.'; + return false; + } finally { + busy.value = false; + } + } + + async function createAndOpenBlank(type: BlankDocumentType): Promise { + if (busy.value) return false; + error.value = ''; + busy.value = true; + + try { + await createSession(); + const doc = await createBlankDocument(type); + + if (type === 'hwpx') { + const buffer = await fetchDocumentContent(doc.docId, doc.token); + storeHwpBuffer(doc.filename, buffer); + await router.push({ name: 'rhwp-editor' }); + return true; + } + + await router.push({ + name: 'onlyoffice-editor', + params: { docId: doc.docId }, + }); + return true; + } catch (err) { + error.value = + err instanceof Error ? err.message : '새 문서를 만들 수 없습니다.'; + return false; + } finally { + busy.value = false; + } + } + + return { busy, error, openDocument, createAndOpenBlank }; +} diff --git a/packages/frontend/src/composables/useSessionLifecycle.ts b/packages/frontend/src/composables/useSessionLifecycle.ts index 74dade7..a63691f 100644 --- a/packages/frontend/src/composables/useSessionLifecycle.ts +++ b/packages/frontend/src/composables/useSessionLifecycle.ts @@ -26,30 +26,27 @@ export function bindSessionPageHideOnce() { } export function storeHwpFile(file: File): Promise { - 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); + return file.arrayBuffer().then((buffer) => { + storeHwpBuffer(file.name, buffer); }); } +export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void { + 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, + base64: btoa(binary), + }), + ); +} + export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null { const raw = sessionStorage.getItem(HWP_FILE_KEY); if (!raw) return null; diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index 0b93c7d..8cab22d 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -5,7 +5,7 @@ import { extensionOf } from '@/config/app'; -type FaviconKind = +export type FaviconKind = | 'home' | 'pdf' | 'word' @@ -16,7 +16,7 @@ type FaviconKind = | 'csv' | 'file'; -const PATHS: Record = { +export const ICON_PATHS: Record = { home: 'M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z', pdf: 'M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z', word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z', @@ -30,8 +30,8 @@ const PATHS: Record = { file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', }; -const COLORS: Record = { - home: '#2f6b55', +export const ICON_COLORS: Record = { + home: '#0f6cbd', pdf: '#e53935', word: '#1e88e5', excel: '#43a047', @@ -64,12 +64,16 @@ const EXT_KIND: Record = { }; function toDataUri(kind: FaviconKind): string { - const svg = ``; + const svg = ``; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } const CACHE = new Map(); +export function iconKindForExtension(ext: string): FaviconKind { + return EXT_KIND[ext] ?? 'file'; +} + export function faviconHrefFor(filename?: string | null): string { const name = filename?.trim(); let kind: FaviconKind = 'home'; diff --git a/packages/frontend/src/styles.css b/packages/frontend/src/styles.css index 86d5e2c..7084ad8 100644 --- a/packages/frontend/src/styles.css +++ b/packages/frontend/src/styles.css @@ -1,14 +1,35 @@ :root { - --ink: #1c2b24; - --ink-soft: #2f4038; - --muted: #5c6b63; - --accent: #2f6b55; - --accent-soft: #d7ebe2; - --surface: #faf9f6; - --canvas: #eef1ee; - --border: #d0d7d2; + --ink: #242424; + --ink-soft: #424242; + --muted: #616161; + --accent: #0f6cbd; + --accent-soft: #e8f3fc; + --surface: #ffffff; + --canvas: #f3f3f3; + --border: #e0e0e0; + --card: #ffffff; + --card-hover: #f5f5f5; + --danger: #c62828; --font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif; - --font-display: 'Newsreader', 'IBM Plex Sans KR', serif; + --font-display: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #f5f5f5; + --ink-soft: #e0e0e0; + --muted: #a0a0a0; + --accent: #4da3ff; + --accent-soft: #1a2f42; + --surface: #2b2b2b; + --canvas: #1f1f1f; + --border: #3a3a3a; + --card: #2b2b2b; + --card-hover: #333333; + --danger: #ef9a9a; + color-scheme: dark; + } } *, diff --git a/packages/frontend/src/utils/downloadBlob.ts b/packages/frontend/src/utils/downloadBlob.ts new file mode 100644 index 0000000..7d437bc --- /dev/null +++ b/packages/frontend/src/utils/downloadBlob.ts @@ -0,0 +1,18 @@ +export function downloadBlob( + data: Blob | ArrayBuffer | Uint8Array, + filename: string, +) { + const blob = + data instanceof Blob + ? data + : new Blob([data instanceof Uint8Array ? data.slice() : data]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/packages/frontend/src/views/HomeView.vue b/packages/frontend/src/views/HomeView.vue index b11babf..ab5dc5e 100644 --- a/packages/frontend/src/views/HomeView.vue +++ b/packages/frontend/src/views/HomeView.vue @@ -1,72 +1,57 @@