Feat: Live thumbnails for office files
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m26s

This commit is contained in:
Elijah 2026-05-25 14:43:04 -07:00
parent b80abdbefb
commit 80d60d540a
10 changed files with 244 additions and 106 deletions

View file

@ -13,6 +13,13 @@ import (
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"github.com/disintegration/imaging"
"git.elijahkuntz.com/Elijah/drive/middleware"
"net/url"
"io"
"net/http"
"encoding/json"
"bytes"
"github.com/golang-jwt/jwt/v5"
)
type ThumbnailJob struct {
@ -70,14 +77,6 @@ func (m *ThumbnailManager) Enqueue(job ThumbnailJob) {
func (m *ThumbnailManager) worker(id int) {
defer m.wg.Done()
for job := range m.Queue {
// Check if thumbnail generation is enabled
if val, err := m.DB.GetSetting("thumbnail_images"); err == nil && val == "false" && strings.HasPrefix(job.MimeType, "image/") {
continue
}
if val, err := m.DB.GetSetting("thumbnail_videos"); err == nil && val == "false" && strings.HasPrefix(job.MimeType, "video/") {
continue
}
destPath := filepath.Join(m.Config.ThumbnailDir, job.Checksum+".jpg")
// Skip if thumbnail already exists
@ -92,6 +91,10 @@ func (m *ThumbnailManager) worker(id int) {
err = generateImageThumbnail(fullSourcePath, destPath)
} else if strings.HasPrefix(job.MimeType, "video/") {
err = generateVideoThumbnail(fullSourcePath, destPath)
} else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") {
err = generateOfficeThumbnail(job.FilePath, destPath, m.Config)
} else {
continue
}
if err != nil {
@ -131,3 +134,109 @@ func generateVideoThumbnail(src, dest string) error {
}
return nil
}
func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error {
// Generate a download token for the Conversion API to fetch the file from our backend
token, err := middleware.GenerateDownloadToken(0, "system_worker", cfg.JWTSecret)
if err != nil {
return fmt.Errorf("failed to generate download token: %w", err)
}
// Assuming backend runs on port from config, but we need the external URL or internal docker URL that Document Server can reach.
// Since we saw in OnlyOfficeEditor that the Document Server can reach the host at 192.168.50.81:5827, we use that.
// We'll try to build a robust URL.
hostIP := "192.168.50.81" // Hardcoded as per OnlyOfficeEditor.tsx implementation for this specific deployment
port := cfg.Port
fileUrl := fmt.Sprintf("http://%s:%s/api/public/onlyoffice/download?path=%s&token=%s", hostIP, port, url.QueryEscape(filePath), token)
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
if ext == "" {
ext = "docx"
}
payload := map[string]interface{}{
"async": false,
"filetype": ext,
"key": fmt.Sprintf("thumb_%d", time.Now().UnixNano()),
"outputtype": "jpg",
"title": "preview.jpg",
"url": fileUrl,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", "https://office.elijahkuntz.com/ConvertService.ashx", bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Sign the payload with the OnlyOffice JWT
if cfg.OnlyOfficeJWT != "" {
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload))
signedToken, err := jwtToken.SignedString([]byte(cfg.OnlyOfficeJWT))
if err == nil {
req.Header.Set("Authorization", "Bearer "+signedToken)
}
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("conversion request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("conversion request returned status: %d", resp.StatusCode)
}
var result struct {
Error *int `json:"error,omitempty"`
FileUrl string `json:"fileUrl,omitempty"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode conversion response: %w", err)
}
// Wait, the API might return error 0 for success
if result.Error != nil && *result.Error != 0 {
return fmt.Errorf("conversion service returned error code: %d", *result.Error)
}
if result.FileUrl == "" {
return fmt.Errorf("no fileUrl in conversion response")
}
// Download the resulting image
imgResp, err := http.Get(result.FileUrl)
if err != nil {
return fmt.Errorf("failed to download converted image: %w", err)
}
defer imgResp.Body.Close()
if imgResp.StatusCode != http.StatusOK {
return fmt.Errorf("downloading image returned status: %d", imgResp.StatusCode)
}
// Create temp file for the downloaded image
tmpFile, err := os.CreateTemp("", "office_thumb_*.jpg")
if err != nil {
return err
}
tmpName := tmpFile.Name()
defer os.Remove(tmpName)
if _, err := io.Copy(tmpFile, imgResp.Body); err != nil {
tmpFile.Close()
return err
}
tmpFile.Close()
// Resize it using imaging to ensure it matches standard thumbnail sizes
return generateImageThumbnail(tmpName, destPath)
}