diff --git a/backend/database/database.go b/backend/database/database.go
index c623901..860f907 100644
--- a/backend/database/database.go
+++ b/backend/database/database.go
@@ -140,8 +140,6 @@ func (db *DB) migrate() error {
END`,
`INSERT OR IGNORE INTO settings (key, value) VALUES ('theme', 'dark')`,
- `INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_images', 'true')`,
- `INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_videos', 'true')`,
`INSERT OR IGNORE INTO settings (key, value) VALUES ('trash_auto_purge_days', '30')`,
}
diff --git a/backend/handlers/onlyoffice.go b/backend/handlers/onlyoffice.go
index a49ad99..95b8726 100644
--- a/backend/handlers/onlyoffice.go
+++ b/backend/handlers/onlyoffice.go
@@ -10,13 +10,19 @@ import (
"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 {
@@ -73,12 +79,51 @@ func (h *OnlyOfficeHandler) Callback(c *fiber.Ctx) error {
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("UPDATE files SET checksum = ?, size = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?", checksum, info.Size(), filepath.ToSlash(relativePath))
+
+ 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
diff --git a/backend/handlers/settings.go b/backend/handlers/settings.go
index a81f14b..6ca2b8d 100644
--- a/backend/handlers/settings.go
+++ b/backend/handlers/settings.go
@@ -40,8 +40,6 @@ func (h *SettingsHandler) UpdateSettings(c *fiber.Ctx) error {
allowedKeys := map[string]bool{
"theme": true,
- "thumbnail_images": true,
- "thumbnail_videos": true,
"trash_auto_purge_days": true,
"pinned_sidebar_expanded": true,
"pinned_order": true,
diff --git a/backend/main.go b/backend/main.go
index 55452ce..d1c15b3 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -69,7 +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}
+ onlyOfficeHandler := &handlers.OnlyOfficeHandler{Config: cfg, DB: db}
// Initialize background workers
workers.InitTaskManager(cfg)
diff --git a/backend/test.xlsx b/backend/test.xlsx
deleted file mode 100644
index 3eb5c6e..0000000
Binary files a/backend/test.xlsx and /dev/null differ
diff --git a/backend/workers/thumbnails.go b/backend/workers/thumbnails.go
index a92f375..838db8e 100644
--- a/backend/workers/thumbnails.go
+++ b/backend/workers/thumbnails.go
@@ -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)
+}
diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx
index cc105e8..3e64930 100644
--- a/frontend/src/components/FileExplorer.tsx
+++ b/frontend/src/components/FileExplorer.tsx
@@ -28,6 +28,7 @@ import FileInfoModal from './FileInfoModal';
import SharedFilesPage from './SharedFilesPage';
import OfficeHome from './OfficeHome';
import OnlyOfficeEditor from './OnlyOfficeEditor';
+import ThumbnailImage from './ThumbnailImage';
interface FileItem {
name: string;
@@ -172,40 +173,6 @@ function Tooltip({ children, text }: { children: React.ReactNode; text: string }
);
}
-function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: { checksum: string; fallbackIcon: React.ReactNode; downloadToken: string }) {
- const [errorCount, setErrorCount] = useState(0);
- const [key, setKey] = useState(0);
- const [showFallback, setShowFallback] = useState(false);
-
- return (
- <>
- 0 ? `&t=${key}` : ''}` : ''}
- alt=""
- className="w-full h-full object-cover"
- style={{ display: showFallback ? 'none' : 'block' }}
- onError={(e) => {
- if (errorCount < 5) {
- e.currentTarget.style.display = 'none';
- setTimeout(() => {
- setErrorCount(c => c + 1);
- setKey(Date.now());
- }, 2000);
- } else {
- setShowFallback(true);
- }
- }}
- onLoad={(e) => {
- e.currentTarget.style.display = 'block';
- setShowFallback(false);
- }}
- />
- {showFallback && fallbackIcon}
- >
- );
-}
-
export default function FileExplorer({ onLogout }: FileExplorerProps) {
const isMobile = useIsMobile();
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -1159,7 +1126,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/');
const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/');
- const isMedia = isImage || isVideo;
+ const isOffice = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(ext) || file.mime_type?.includes('officedocument');
+ const isMedia = isImage || isVideo || isOffice;
if (ext === 'pdf') {
return (
@@ -1169,31 +1137,31 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
);
}
- if (ext === 'docx' || ext === 'doc') {
- return (
-