package handlers import ( "archive/zip" "fmt" "io" "net/http" "os" "path/filepath" "git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/middleware" "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v5" ) 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"}) } // 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 // Download modified file from Document Server resp, err := http.Get(downloadUrl) 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}) } // 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 download token if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, ""); err != nil { return c.Status(401).JSON(fiber.Map{"error": "unauthorized"}) } // Jail the path to the storage directory cleaned := filepath.Clean(path) if filepath.IsAbs(cleaned) || cleaned == ".." || len(cleaned) > 2 && cleaned[:3] == ".." + string(filepath.Separator) { return c.Status(403).JSON(fiber.Map{"error": "invalid path"}) } fullPath := filepath.Join(h.Config.StorageDir, cleaned) 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" } } 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 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) return c.JSON(fiber.Map{ "path": filepath.ToSlash(relativePath), "name": filepath.Base(finalPath), }) } // 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) } 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/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 } // 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, }) }