Feat: Live thumbnails for office files
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m26s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m26s
This commit is contained in:
parent
b80abdbefb
commit
80d60d540a
10 changed files with 244 additions and 106 deletions
|
|
@ -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')`,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<img
|
||||
key={key}
|
||||
src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 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,6 +1137,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const fallbackIcon = (() => {
|
||||
if (ext === 'docx' || ext === 'doc') {
|
||||
return (
|
||||
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px] flex-shrink-0 aspect-square'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#2B579A]`}>
|
||||
|
|
@ -1193,7 +1162,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const fallbackIcon = (() => {
|
||||
if (isImage) {
|
||||
return (
|
||||
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded flex-shrink-0 aspect-square'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null); // path of file being renamed
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(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 ? (
|
||||
<div className="absolute inset-0 w-full h-full flex items-center justify-center z-0">
|
||||
<ThumbnailImage checksum={file.checksum} fallbackIcon={<Icon className="w-16 h-16 text-3xl opacity-30 group-hover:opacity-60 transition-opacity" />} downloadToken={downloadToken} />
|
||||
</div>
|
||||
) : (
|
||||
<Icon className="w-16 h-16 text-3xl opacity-30 group-hover:opacity-60 transition-opacity" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5 text-[10px]" />
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</form>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'thumbnails',
|
||||
title: 'Media & Thumbnails',
|
||||
icon: <ImageIcon className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.thumbnail_images === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, thumbnail_images: e.target.checked ? 'true' : 'false' })}
|
||||
className="w-4 h-4 rounded text-accent focus:ring-accent"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Generate thumbnails for images</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.thumbnail_videos === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, thumbnail_videos: e.target.checked ? 'true' : 'false' })}
|
||||
className="w-4 h-4 rounded text-accent focus:ring-accent"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Generate thumbnails for videos (requires FFmpeg)</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
id: 'trash',
|
||||
|
|
|
|||
42
frontend/src/components/ThumbnailImage.tsx
Normal file
42
frontend/src/components/ThumbnailImage.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<img
|
||||
key={key}
|
||||
src={downloadToken ? `${api.getThumbnailUrl(checksum, downloadToken)}${errorCount > 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in a new issue