diff --git a/backend/handlers/onlyoffice.go b/backend/handlers/onlyoffice.go new file mode 100644 index 0000000..2f78e1c --- /dev/null +++ b/backend/handlers/onlyoffice.go @@ -0,0 +1,129 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "git.elijahkuntz.com/Elijah/drive/config" + "git.elijahkuntz.com/Elijah/drive/middleware" + "github.com/gofiber/fiber/v2" +) + +type OnlyOfficeHandler struct { + Config *config.Config +} + +type CallbackRequest struct { + Status int `json:"status"` + Url string `json:"url"` + Key string `json:"key"` +} + +func (h *OnlyOfficeHandler) Callback(c *fiber.Ctx) error { + path := c.Query("path") + token := c.Query("token") + + if path == "" { + return c.Status(400).SendString("path required") + } + + // Validate the token to ensure the callback is authorized + if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, ""); err != nil { + return c.Status(401).SendString("unauthorized") + } + + var req CallbackRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid json"}) + } + + // Status 2: Document is ready for saving + // Status 6: Document is being edited, force save was triggered + if req.Status == 2 || req.Status == 6 { + if req.Url == "" { + return c.Status(400).JSON(fiber.Map{"error": "url required"}) + } + + // Download modified file from Document Server + resp, err := http.Get(req.Url) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "failed to download file"}) + } + defer resp.Body.Close() + + targetPath := filepath.Join(h.Config.StorageDir, path) + + // Save file + out, err := os.Create(targetPath) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "failed to open file for writing"}) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "failed to write file"}) + } + } + + // Send successful response according to OnlyOffice API + return c.JSON(fiber.Map{"error": 0}) +} + +// CreateBlank creates an empty document for Docs or Slides mode +func (h *OnlyOfficeHandler) CreateBlank(c *fiber.Ctx) error { + req := new(struct { + Type string `json:"type"` + Name string `json:"name"` + }) + + if err := c.BodyParser(req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid request"}) + } + + if req.Name == "" { + req.Name = "Untitled Document" + if req.Type == "slide" { + req.Name = "Untitled Presentation" + } + } + + ext := ".docx" + if req.Type == "slide" { + ext = ".pptx" + } + + // Add extension if missing + if filepath.Ext(req.Name) != ext { + req.Name += ext + } + + // Make sure the file name doesn't collide + basePath := filepath.Join(h.Config.StorageDir, req.Name) + finalPath := basePath + counter := 1 + for { + if _, err := os.Stat(finalPath); os.IsNotExist(err) { + break + } + finalPath = filepath.Join(h.Config.StorageDir, fmt.Sprintf("%s (%d)%s", req.Name[:len(req.Name)-len(ext)], counter, ext)) + counter++ + } + + // Create an empty file. OnlyOffice will initialize it when opened. + f, err := os.Create(finalPath) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "failed to create file"}) + } + f.Close() + + // Return the relative path + relativePath, _ := filepath.Rel(h.Config.StorageDir, finalPath) + + return c.JSON(fiber.Map{ + "path": filepath.ToSlash(relativePath), + "name": filepath.Base(finalPath), + }) +} diff --git a/backend/main.go b/backend/main.go index 8578135..f08785b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -69,6 +69,7 @@ func main() { archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg} shareGuard := middleware.NewBruteForceGuard(3, 60) shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard} + onlyOfficeHandler := &handlers.OnlyOfficeHandler{Config: cfg} // Initialize background workers workers.InitTaskManager(cfg) @@ -135,6 +136,9 @@ func main() { public.Post("/share/:id", shareGuard.Check(), shareHandler.GetPublicShare) public.Get("/download/:id", shareHandler.DownloadPublicShare) + // OnlyOffice callback (public but validates query token) + public.Post("/onlyoffice/callback", onlyOfficeHandler.Callback) + // Auth routes (with brute force protection) auth := app.Group("/api/auth") auth.Post("/login", guard.Check(), authHandler.Login) @@ -184,6 +188,9 @@ func main() { protected.Post("/files/upload", fsHandler.Upload) protected.Post("/files/check-conflicts", fsHandler.CheckConflicts) + // OnlyOffice blanks + protected.Post("/onlyoffice/create", onlyOfficeHandler.CreateBlank) + protected.Get("/files/folders-tree", fsHandler.GetFolderTree) // File listing and download (with path jail) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ad628dd..8749136 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "drive-frontend", "version": "1.0.0", "dependencies": { + "@onlyoffice/document-editor-react": "^2.2.0", "@types/react-syntax-highlighter": "^15.5.13", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", @@ -988,6 +989,27 @@ "node": ">= 8" } }, + "node_modules/@onlyoffice/doceditor-types": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@onlyoffice/doceditor-types/-/doceditor-types-9.3.1.tgz", + "integrity": "sha512-2RZEyes+TA/1PbxSGKU8kMQn0upImDgmA3osqvFux+KrUfAW41yB26ZZmA9zKGR57jYiyOuUBtSvFQsXn8C2dw==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@onlyoffice/document-editor-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@onlyoffice/document-editor-react/-/document-editor-react-2.2.0.tgz", + "integrity": "sha512-SPgeJ1lK+I5mpcCUi6Qz98QVtlZR6axxCArgHoKNNBfFqXNmb6iDOf5Z1QUiVQ0xr57oo84D0ptyXZaF8K51nA==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "4.18.1" + }, + "peerDependencies": { + "@onlyoffice/doceditor-types": ">=9.3.1", + "react": "^16.9.0 || ^17 || ^18 || ^19", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1819,6 +1841,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash._baseiteratee": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3296a18..482cedf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@onlyoffice/document-editor-react": "^2.2.0", "@types/react-syntax-highlighter": "^15.5.13", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index c5b3389..6681c74 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -8,7 +8,7 @@ import { MoreVertical, Star, Download, Pencil, Copy, Move, FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon, Home, Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight, - CheckCircle, AlertCircle, Menu + CheckCircle, AlertCircle, Menu, FileText, Presentation } from 'lucide-react'; import api from '@/lib/api'; import { useIsMobile } from '@/hooks/useIsMobile'; @@ -26,6 +26,8 @@ import TaskManager from './TaskManager'; import ShareModal from './ShareModal'; import FileInfoModal from './FileInfoModal'; import SharedFilesPage from './SharedFilesPage'; +import OfficeHome from './OfficeHome'; +import OnlyOfficeEditor from './OnlyOfficeEditor'; interface FileItem { name: string; @@ -237,6 +239,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useState('grid'); + const [appMode, setAppMode] = useState<'drive' | 'docs' | 'slides'>('drive'); + const [editingFile, setEditingFile] = useState(null); const [activeSection, setActiveSection] = useState('files'); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); @@ -548,8 +552,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { return next; }); } else { - if (file.is_dir) navigateToFolder(file); - else setPreviewFile(file); + if (file.is_dir) { + navigateToFolder(file); + } else if (file.name.endsWith('.docx')) { + setAppMode('docs'); + setEditingFile(file); + } else if (file.name.endsWith('.pptx')) { + setAppMode('slides'); + setEditingFile(file); + } else { + setPreviewFile(file); + } } } @@ -1300,22 +1313,54 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { borderColor: 'var(--color-border-subtle)', }} > -
-
+
- +
+ +
Drive -
+ + + + +
@@ -1481,6 +1562,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
) : activeSection === 'shared' ? ( + ) : editingFile ? ( + setEditingFile(null)} + /> + ) : (appMode === 'docs' || appMode === 'slides') && activeSection === 'files' ? ( + setEditingFile(file)} + onCreateBlank={(file) => setEditingFile(file)} + /> ) : (
void; + onCreateBlank: (file: FileItem) => void; +} + +export default function OfficeHome({ type, onFileSelect, onCreateBlank }: OfficeHomeProps) { + const [recentFiles, setRecentFiles] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Fetch all files from root directory (as a simple way to find recent files of this type) + // Or we could use a search endpoint. For now we will fetch the root directory and filter. + api.listFiles('.') + .then(files => { + const ext = type === 'docs' ? '.docx' : '.pptx'; + const filtered = files + .filter(f => !f.is_dir && f.name.endsWith(ext) && !f.is_trashed) + .sort((a, b) => new Date(b.mod_time).getTime() - new Date(a.mod_time).getTime()) + .slice(0, 10); + setRecentFiles(filtered); + setLoading(false); + }) + .catch(err => { + console.error(err); + setLoading(false); + }); + }, [type]); + + const handleCreateBlank = async () => { + try { + const res = await api.client.post('/onlyoffice/create', { type: type === 'docs' ? 'word' : 'slide' }); + const { path, name } = res.data; + + const newFile: FileItem = { + name, + path, + is_dir: false, + size: 0, + mime_type: type === 'docs' ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' : 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + is_pinned: false, + is_trashed: false, + mod_time: new Date().toISOString() + }; + + onCreateBlank(newFile); + } catch (err) { + console.error("Failed to create document", err); + } + }; + + const title = type === 'docs' ? 'Documents' : 'Presentations'; + const Icon = type === 'docs' ? FileText : Presentation; + const colorClass = type === 'docs' ? 'text-blue-500' : 'text-amber-500'; + + return ( +
+ {/* Top Banner (Template Gallery placeholder) */} +
+
+

Start a new document

+
+ {/* Blank Document */} +
+ + Blank +
+
+
+
+ + {/* Recent Documents */} +
+
+

Recent {title.toLowerCase()}

+ + {loading ? ( +
Loading...
+ ) : recentFiles.length === 0 ? ( +
No recent {title.toLowerCase()} found.
+ ) : ( +
+ {recentFiles.map(file => ( +
+ +
+ + + {file.name} + +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/OnlyOfficeEditor.tsx b/frontend/src/components/OnlyOfficeEditor.tsx new file mode 100644 index 0000000..df5ba62 --- /dev/null +++ b/frontend/src/components/OnlyOfficeEditor.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; +import { DocumentEditor } from '@onlyoffice/document-editor-react'; +import api from '@/lib/api'; + +interface FileItem { + name: string; + path: string; + is_dir: boolean; + size: number; + mime_type: string; + is_pinned: boolean; + is_trashed: boolean; + mod_time: string; +} + +interface OnlyOfficeEditorProps { + file: FileItem; + type: 'word' | 'cell' | 'slide'; + onClose: () => void; +} + +export default function OnlyOfficeEditor({ file, type, onClose }: OnlyOfficeEditorProps) { + const [downloadToken, setDownloadToken] = useState(null); + + useEffect(() => { + // We need a short-lived download token to pass to the Document Server + api.client.get('/auth/download-token') + .then(res => setDownloadToken(res.data.token)) + .catch(console.error); + }, [file.path]); + + if (!downloadToken) { + return ( +
+
Loading Editor...
+
+ ); + } + + const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + + // URL that OnlyOffice will use to download the file + const fileUrl = `${API_BASE}/api/files/download/${encodeURIComponent(file.path)}?token=${downloadToken}`; + + // Callback URL that OnlyOffice will post to when saving + const callbackUrl = `${API_BASE}/api/public/onlyoffice/callback?path=${encodeURIComponent(file.path)}&token=${downloadToken}`; + + const config = { + document: { + fileType: file.name.split('.').pop() || 'docx', + key: `${file.path}_${new Date(file.mod_time).getTime()}`, + title: file.name, + url: fileUrl, + }, + documentType: type, + editorConfig: { + callbackUrl: callbackUrl, + mode: 'edit', + customization: { + forcesave: true, + goback: { + url: '', + } + } + }, + }; + + const onDocumentReady = function (event: any) { + console.log("Document is loaded"); + }; + + const onLoadComponentError = function (errorCode: number, errorDescription: string) { + switch (errorCode) { + case -1: // Unknown error loading component + console.log(errorDescription); + break; + case -2: // Error load DocsAPI from http://documentserver/ + console.log(errorDescription); + break; + case -3: // DocsAPI is not defined + console.log(errorDescription); + break; + } + }; + + return ( +
+
+ {file.name} + +
+
+ +
+
+ ); +}