Skip to content

updated with office preview fixes #703

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).

## v0.7.7-beta

**New Features**:
- since theres a wider kind of document preview types, a new disableOfficePreviewExt option has been added.
**Notes**:
- all text mimetype files have preview support.
- high-quality preview image sizes bumped from 512x512 to 640x640 to help make text previews readable.

**BugFixes**:
-

## v0.7.6-beta

NOTE: if using docker arm32 image, you will need to switch to the slim images. The regular docker images are much larger now and support generating office previews out of the box without any only office running. However, they don't support arm32. Also, be aware the docker images are much larger now (600MB I believe) because of the office document preview support -- if thats not something you care about you can switch to the slim images.
Expand Down
2 changes: 1 addition & 1 deletion _docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:7.1.1 AS ffmpeg
FROM gtstef/ffmpeg:7.1.1-decode AS ffmpeg
FROM golang:1.24-alpine AS base
ARG VERSION
ARG REVISION
Expand Down
2 changes: 0 additions & 2 deletions _docker/Dockerfile.slim
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,10 @@ WORKDIR /home/filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/

## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]

USER root
# exposing default port for auto discovery.
EXPOSE 80
Expand Down
35 changes: 18 additions & 17 deletions backend/common/settings/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,21 +113,22 @@ type ExternalLink struct {
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // comma separated list of file extensions to disable onlyoffice preview for
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview users.Preview `json:"preview"`
DefaultScopes []users.SourceScope `json:"-"`
Permissions users.Permissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // comma separated list of file extensions to disable onlyoffice preview for
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // comma separated list of file extensions to disable office preview for
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview users.Preview `json:"preview"`
DefaultScopes []users.SourceScope `json:"-"`
Permissions users.Permissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
}
29 changes: 15 additions & 14 deletions backend/database/users/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,21 @@ type SourceScope struct {

// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // comma separated list of file extensions to disable onlyoffice preview for
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // comma separated list of file extensions to disable onlyoffice preview for
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // comma separated list of file extensions to disable office preview for
}

var PublicUser = User{
Expand Down
65 changes: 59 additions & 6 deletions backend/http/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"

Expand Down Expand Up @@ -345,28 +346,80 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
// LoggingMiddleware logs each request and its status code.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// DEFER RECOVERY FUNCTION
defer func() {
if rcv := recover(); rcv != nil {
// Log detailed information about the panic
// Extract as much context as possible for logging
method := r.Method
url := r.URL.String()
remoteAddr := r.RemoteAddr
username := "unknown" // Default username

// Attempt to get username from ResponseWriterWrapper if it's set
if ww, ok := w.(*ResponseWriterWrapper); ok && ww.User != "" {
username = ww.User
}
// Or try to get it from request context if your other middleware populates it
// This depends on your context setup; example:
// if dataCtx, ok := r.Context().Value("requestData").(*requestContext); ok && dataCtx.user != nil {
// username = dataCtx.user.Username
// }

// Get Go-level stack trace
buf := make([]byte, 16384) // Increased buffer size for potentially long CGo traces
n := runtime.Stack(buf, false) // false for current goroutine only
stackTrace := string(buf[:n])

logger.Errorf("PANIC RECOVERED: %v\nUser: %s\nMethod: %s\nURL: %s\nRemoteAddr: %s\nGo Stack Trace:\n%s",
rcv, username, method, url, remoteAddr, stackTrace)

// Attempt to send a 500 error response to the client
// This is a best-effort; the connection might be broken or process too unstable.
if ww, ok := w.(*ResponseWriterWrapper); ok { // Check if it's our wrapper
if !ww.wroteHeader { // Only write if headers haven't been sent
ww.Header().Set("Content-Type", "application/json; charset=utf-8")
ww.WriteHeader(http.StatusInternalServerError)
}
} else {
_, _ = renderJSON(w, r, &HttpResponse{
Status: 500,
Message: "A critical internal error occurred. Please try again later.",
})
}

// Wrap the ResponseWriter to capture the status code.
// IMPORTANT: After a SIGSEGV from C code, the process might be unstable.
// Even if Go recovers, continuing to run the process is risky.
// Consider a strategy to gracefully shut down or signal an external supervisor
// to restart the process after logging. For now, this will allow other requests
// to proceed if the process doesn't die, but be wary.
}
}() // End of deferred recovery function

start := time.Now()
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}

// Call the next handler.
// Call the next handler in the chain
next.ServeHTTP(wrappedWriter, r)

