package handlers import ( "archive/zip" "fmt" "io" "os" "path/filepath" "strings" "git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/database" "git.elijahkuntz.com/Elijah/drive/middleware" "git.elijahkuntz.com/Elijah/drive/workers" "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v5" "sync" "time" "mime" ) type OnlyOfficeHandler struct { Config *config.Config DB *database.DB } 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. // First check if it's a long-lived edit token, fallback to short-lived download token. err := middleware.ValidateEditToken(token, h.Config.JWTSecret, "", path) if err != nil { err = middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path) } if 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"}) } // If the server cannot resolve the public URL due to hairpin NAT, // we can rewrite it to use the internal Docker IP downloadUrl := req.Url trustedHosts := []string{h.Config.OnlyOfficeHost} if err := validateCallbackURL(downloadUrl, trustedHosts); err != nil { return c.Status(403).JSON(fiber.Map{"error": "blocked URL: " + err.Error()}) } // Download modified file from Document Server resp, err := SafeHTTPClient.Get(downloadUrl) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "failed to download file"}) } defer resp.Body.Close() targetPath, err := resolveSafe(h.Config.StorageDir, path) if err != nil { return c.Status(403).JSON(fiber.Map{"error": "invalid 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"}) } h.scheduleThumbnailUpdate(targetPath, path) } // Send successful response according to OnlyOffice API return c.JSON(fiber.Map{"error": 0}) } var ( saveDebounceMutex sync.Mutex saveTimers = make(map[string]*time.Timer) ) func (h *OnlyOfficeHandler) scheduleThumbnailUpdate(targetPath, relativePath string) { saveDebounceMutex.Lock() defer saveDebounceMutex.Unlock() if timer, exists := saveTimers[targetPath]; exists { timer.Stop() } saveTimers[targetPath] = time.AfterFunc(10*time.Second, func() { saveDebounceMutex.Lock() delete(saveTimers, targetPath) saveDebounceMutex.Unlock() checksum, err := generateChecksum(targetPath) if err != nil { return } info, err := os.Stat(targetPath) if err != nil { return } h.DB.Exec(` INSERT INTO files (path, name, size, mime_type, checksum) VALUES (?, ?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET checksum=excluded.checksum, size=excluded.size, updated_at=CURRENT_TIMESTAMP`, filepath.ToSlash(relativePath), filepath.Base(relativePath), info.Size(), mime.TypeByExtension(filepath.Ext(relativePath)), checksum, ) workers.Instance.Enqueue(workers.ThumbnailJob{ FilePath: relativePath, Checksum: checksum, MimeType: mime.TypeByExtension(filepath.Ext(relativePath)), }) }) } // DownloadForEditor serves a file to the OnlyOffice Document Server. // This is a public endpoint that validates the download token manually, // bypassing the auth middleware (which can fail when requests are proxied // through Next.js rewrites). func (h *OnlyOfficeHandler) DownloadForEditor(c *fiber.Ctx) error { path := c.Query("path") token := c.Query("token") if path == "" { return c.Status(400).JSON(fiber.Map{"error": "path required"}) } // Validate the token. // Allow both short-lived download tokens and long-lived edit tokens. err := middleware.ValidateEditToken(token, h.Config.JWTSecret, "", path) if err != nil { err = middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path) } if err != nil { return c.Status(401).JSON(fiber.Map{"error": "unauthorized"}) } // Jail the path to the storage directory fullPath, err := resolveSafe(h.Config.StorageDir, path) if err != nil { return c.Status(403).JSON(fiber.Map{"error": "invalid path"}) } info, err := os.Stat(fullPath) if err != nil || info.IsDir() { return c.Status(404).JSON(fiber.Map{"error": "file not found"}) } return c.SendFile(fullPath) } // 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" } else if req.Type == "cell" { req.Name = "Untitled Spreadsheet" } } ext := ".docx" if req.Type == "slide" { ext = ".pptx" } else if req.Type == "cell" { ext = ".xlsx" } // Add extension if missing if filepath.Ext(req.Name) != ext { req.Name += ext } // Create Documents directory if it doesn't exist docsDir := filepath.Join(h.Config.StorageDir, "Documents") os.MkdirAll(docsDir, 0755) // Make sure the file name doesn't collide basePath := filepath.Join(docsDir, req.Name) finalPath := basePath counter := 1 for { if _, err := os.Stat(finalPath); os.IsNotExist(err) { break } finalPath = filepath.Join(docsDir, fmt.Sprintf("%s (%d)%s", req.Name[:len(req.Name)-len(ext)], counter, ext)) counter++ } // Create a valid Office Open XML file (docx/pptx are ZIP archives) if err := createBlankOfficeFile(finalPath, ext); err != nil { return c.Status(500).JSON(fiber.Map{"error": "failed to create file"}) } // Return the relative path relativePath, _ := filepath.Rel(h.Config.StorageDir, finalPath) // Enqueue for thumbnail generation (with 10s debounce) h.scheduleThumbnailUpdate(finalPath, relativePath) return c.JSON(fiber.Map{ "path": filepath.ToSlash(relativePath), "name": filepath.Base(finalPath), }) } // POST /api/onlyoffice/template func (h *OnlyOfficeHandler) CreateFromTemplate(c *fiber.Ctx) error { var req struct { TemplateName string `json:"template"` // e.g., "apa.docx" } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid request"}) } srcPath := filepath.Join("templates", req.TemplateName) if _, err := os.Stat(srcPath); os.IsNotExist(err) { return c.Status(404).JSON(fiber.Map{"error": "template not found"}) } // Figure out a safe name in root directory baseName := "Untitled Document" ext := ".docx" if strings.HasSuffix(req.TemplateName, ".pptx") { baseName = "Untitled Presentation" ext = ".pptx" } if req.TemplateName == "apa.docx" { baseName = "APA Paper" } else if req.TemplateName == "resume.docx" { baseName = "Resume" } // Create Documents directory if it doesn't exist docsDir := filepath.Join(h.Config.StorageDir, "Documents") os.MkdirAll(docsDir, 0755) finalPath := filepath.Join(docsDir, baseName+ext) counter := 1 for { if _, err := os.Stat(finalPath); os.IsNotExist(err) { break } finalPath = filepath.Join(docsDir, fmt.Sprintf("%s (%d)%s", baseName, counter, ext)) counter++ } // Copy the template file srcFile, err := os.Open(srcPath) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "failed to open template"}) } defer srcFile.Close() destFile, err := os.Create(finalPath) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "failed to create destination file"}) } defer destFile.Close() if _, err := io.Copy(destFile, srcFile); err != nil { return c.Status(500).JSON(fiber.Map{"error": "failed to copy template data"}) } relativePath, _ := filepath.Rel(h.Config.StorageDir, finalPath) h.scheduleThumbnailUpdate(finalPath, relativePath) return c.JSON(fiber.Map{ "path": filepath.ToSlash(relativePath), "name": filepath.Base(finalPath), }) } // ListOfficeFiles returns all .docx, .pptx, or .xlsx files across the entire storage directory. // GET /api/onlyoffice/files?type=docx|pptx|xlsx func (h *OnlyOfficeHandler) ListOfficeFiles(c *fiber.Ctx) error { fileType := c.Query("type", "docx") // "docx", "pptx", or "xlsx" ext := ".docx" if fileType == "pptx" { ext = ".pptx" } else if fileType == "xlsx" { ext = ".xlsx" } type OfficeFile struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` ModTime string `json:"mod_time"` Checksum string `json:"checksum,omitempty"` } var results []OfficeFile checksums := make(map[string]string) dbPaths := []string{} rows, err := h.DB.Query("SELECT path, checksum FROM files WHERE checksum IS NOT NULL AND checksum != ''") if err == nil { defer rows.Close() for rows.Next() { var p, c string if rows.Scan(&p, &c) == nil { checksums[p] = c dbPaths = append(dbPaths, p) } } } walkPaths := []string{} filepath.Walk(h.Config.StorageDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if info.IsDir() { base := filepath.Base(path) if strings.HasPrefix(base, ".") && path != h.Config.StorageDir { return filepath.SkipDir } return nil } if strings.HasSuffix(strings.ToLower(info.Name()), ext) { relPath, _ := filepath.Rel(h.Config.StorageDir, path) relPathSlash := filepath.ToSlash(relPath) chk := checksums[relPathSlash] walkPaths = append(walkPaths, relPathSlash) results = append(results, OfficeFile{ Name: info.Name(), Path: relPathSlash, Size: info.Size(), ModTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"), Checksum: chk, }) } return nil }) if results == nil { results = []OfficeFile{} } return c.JSON(fiber.Map{ "files": results, "count": len(results), "debug_db": dbPaths, "debug_walk": walkPaths, }) } // createBlankOfficeFile creates a minimal valid .docx or .pptx file. // These formats are ZIP archives containing XML files following the // Office Open XML (OOXML) standard. func createBlankOfficeFile(path string, ext string) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() w := zip.NewWriter(f) defer w.Close() if ext == ".pptx" { return writePptxContents(w) } else if ext == ".xlsx" { return writeXlsxContents(w) } return writeDocxContents(w) } func writeDocxContents(w *zip.Writer) error { files := map[string]string{ "[Content_Types].xml": ` `, "_rels/.rels": ` `, "word/document.xml": ` `, } for name, content := range files { fw, err := w.Create(name) if err != nil { return err } if _, err := fw.Write([]byte(content)); err != nil { return err } } return nil } func writePptxContents(w *zip.Writer) error { files := map[string]string{ "[Content_Types].xml": ` `, "_rels/.rels": ` `, "ppt/presentation.xml": ` `, "ppt/_rels/presentation.xml.rels": ` `, "ppt/theme/theme1.xml": ` `, "ppt/slideMasters/slideMaster1.xml": ` `, "ppt/slideMasters/_rels/slideMaster1.xml.rels": ` `, "ppt/slideLayouts/slideLayout1.xml": ` `, "ppt/slideLayouts/_rels/slideLayout1.xml.rels": ` `, "ppt/slides/slide1.xml": ` `, "ppt/slides/_rels/slide1.xml.rels": ` `, } for name, content := range files { fw, err := w.Create(name) if err != nil { return err } if _, err := fw.Write([]byte(content)); err != nil { return err } } return nil } func writeXlsxContents(w *zip.Writer) error { files := map[string]string{ "[Content_Types].xml": ` `, "_rels/.rels": ` `, "xl/workbook.xml": ` `, "xl/_rels/workbook.xml.rels": ` `, "xl/worksheets/sheet1.xml": ` `, } for name, content := range files { fw, err := w.Create(name) if err != nil { return err } if _, err := fw.Write([]byte(content)); err != nil { return err } } return nil } // SignConfig takes the OnlyOffice configuration payload and signs it with the JWT secret func (h *OnlyOfficeHandler) SignConfig(c *fiber.Ctx) error { if h.Config.OnlyOfficeJWT == "" { return c.Status(500).JSON(fiber.Map{"error": "JWT secret is not configured on the server"}) } var payload map[string]interface{} if err := c.BodyParser(&payload); err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid json payload"}) } // Create a new JWT token using the HS256 algorithm token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload)) // Sign the token with our secret signedToken, err := token.SignedString([]byte(h.Config.OnlyOfficeJWT)) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "failed to sign token"}) } return c.JSON(fiber.Map{ "token": signedToken, }) }