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 ( -
- -
- ); - } - - if (ext === 'pptx' || ext === 'ppt') { - return ( -
- -
- ); - } - - if (ext === 'xlsx' || ext === 'xls') { - return ( -
- -
- ); - } - const fallbackIcon = (() => { + if (ext === 'docx' || ext === 'doc') { + return ( +
+ +
+ ); + } + + if (ext === 'pptx' || ext === 'ppt') { + return ( +
+ +
+ ); + } + + if (ext === 'xlsx' || ext === 'xls') { + return ( +
+ +
+ ); + } + if (isImage) { return (
diff --git a/frontend/src/components/OfficeHome.tsx b/frontend/src/components/OfficeHome.tsx index cf117a4..ec1fe19 100644 --- a/frontend/src/components/OfficeHome.tsx +++ b/frontend/src/components/OfficeHome.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Plus, Pencil, Trash2, Pin, FileText, LayoutTemplate, FileSpreadsheet } from 'lucide-react'; import api from '@/lib/api'; +import ThumbnailImage from './ThumbnailImage'; interface FileItem { name: string; @@ -11,6 +12,7 @@ interface FileItem { is_pinned: boolean; is_trashed: boolean; mod_time: string; + checksum?: string; } interface OfficeHomeProps { @@ -63,6 +65,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha const [renaming, setRenaming] = useState(null); // path of file being renamed const [renameValue, setRenameValue] = useState(''); const renameInputRef = useRef(null); + const [downloadToken, setDownloadToken] = useState(''); const ext = type === 'docs' ? '.docx' : type === 'slides' ? '.pptx' : '.xlsx'; const fileTypeParam = type === 'docs' ? 'docx' : type === 'slides' ? 'pptx' : 'xlsx'; @@ -118,6 +121,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha useEffect(() => { fetchFiles(); + api.createDownloadToken().then(setDownloadToken).catch(console.error); }, [type]); // Close context menu on click elsewhere @@ -232,7 +236,13 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha className={`w-full ${type === 'docs' ? 'aspect-[1/1.4]' : 'aspect-[1.4/1]'} rounded-lg border hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm overflow-hidden relative`} style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }} > - + {file.checksum ? ( +
+ } downloadToken={downloadToken} /> +
+ ) : ( + + )}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index c888ae3..18fc23b 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -8,8 +8,6 @@ import api from '@/lib/api'; interface SettingsData { theme: string; grid_size: string; - thumbnail_images: string; - thumbnail_videos: string; trash_auto_purge_days: string; } @@ -55,8 +53,6 @@ export default function SettingsPage({ onLogout }: SettingsPageProps) { const loaded = { theme: s.theme || 'dark', grid_size: s.grid_size || '200', - thumbnail_images: s.thumbnail_images || 'true', - thumbnail_videos: s.thumbnail_videos || 'true', trash_auto_purge_days: s.trash_auto_purge_days || '14', }; setSettings(loaded); @@ -200,35 +196,7 @@ export default function SettingsPage({ onLogout }: SettingsPageProps) { ) }, - { - id: 'thumbnails', - title: 'Media & Thumbnails', - icon: , - content: ( -
- - -
- ) - }, + { id: 'trash', diff --git a/frontend/src/components/ThumbnailImage.tsx b/frontend/src/components/ThumbnailImage.tsx new file mode 100644 index 0000000..0cf9cd5 --- /dev/null +++ b/frontend/src/components/ThumbnailImage.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import api from '@/lib/api'; + +interface ThumbnailImageProps { + checksum: string; + fallbackIcon: React.ReactNode; + downloadToken: string; +} + +export default function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: ThumbnailImageProps) { + 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} + + ); +}