This repository has been archived on 2026-07-15. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
drive/backend/handlers/onlyoffice.go
Elijah fd991a1d4b
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m20s
Add public OnlyOffice download endpoint to bypass auth middleware 401 issue
2026-05-24 12:48:03 -07:00

320 lines
12 KiB
Go

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": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`,
"word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t></w:t></w:r></w:p>
</w:body>
</w:document>`,
}
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": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
</Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
</Relationships>`,
"ppt/presentation.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst>
<p:sldSz cx="9144000" cy="6858000" type="screen4x3"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>`,
"ppt/_rels/presentation.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
</Relationships>`,
"ppt/slideMasters/slideMaster1.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
</p:spTree></p:cSld>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
</p:sldMaster>`,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`,
"ppt/slideLayouts/slideLayout1.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" type="blank">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
</p:spTree></p:cSld>
</p:sldLayout>`,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`,
"ppt/slides/slide1.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
</p:spTree></p:cSld>
</p:sld>`,
"ppt/slides/_rels/slide1.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`,
}
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,
})
}