// Capture the full URL path including the query parameters.
// Existing logging logic for normal requests
fullURL := r.URL.Path
if r.URL.RawQuery != "" {
fullURL += "?" + r.URL.RawQuery
}
truncUser := wrappedWriter.User
if len(truncUser) > 12 {
if truncUser == "" {
truncUser = "N/A" // Handle case where user might not be set (e.g., if panic occurred before user auth)
} else if len(truncUser) > 12 {
truncUser = truncUser[:10] + ".."
}
duration := time.Since(start)

// Use the StatusCode from wrappedWriter, which might have been set to 500 by the recover logic
logger.Api(wrappedWriter.StatusCode,
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode, // Captured status code
wrappedWriter.StatusCode,
r.RemoteAddr,
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
Expand Down
6 changes: 5 additions & 1 deletion backend/indexing/iteminfo/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ var PdfConvertable = []string{
".xlsx", // XLSX
".hwp", // HWP
".hwp", // HWPX
".md", // Markdown
}

// Known bundle-style extensions that are technically directories but treated as files
Expand Down Expand Up @@ -375,10 +376,13 @@ func hasBundleExtension(name string) bool {
return false
}

func HasDocConvertableExtension(name string) bool {
func HasDocConvertableExtension(name, mimetype string) bool {
if !settings.Config.Server.PdfAvailable {
return false
}
if strings.HasPrefix(mimetype, "text") {
return true
}
ext := strings.ToLower(filepath.Ext(name))
for _, e := range PdfConvertable {
if ext == e {
Expand Down
75 changes: 65 additions & 10 deletions backend/preview/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ package preview

import (
"bytes"
"context"
"fmt"
"image/jpeg"
"io"
"os"
"runtime"
"strings"

"github.com/gen2brain/go-fitz"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)

func docEnabled() bool {
Expand All @@ -18,21 +22,72 @@ func docEnabled() bool {
return true
}

func (s *Service) GenerateImageFromDoc(docPath string, pageNumber int) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
return nil, err
func (s *Service) GenerateImageFromDoc(file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
// 1. Serialize access to the entire go-fitz operation block
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()

// 2. Lock the current goroutine to a single OS thread for CGo calls
runtime.LockOSThread()
defer runtime.UnlockOSThread()

docPath := file.RealPath
// copy file to a temporary location if needed
if strings.HasPrefix(file.Type, "text") && !strings.HasSuffix(file.RealPath, ".txt") {
originalFile, err := os.Open(file.RealPath)
if err != nil {
return nil, fmt.Errorf("text snippet: failed to open original file '%s': %w", file.RealPath, err)
}
defer originalFile.Close() // Ensure original file is closed

buffer := make([]byte, 1024) // Buffer for up to 1KB
n, readErr := originalFile.Read(buffer)
if readErr != nil && readErr != io.EOF { // io.EOF is not an error if some bytes were read
return nil, fmt.Errorf("text snippet: failed to read from original file '%s': %w", file.RealPath, readErr)
}

if n == 0 {
return nil, fmt.Errorf("text snippet: original file '%s' is empty or unreadable", file.RealPath)
} else {
tempFile, err := os.Create(tempFilePath)
if err != nil {
return nil, fmt.Errorf("text snippet: failed to create temporary file '%s': %w", tempFilePath, err)
}
defer os.Remove(tempFilePath) // Ensure cleanup on error
// Write the read content (up to 1KB or EOF) to the temporary file
if _, err := tempFile.Write(buffer[:n]); err != nil {
tempFile.Close() // Attempt to close
os.Remove(tempFilePath) // Clean up on error
return nil, fmt.Errorf("text snippet: failed to write to temporary file '%s': %w", tempFilePath, err)
}

// Close the temporary file so it can be reliably opened by path by other processes/functions
if err := tempFile.Close(); err != nil {
os.Remove(tempFilePath) // Clean up on error
return nil, fmt.Errorf("text snippet: failed to close temporary file '%s': %w", tempFilePath, err)
}

docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
}
}
defer s.release()
doc, err := fitz.New(docPath)
doc, err := fitz.New(docPath) // This calls the CGo version
if err != nil {
return nil, fmt.Errorf("failed to open PDF: %w", err)
// The error message you received: "failed to open PDF: fitz: cannot open memory"
return nil, fmt.Errorf("failed to open PDF from memory for file '%s': %w", docPath, err)
}
defer doc.Close()

// Get the image from the doc page
img, err := doc.Image(pageNumber) // Assuming page numbers are 0-indexed as per go-fitz common usage
// Ensure pageNumber is valid (e.g., >= 0 for 0-indexed or >= 1 for 1-indexed, check go-fitz docs)
// And pageNumber < doc.NumPage()
numPages := doc.NumPage()
if pageNumber < 0 || pageNumber >= numPages { // Assuming 0-indexed for Image()
return nil, fmt.Errorf("invalid page number %d for PDF with %d pages ('%s')", pageNumber, numPages, docPath)
}

img, err := doc.Image(pageNumber)
if err != nil {
return nil, fmt.Errorf("failed to get image from page %d: %w", pageNumber, err)
return nil, fmt.Errorf("failed to get image from page %d of '%s': %w", pageNumber, docPath, err)
}

// Create a new buffer to hold the image bytes
Expand All @@ -41,7 +96,7 @@ func (s *Service) GenerateImageFromDoc(docPath string, pageNumber int) ([]byte,
// Encode the image directly into the buffer
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
if err != nil {
return nil, fmt.Errorf("failed to encode image to jpeg: %w", err)
return nil, fmt.Errorf("failed to encode image to jpeg for '%s': %w", docPath, err)
}

// Return the byte slice from the buffer
Expand Down
7 changes: 6 additions & 1 deletion backend/preview/docDefault.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@

package preview

import "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"

func docEnabled() bool {
// This function checks if the PDF support is enabled.
// In a real implementation, this might check a build tag or configuration.
return false
}

func (s *Service) GenerateImageFromDoc(pdfPath string, pageNumber int) ([]byte, error) {
func (s *Service) GenerateImageFromDoc(file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) { // 1. Serialize access to the entire go-fitz operation block
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()

return nil, nil
}
Loading
Loading