Compare commits

..
Author SHA1 Message Date
Graham Steffaniak fd07f4ac1d fix linting 2026-11-06 13:27:40 -06:00
Graham Steffaniak da548cb7b3 use static vars 2026-11-06 13:21:31 -06:00
Graham Steffaniak 8cbca1ab3a Merge branch 'dev/v2.2.0' into consolidate-folio-package 2026-11-06 13:13:44 -06:00
Graham Steffaniak f547d0de32 Merge branch 'dev/v2.2.0' of github.com:gtsteffaniak/filebrowser into dev/v2.2.0 2026-11-06 13:11:53 -06:00
Graham Steffaniak e517ad2777 updated conditions 2026-11-06 13:10:03 -06:00
Graham Steffaniak a76b7b0415 Consolidate folio package (#3344) 2026-11-05 14:42:44 -06:00
Graham Steffaniakandcoderabbitai[bot] 74f2951688 Update makefile
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-11-05 14:32:27 -06:00
Graham Steffaniak 943a915cf3 updated dist path ignore logic 2026-11-05 14:13:46 -06:00
Graham Steffaniak efe5379f3f add back gitignore 2026-11-05 13:50:17 -06:00
Graham Steffaniak 0d2451e009 updated go mod 2026-11-05 13:48:52 -06:00
Graham Steffaniak 20af71da61 Merge branch 'dev/v2.2.0' into consolidate-folio-package 2026-11-05 13:38:32 -06:00
Graham Steffaniak 324a9c3802 Merge branch 'dev/v2.2.0' of github.com:gtsteffaniak/filebrowser into dev/v2.2.0 2026-11-05 13:32:51 -06:00
Graham Steffaniak 7f88f829ef consolidate folio package into preview 2026-11-05 13:32:27 -06:00
Graham Steffaniak 3aa5b88a95 Improve text file guards (#3337) 2026-11-05 08:13:29 -05:00
Graham Steffaniak 972ba638df docker ignore the dist folder 2026-11-03 16:08:15 -06:00
43 changed files with 485 additions and 347 deletions

No files matched your search

+3 -1
View File
@@ -10,7 +10,9 @@ backend/tmp
backend/dist
backend/backend*
backend/vendor/**
backend/internal/web/dist/*
!backend/internal/web/dist/.gitkeep
frontend/dist/**
frontend/node_modules/**
frontend/pkg/**
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: dist
path: backend/internal/web/dist
path: frontend/dist
build_backend:
name: Build backend
runs-on: ubuntu-24.04-arm
@@ -97,7 +97,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: dist
path: backend/internal/web/dist
path: frontend/dist
- name: Download backend build
uses: actions/download-artifact@v4
with:
+1 -1
View File
@@ -39,7 +39,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
+1 -1
View File
@@ -34,7 +34,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+1 -1
View File
@@ -68,7 +68,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
RUN filebrowser version && ffmpeg -version && ffprobe -version
+1 -1
View File
@@ -12,7 +12,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# generate duplicate 1.1MB files
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
+1 -1
View File
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser user set testuser1 --password testuser1
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -7,7 +7,7 @@ WORKDIR /app/backend/
RUN mkdir -p ./internal/web/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
WORKDIR /app/frontend
+1 -1
View File
@@ -23,7 +23,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
ENV CI=true
+1 -1
View File
@@ -13,7 +13,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1 -1
View File
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
+1 -1
View File
@@ -10,7 +10,7 @@ COPY [ "./backend/filebrowser", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+1 -1
View File
@@ -13,7 +13,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -33,7 +33,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+1 -1
View File
@@ -32,7 +32,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+1 -1
View File
@@ -21,7 +21,7 @@ require (
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.6.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/gtsteffaniak/go-media v1.2.0
github.com/gtsteffaniak/go-media v1.2.1
github.com/kovidgoyal/imaging v1.9.0
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
+2
View File
@@ -167,6 +167,8 @@ github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcd
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-media v1.2.0 h1:lbNL/G86uzzH8NaT45DBHKm1r0P7yndIo9pNC7gdqQs=
github.com/gtsteffaniak/go-media v1.2.0/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
github.com/gtsteffaniak/go-media v1.2.1 h1:SjrPqgQTHnenAFuVh8fTRxk8+qXFEaCxiADx3zkM1OI=
github.com/gtsteffaniak/go-media v1.2.1/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
github.com/gtsteffaniak/go-vpx v1.0.1 h1:9lSI8yoz6omDChaeV0AJrhdfe2YTrWvJrDENI/bnkAM=
github.com/gtsteffaniak/go-vpx v1.0.1/go.mod h1:tRCdDZ15oZZTsiwmQAwyH56PTt1jn1C1O0P03oOkoJ8=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
-167
View File
@@ -1,167 +0,0 @@
// Package folio is the pure-Go document preview fast path using gen2brain/folio.
// When folio cannot handle a file, callers fall back to the office converter (when configured).
package folio
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
"path/filepath"
"strings"
"sync"
"github.com/gen2brain/folio/doc"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
var renderMu sync.Mutex
// RenderOptions controls thumbnail sizing for one rendered page.
type RenderOptions struct {
MaxWidth int
MaxHeight int
Fill bool
JpegQuality int
Original bool
}
// Enabled reports whether the folio fast path may run.
func Enabled() bool {
return !settings.AlwaysUseOnlyOfficeEnabled()
}
// SupportsExtension reports whether folio targets the file extension (including leading dot).
func SupportsExtension(ext string) bool {
ext = strings.ToLower(ext)
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return iteminfo.FolioConvertable[ext]
}
func logFallback(op, path string, err error) {
if errors.Is(err, doc.ErrUnsupported) {
logger.Warningf("folio does not support %s for %s (%v); using office converter if configured", op, path, err)
return
}
logger.Errorf("folio %s failed for %s: %v; falling back to office converter if configured", op, path, err)
}
// RenderPageJPEG renders one document page to JPEG. ok is true when folio returned bytes.
// name and mimetype should match the file being previewed; they control eligibility for
// folio (content sniffing does not depend on the path extension).
func RenderPageJPEG(ctx context.Context, path string, pageNumber int, opts RenderOptions, name, mimetype string) (data []byte, ok bool, err error) {
if !Enabled() {
return nil, false, nil
}
if name == "" {
name = filepath.Base(path)
}
if !SupportsPreview(name, mimetype) {
return nil, false, nil
}
if ctx.Err() != nil {
return nil, false, ctx.Err()
}
type result struct {
data []byte
err error
}
done := make(chan result, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- result{err: fmt.Errorf("folio render panicked: %v", r)}
}
}()
data, err := renderPageJPEG(ctx, path, name, mimetype, pageNumber, opts)
done <- result{data: data, err: err}
}()
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case res := <-done:
if res.err != nil {
if !errors.Is(res.err, context.Canceled) && !errors.Is(res.err, context.DeadlineExceeded) {
logFallback("render", path, res.err)
}
return nil, false, res.err
}
if len(res.data) == 0 {
return nil, false, fmt.Errorf("folio render returned empty jpeg")
}
return res.data, true, nil
}
}
func renderPageJPEG(ctx context.Context, path, name, mimetype string, pageNumber int, opts RenderOptions) ([]byte, error) {
renderMu.Lock()
defer renderMu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
d, err := openDocument(path, name, mimetype)
if err != nil {
return nil, err
}
defer d.Close()
if pageNumber < 0 || pageNumber >= d.NumPages() {
return nil, fmt.Errorf("invalid page number %d (pages: %d)", pageNumber, d.NumPages())
}
p, err := d.Page(pageNumber)
if err != nil {
return nil, err
}
var img image.Image
if opts.Original || opts.MaxWidth <= 0 || opts.MaxHeight <= 0 {
const maxEdge = 4096
rgba, err := p.ImageDPI(150)
if err != nil {
return nil, err
}
img = rgba
w, h := rgba.Bounds().Dx(), rgba.Bounds().Dy()
if w > maxEdge || h > maxEdge {
scale := min(float64(maxEdge)/float64(w), float64(maxEdge)/float64(h))
newW := max(1, int(float64(w)*scale))
newH := max(1, int(float64(h)*scale))
img = imaging.Resize(img, newW, newH, imaging.Lanczos)
}
} else {
rgba, err := p.ImageDPI(150)
if err != nil {
return nil, err
}
img = rgba
if opts.Fill {
img = imaging.Fill(img, opts.MaxWidth, opts.MaxHeight, imaging.Center, imaging.Lanczos)
} else {
b := img.Bounds()
if b.Dx() > opts.MaxWidth || b.Dy() > opts.MaxHeight {
img = imaging.Fit(img, opts.MaxWidth, opts.MaxHeight, imaging.Lanczos)
}
}
}
q := opts.JpegQuality
if q <= 0 {
q = 85
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+20 -8
View File
@@ -5,16 +5,26 @@ import (
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
const (
// docPreviewTimeout bounds small, large and xlarge page renders.
docPreviewTimeout = 2 * time.Second
// docOriginalTimeout bounds "original" renders, which are higher resolution.
docOriginalTimeout = 10 * time.Second
)
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, pageNumber int, previewSize string) ([]byte, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
timeout := docPreviewTimeout
if previewSize == "original" {
timeout = docOriginalTimeout
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
opts, err := folioRenderOptions(previewSize)
@@ -22,7 +32,7 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, err
}
imageBytes, ok, err := folio.RenderPageJPEG(timeoutCtx, file.RealPath, pageNumber, opts, file.Name, file.Type)
imageBytes, ok, err := renderDocPageJPEG(timeoutCtx, file.RealPath, pageNumber, opts, file.Name, file.Type)
if !ok {
if err != nil {
return nil, err
@@ -30,20 +40,22 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, nil
}
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
return nil, fmt.Errorf("document preview generation timed out after %s for '%s'", timeout, file.Name)
}
return imageBytes, nil
}
func folioRenderOptions(previewSize string) (folio.RenderOptions, error) {
// folioRenderOptions maps a preview size onto the same dimensions, fill mode and JPEG quality
// the image pipeline uses (see getPreviewOptions); "original" renders at full resolution.
func folioRenderOptions(previewSize string) (docRenderOptions, error) {
if previewSize == "original" {
return folio.RenderOptions{Original: true, JpegQuality: QualityHigh.jpegQuality()}, nil
return docRenderOptions{Original: true, JpegQuality: QualityHigh.jpegQuality()}, nil
}
opts, err := getPreviewOptions(previewSize)
if err != nil {
return folio.RenderOptions{}, err
return docRenderOptions{}, err
}
return folio.RenderOptions{
return docRenderOptions{
MaxWidth: opts.Width,
MaxHeight: opts.Height,
Fill: opts.ResizeMode == ResizeModeFill,
@@ -5,7 +5,6 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/stretchr/testify/require"
)
@@ -17,7 +16,7 @@ func TestFolioRender_utf8TruncatedPlaywrightFile(t *testing.T) {
}
opts, err := folioRenderOptions("small")
require.NoError(t, err)
data, ok, err := folio.RenderPageJPEG(t.Context(), realPath, 0, opts, "utf8-truncated.txt", "text/plain")
data, ok, err := renderDocPageJPEG(t.Context(), realPath, 0, opts, "utf8-truncated.txt", "text/plain")
require.NoError(t, err)
require.True(t, ok, "err=%v", err)
require.NotEmpty(t, data)
@@ -29,7 +28,7 @@ func TestFolioRender_cssAsTxt(t *testing.T) {
require.NoError(t, os.WriteFile(cssPath, []byte("body { color: red; }\n"), 0o644))
opts, err := folioRenderOptions("small")
require.NoError(t, err)
data, ok, err := folio.RenderPageJPEG(t.Context(), cssPath, 0, opts, "styles.css", "text/css")
data, ok, err := renderDocPageJPEG(t.Context(), cssPath, 0, opts, "styles.css", "text/css")
require.NoError(t, err)
require.True(t, ok, "err=%v", err)
require.NotEmpty(t, data)
+208
View File
@@ -0,0 +1,208 @@
// folio.go is the pure-Go document preview fast path using gen2brain/folio.
// When folio cannot handle a file, callers fall back to the office converter (when configured).
package preview
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
"path/filepath"
"sync"
"github.com/gen2brain/folio/doc"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
const (
// docSupersample renders a page at this multiple of the target box before the
// final downscale. Rasterized text needs the extra pixels to stay crisp.
docSupersample = 2
// docOriginalDPI is the resolution used for "original" page previews.
docOriginalDPI = 200
// docMaxRenderEdge caps the longest edge of a rendered page, in pixels.
docMaxRenderEdge = 4096
// docMinDPI and docMaxDPI bound the computed render resolution.
docMinDPI = 36.0
docMaxDPI = 600.0
)
var renderMu sync.Mutex
// docRenderOptions controls the size and quality of one rendered document page.
type docRenderOptions struct {
MaxWidth int
MaxHeight int
Fill bool
JpegQuality int
Original bool
}
// folioEnabled reports whether the folio fast path may run.
func folioEnabled() bool {
return !settings.AlwaysUseOnlyOfficeEnabled()
}
func logFolioFallback(op, path string, err error) {
if errors.Is(err, doc.ErrUnsupported) {
logger.Warningf("folio does not support %s for %s (%v); using office converter if configured", op, path, err)
return
}
logger.Errorf("folio %s failed for %s: %v; falling back to office converter if configured", op, path, err)
}
// renderDocPageJPEG renders one document page to JPEG. ok is true when folio returned bytes.
// name and mimetype should match the file being previewed; they control eligibility for
// folio (content sniffing does not depend on the path extension).
func renderDocPageJPEG(ctx context.Context, path string, pageNumber int, opts docRenderOptions, name, mimetype string) (data []byte, ok bool, err error) {
if !folioEnabled() {
return nil, false, nil
}
if name == "" {
name = filepath.Base(path)
}
if !folioSupportsPreview(name, mimetype) {
return nil, false, nil
}
if ctx.Err() != nil {
return nil, false, ctx.Err()
}
type result struct {
data []byte
err error
}
done := make(chan result, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- result{err: fmt.Errorf("folio render panicked: %v", r)}
}
}()
data, err := renderPageJPEG(ctx, path, name, mimetype, pageNumber, opts)
done <- result{data: data, err: err}
}()
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case res := <-done:
if res.err != nil {
if !errors.Is(res.err, context.Canceled) && !errors.Is(res.err, context.DeadlineExceeded) {
logFolioFallback("render", path, res.err)
}
return nil, false, res.err
}
if len(res.data) == 0 {
return nil, false, fmt.Errorf("folio render returned empty jpeg")
}
return res.data, true, nil
}
}
func renderPageJPEG(ctx context.Context, path, name, mimetype string, pageNumber int, opts docRenderOptions) ([]byte, error) {
renderMu.Lock()
defer renderMu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
d, err := openDocument(path, name, mimetype)
if err != nil {
return nil, err
}
defer d.Close()
if pageNumber < 0 || pageNumber >= d.NumPages() {
return nil, fmt.Errorf("invalid page number %d (pages: %d)", pageNumber, d.NumPages())
}
p, err := d.Page(pageNumber)
if err != nil {
return nil, err
}
boxed := !opts.Original && opts.MaxWidth > 0 && opts.MaxHeight > 0
var dpi float64
if boxed {
dpi = docBoxRenderDPI(p, d.Kind(), opts.MaxWidth, opts.MaxHeight, opts.Fill)
} else {
dpi = docOriginalRenderDPI(p, d.Kind())
}
rendered, err := p.ImageDPI(dpi)
if err != nil {
return nil, err
}
if rendered == nil {
return nil, fmt.Errorf("folio rendered no image for page %d", pageNumber)
}
var img image.Image = rendered
if boxed {
// The page was rendered at docSupersample times the target, so this
// single high-quality downscale produces the anti-aliased final image.
if opts.Fill {
img = imaging.Fill(img, opts.MaxWidth, opts.MaxHeight, imaging.Center, imaging.Lanczos)
} else {
img = imaging.Fit(img, opts.MaxWidth, opts.MaxHeight, imaging.Lanczos)
}
}
q := opts.JpegQuality
if q <= 0 {
q = 85
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// docUnitDPI is the resolution at which folio's ImageDPI renders page units one to one:
// 72 for PDF and book pages, 96 for drawings (SVG).
func docUnitDPI(kind doc.Kind) float64 {
if kind == doc.KindSVG {
return 96
}
return 72
}
// docMaxEdgeDPI is the highest resolution at which the longest page edge stays within docMaxRenderEdge.
func docMaxEdgeDPI(p doc.Page, kind doc.Kind) float64 {
b := p.Bounds()
longest := float64(max(b.X1-b.X0, b.Y1-b.Y0))
if longest <= 0 {
return docMinDPI
}
return docUnitDPI(kind) * docMaxRenderEdge / longest
}
// docBoxRenderDPI picks the resolution for a page that will be scaled into a boxW x boxH target.
// Fill covers the box; fit stays inside it. The page is rendered at docSupersample times the target.
func docBoxRenderDPI(p doc.Page, kind doc.Kind, boxW, boxH int, fill bool) float64 {
b := p.Bounds()
pw, ph := float64(b.X1-b.X0), float64(b.Y1-b.Y0)
if pw <= 0 || ph <= 0 {
return docMinDPI
}
// Scale (pixels per page unit) needed for the supersampled render to reach the box on each axis.
sx := float64(boxW*docSupersample) / pw
sy := float64(boxH*docSupersample) / ph
s := min(sx, sy)
if fill {
s = max(sx, sy)
}
dpi := min(s*docUnitDPI(kind), docMaxEdgeDPI(p, kind))
return min(max(dpi, docMinDPI), docMaxDPI)
}
// docOriginalRenderDPI picks the resolution for "original" previews: docOriginalDPI, capped by docMaxRenderEdge.
func docOriginalRenderDPI(p doc.Page, kind doc.Kind) float64 {
return min(docOriginalDPI, docMaxEdgeDPI(p, kind))
}
@@ -1,4 +1,4 @@
package folio
package preview
import (
"fmt"
@@ -17,9 +17,9 @@ const folioTextCheckWindow = 4096 // html.readsAsText validation window
// maxTextPreviewLoad is the most plain-text we read for a first-page thumbnail.
const maxTextPreviewLoad = 32 << 10 // 32 KiB
// SupportsPreview reports whether folio should attempt this file (name + MIME).
// folioSupportsPreview reports whether folio should attempt this file (name + MIME).
// doc.Open sniffs content; extensions like .css are not in FolioConvertable but still render.
func SupportsPreview(name, mimetype string) bool {
func folioSupportsPreview(name, mimetype string) bool {
ext := strings.ToLower(filepath.Ext(name))
if iteminfo.FolioConvertable[ext] {
return true
@@ -1,4 +1,4 @@
package folio
package preview
import (
"os"
@@ -8,12 +8,12 @@ import (
"github.com/stretchr/testify/require"
)
func TestRenderPageJPEG_txt(t *testing.T) {
func TestRenderDocPageJPEG_txt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hello.txt")
require.NoError(t, os.WriteFile(path, []byte("Hello folio preview"), 0o644))
data, ok, err := RenderPageJPEG(t.Context(), path, 0, RenderOptions{
data, ok, err := renderDocPageJPEG(t.Context(), path, 0, docRenderOptions{
MaxWidth: 256,
MaxHeight: 256,
Fill: true,
@@ -26,8 +26,8 @@ func TestRenderPageJPEG_txt(t *testing.T) {
require.Equal(t, byte(0xd8), data[1])
}
func TestRenderPageJPEG_unsupportedExtension(t *testing.T) {
data, ok, err := RenderPageJPEG(t.Context(), "/nonexistent/file.xyz", 0, RenderOptions{}, "file.xyz", "application/octet-stream")
func TestRenderDocPageJPEG_unsupportedExtension(t *testing.T) {
data, ok, err := renderDocPageJPEG(t.Context(), "/nonexistent/file.xyz", 0, docRenderOptions{}, "file.xyz", "application/octet-stream")
require.NoError(t, err)
require.False(t, ok)
require.Nil(t, data)
@@ -1,4 +1,4 @@
package folio
package preview
import (
"os"
@@ -37,22 +37,17 @@ func TestSanitizeFolioText_stripsNUL(t *testing.T) {
raw := []byte("hello\x00world")
got := sanitizeFolioText(raw)
require.Equal(t, []byte("hello world"), got)
opts, err := folioRenderOptionsForTest()
require.NoError(t, err)
opts := docRenderOptions{MaxWidth: 256, MaxHeight: 256, Fill: true, JpegQuality: 80}
dir := t.TempDir()
path := filepath.Join(dir, "nul.txt")
require.NoError(t, os.WriteFile(path, raw, 0o600))
data, ok, err := RenderPageJPEG(t.Context(), path, 0, opts, "nul.txt", "text/plain")
data, ok, err := renderDocPageJPEG(t.Context(), path, 0, opts, "nul.txt", "text/plain")
require.NoError(t, err)
require.True(t, ok, "err=%v", err)
require.NotEmpty(t, data)
}
func folioRenderOptionsForTest() (RenderOptions, error) {
return RenderOptions{MaxWidth: 256, MaxHeight: 256, Fill: true, JpegQuality: 80}, nil
}
func TestReadTextPreviewPrefix_cap(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.log")
+2 -3
View File
@@ -18,7 +18,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/embeddedpreview"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
"github.com/gtsteffaniak/filebrowser/backend/internal/imagemeta"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
@@ -481,7 +480,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// Folio document previews render at target size in-process; they skip imageSem.
previewTypeEarly := determinePreviewType(file)
skipImageSem := previewTypeEarly == previewTypeDocument && folio.Enabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)
skipImageSem := previewTypeEarly == previewTypeDocument && folioEnabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)
// True when the semaphore block below holds a permit for this whole function.
imagePermitHeld := !skipImageSem
if !skipImageSem {
@@ -576,7 +575,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// Video thumbnails come from go-media or ffmpeg already sized to the preview
// box, so they are final and need no ffmpeg gate here.
if !fromEmbeddedPreview && (previewType == previewTypeImage ||
(previewType == previewTypeDocument && folio.Enabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)) ||
(previewType == previewTypeDocument && folioEnabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)) ||
previewType == previewTypeVideo) {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
+1 -1
View File
@@ -42,7 +42,7 @@ func initRuntime(deps Deps, assets fs.FS) {
initSettingsSecretMarkers()
}
// Embed the frontend build output under internal/web/dist (see frontend `npm run build`).
// Embed the frontend build output under internal/web/dist (see `make embed-frontend` after `npm run build` in frontend/).
//
//go:embed dist/*
var assets embed.FS
+3 -1
View File
@@ -245,6 +245,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
manifestURL = staticURL + "/site.webmanifest?" + query.Encode()
}
// only include constant values that don't change during a users session
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": template.CSS(settings.Config.Frontend.Styling.CustomCSSRaw),
@@ -272,6 +273,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"devMode": settings.Env.IsDevMode,
}
// only include constant values that don't change during a users session
data["globalVars"] = map[string]interface{}{
"name": settings.Config.Frontend.Name,
"minSearchLength": settings.Config.Server.MinSearchLength,
@@ -302,7 +304,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"transcodeProfiles": settings.EnabledTranscodeProfiles(),
"transcodeMaxPerUser": settings.TranscodeMaxConcurrentPerUser(),
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
"textPreviewExtensions": iteminfo.TextPreviewExtensionsForSPA(),
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
@@ -324,6 +325,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"playwrightTest": settings.Env.IsPlaywright,
"officeProduct": officeProductForSPA(),
"officeExtensions": wopiExtensionsForSPA(),
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
}
cspNonce, err := utils.CSPNonce()
+2 -4
View File
@@ -5,8 +5,6 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
@@ -101,10 +99,10 @@ func serveTextPreview(w http.ResponseWriter, r *http.Request, info *iteminfo.Ext
if info.Type == "directory" {
return http.StatusUnsupportedMediaType, fmt.Errorf("directories have no text preview")
}
kind := iteminfo.TextPreviewKind(strings.ToLower(filepath.Ext(info.Name)))
if kind == "" {
if !iteminfo.IsTextPreviewable(info.Type) {
return http.StatusUnsupportedMediaType, fmt.Errorf("unsupported file type for text preview")
}
kind := iteminfo.TextPreviewRenderKind(info.Name)
// Bound concurrent reads and RTF conversions with the preview service's shared
// image-processor semaphore, so a listing of many text files cannot open an
// unbounded number of file handles at once.
+1 -1
View File
@@ -1597,7 +1597,7 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
}
// Text-like files are rendered by the text preview endpoint (not the image pipeline),
// so this runs before Folio/OnlyOffice. SVG is not a text preview type and stays an image.
if iteminfo.IsTextPreviewable(fileInfo.Name) {
if iteminfo.IsTextPreviewable(fileInfo.Type) {
fileInfo.HasPreview = true
return
}
+88 -61
View File
@@ -271,57 +271,57 @@ var compressedFile = map[string]bool{
// Raw camera image: extension -> MIME type (single source for IsRawImage + type detection)
var rawImageMimeType = map[string]string{
".cr2": "image/x-canon-cr2",
".cr3": "image/x-canon-cr3",
".nef": "image/x-nikon-nef",
".nrw": "image/x-nikon-nef",
".arw": "image/x-sony-arw",
".srf": "image/x-sony-arw",
".sr2": "image/x-sony-arw",
".orf": "image/x-olympus-orf",
".rw2": "image/x-panasonic-rw2",
".raw": "image/x-panasonic-raw",
".dng": "image/x-adobe-dng",
".raf": "image/x-fuji-raf",
".pef": "image/x-pentax-pef",
".ptx": "image/x-pentax-pef",
".rwl": "image/x-leica-rwl",
".3fr": "image/x-hasselblad-3fr",
".fff": "image/x-hasselblad-fff",
".erf": "image/x-epson-erf",
".mrw": "image/x-minolta-mrw",
".dcr": "image/x-kodak-dcr",
".kdc": "image/x-kodak-dcr",
".dc2": "image/x-kodak-dc2",
".x3f": "image/x-sigma-x3f",
".iiq": "image/x-phaseone-iiq",
".nkc": "image/x-kodak-nkc",
".r3d": "image/x-red-r3d",
".cr2": "image/x-canon-cr2",
".cr3": "image/x-canon-cr3",
".nef": "image/x-nikon-nef",
".nrw": "image/x-nikon-nef",
".arw": "image/x-sony-arw",
".srf": "image/x-sony-arw",
".sr2": "image/x-sony-arw",
".orf": "image/x-olympus-orf",
".rw2": "image/x-panasonic-rw2",
".raw": "image/x-panasonic-raw",
".dng": "image/x-adobe-dng",
".raf": "image/x-fuji-raf",
".pef": "image/x-pentax-pef",
".ptx": "image/x-pentax-pef",
".rwl": "image/x-leica-rwl",
".3fr": "image/x-hasselblad-3fr",
".fff": "image/x-hasselblad-fff",
".erf": "image/x-epson-erf",
".mrw": "image/x-minolta-mrw",
".dcr": "image/x-kodak-dcr",
".kdc": "image/x-kodak-dcr",
".dc2": "image/x-kodak-dc2",
".x3f": "image/x-sigma-x3f",
".iiq": "image/x-phaseone-iiq",
".nkc": "image/x-kodak-nkc",
".r3d": "image/x-red-r3d",
}
// 3D model extension -> MIME type (single source for Is3DModel + type detection)
var model3DMimeType = map[string]string{
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".fbx": "model/fbx",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
".3mf": "model/3mf",
".3ds": "model/3ds",
".usdz": "model/vnd.usdz+zip",
".usd": "model/vnd.usd+zip",
".usda": "model/vnd.usd+zip",
".usdc": "model/vnd.usd+zip",
".amf": "model/x-amf",
".vrml": "model/vrml",
".wrl": "model/vrml",
".vtk": "model/vtk",
".vtp": "model/vtk",
".pcd": "model/pcd",
".xyz": "model/xyz",
".vox": "model/vox",
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".fbx": "model/fbx",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
".3mf": "model/3mf",
".3ds": "model/3ds",
".usdz": "model/vnd.usdz+zip",
".usd": "model/vnd.usd+zip",
".usda": "model/vnd.usd+zip",
".usdc": "model/vnd.usd+zip",
".amf": "model/x-amf",
".vrml": "model/vrml",
".wrl": "model/vrml",
".vtk": "model/vtk",
".vtp": "model/vtk",
".pcd": "model/pcd",
".xyz": "model/xyz",
".vox": "model/vox",
}
var audioMetadataTypes = map[string]bool{
@@ -560,20 +560,47 @@ func TextPreviewKind(ext string) string {
return textPreviewKinds[ext]
}
// IsTextPreviewable reports whether a file name uses the text preview endpoint.
func IsTextPreviewable(name string) bool {
return textPreviewKinds[strings.ToLower(filepath.Ext(name))] != ""
// textPreviewMimeTypes are non-text/* mimetypes that textTypes extensions resolve to
// (see the custom table in backend/internal/adapters/fs/files/mime.go). The frontend
// receives this list through globalVars (backend/internal/web/static.go), so it is
// the single source of truth.
// Do not add mimetypes shared with binaries (e.g. application/x-msdownload for .exe).
var textPreviewMimeTypes = map[string]bool{
"application/json": true,
"application/yaml": true,
"application/x-yaml": true,
"application/sql": true,
"application/x-sql": true,
"application/x-sh": true,
"application/x-php": true,
"application/x-powershell": true,
"application/x-tex": true,
"application/toml": true,
"application/x-ruby": true,
}
// TextPreviewExtensionsForSPA groups text preview extensions (without dots) by renderer kind.
// The frontend uses this to decide which items get a text preview container.
func TextPreviewExtensionsForSPA() map[string][]string {
out := make(map[string][]string)
for ext, kind := range textPreviewKinds {
out[kind] = append(out[kind], strings.TrimPrefix(ext, "."))
}
for kind := range out {
sort.Strings(out[kind])
}
return out
// IsTextPreviewable reports whether an item uses the text preview endpoint. The decision
// is the item's mimetype: every text/* type and the textPreviewMimeTypes allowlist.
func IsTextPreviewable(mimetype string) bool {
return strings.HasPrefix(mimetype, "text/") || textPreviewMimeTypes[mimetype]
}
// TextPreviewMimeTypes returns the non-text/* allowlist in sorted order, for the
// frontend's globalVars. Sorting keeps the served value stable across restarts.
func TextPreviewMimeTypes() []string {
types := make([]string, 0, len(textPreviewMimeTypes))
for mt := range textPreviewMimeTypes {
types = append(types, mt)
}
sort.Strings(types)
return types
}
// TextPreviewRenderKind returns the renderer kind for a text-previewable file: the
// extension-specific kind when one exists, otherwise "plain".
func TextPreviewRenderKind(name string) string {
if kind := TextPreviewKind(strings.ToLower(filepath.Ext(name))); kind != "" {
return kind
}
return "plain"
}
@@ -1,7 +1,7 @@
package iteminfo
import (
"slices"
"sort"
"testing"
"github.com/stretchr/testify/require"
@@ -22,21 +22,30 @@ func TestTextPreviewKinds(t *testing.T) {
}
func TestIsTextPreviewable(t *testing.T) {
require.True(t, IsTextPreviewable("Notes.TXT"))
require.True(t, IsTextPreviewable("dir/readme.md"))
require.False(t, IsTextPreviewable("image.svg"))
require.False(t, IsTextPreviewable("noext"))
}
func TestTextPreviewExtensionsForSPA(t *testing.T) {
groups := TextPreviewExtensionsForSPA()
require.Contains(t, groups["markdown"], "md")
require.Contains(t, groups["rtf"], "rtf")
require.True(t, slices.Contains(groups["code"], "go"))
require.False(t, slices.Contains(groups["code"], "svg"))
for _, exts := range groups {
for _, ext := range exts {
require.NotContains(t, ext, ".", "extensions must not have a leading dot")
}
for _, mt := range []string{"text/plain", "text/markdown", "text/lyrics", "text/x-go", "text/html"} {
require.True(t, IsTextPreviewable(mt), mt)
}
for _, mt := range []string{"application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"} {
require.True(t, IsTextPreviewable(mt), mt)
}
// Mimetypes shared with binaries (.exe, .bat via application/x-msdownload) must stay non-previewable.
for _, mt := range []string{"", "image/svg+xml", "application/pdf", "application/x-sqlite3", "application/postscript", "application/backup", "application/wasm", "application/x-msdownload", "application/x-msdos-program", "application/octet-stream"} {
require.False(t, IsTextPreviewable(mt), mt)
}
}
func TestTextPreviewRenderKind(t *testing.T) {
require.Equal(t, "markdown", TextPreviewRenderKind("notes.md"))
require.Equal(t, "plain", TextPreviewRenderKind("50. Holy Water.lrc"))
require.Equal(t, "plain", TextPreviewRenderKind("cert.pem"))
}
func TestTextPreviewMimeTypesIsSortedAndMatchesAllowlist(t *testing.T) {
types := TextPreviewMimeTypes()
require.NotEmpty(t, types)
require.True(t, sort.StringsAreSorted(types), "list must be sorted for a stable globalVars value")
for _, mt := range types {
require.True(t, IsTextPreviewable(mt), mt)
}
require.Contains(t, types, "application/json")
}
+2 -2
View File
@@ -53,7 +53,7 @@ import {
storeThumbnailBlob,
} from "@/utils/cache/imageCache.ts";
import { queuedFetchPreview } from "@/utils/previewRequests.ts";
import { isTextPreviewableName, languageFromPath, MIN_TEXT_PREVIEW_WIDTH_PX } from "@/utils/textPreview.ts";
import { isTextPreviewableType, languageFromPath, MIN_TEXT_PREVIEW_WIDTH_PX } from "@/utils/textPreview.ts";
const props = withDefaults(
defineProps<{
@@ -226,7 +226,7 @@ const shouldUse3DPreview = computed(() => {
/** True when the filename has an extension the text preview endpoint supports. */
const isTextPreviewable = computed(
() => props.mimetype !== "directory" && isTextPreviewableName(props.filename),
() => props.mimetype !== "directory" && isTextPreviewableType(props.mimetype),
);
/** Extension used for syntax highlighting and the CSV/TSV delimiter. */
@@ -98,7 +98,7 @@ import { globalVars } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getKindKey } from "@/utils/mimetype";
import { isTextPreviewableName } from "@/utils/textPreview";
import { isTextPreviewableType } from "@/utils/textPreview";
import { hasCachedThumbnailBlob } from "@/utils/cache/imageCache.ts";
import { formatDuration } from "@/utils/files.js";
import { getObjectProperty } from '@/utils/object.js';
@@ -383,7 +383,7 @@ export default {
}
// Only text-previewable names have a preview; other items must never build a URL
// (a DuplicateFinder row can pass a text-like name with no source).
if (!isTextPreviewableName(this.name)) {
if (!isTextPreviewableType(this.type)) {
return "";
}
let previewPath;
+1 -1
View File
@@ -83,7 +83,7 @@ import { useListingVirtualization } from "@/composables/useListingVirtualization
import url from "@/utils/url";
import { removeFromFolderLists } from "@/utils/cache/listingCache.ts";
interface DeleteItem {
export interface DeleteItem {
source?: string;
path: string;
type?: string;
+54 -7
View File
@@ -1,9 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// The backend serves the text allowlist through globalVars (backend/internal/web/static.go).
const { globalsMock } = vi.hoisted(() => ({
globalsMock: {
textPreviewMimeTypes: [
"application/json",
"application/yaml",
"application/x-yaml",
"application/sql",
"application/x-sql",
"application/x-sh",
"application/x-php",
"application/x-powershell",
"application/x-tex",
"application/toml",
"application/x-ruby",
] as string[],
},
}));
vi.mock("@/utils/constants.js", async (importOriginal) => ({
...(await importOriginal<object>()),
globalVars: globalsMock,
}));
import {
clearTextPreviewCache,
fetchTextPreview,
getRenderedTextPreview,
isTextPreviewableName,
isTextPreviewableType,
languageFromPath,
languageFromPreviewUrl,
MIN_TEXT_PREVIEW_WIDTH_PX,
@@ -33,12 +58,34 @@ describe("languageFromPath", () => {
});
});
describe("isTextPreviewableName", () => {
it("is true for extensions in the backend kind map", () => {
// The vitest setup stubs globalVars.textPreviewExtensions.
expect(isTextPreviewableName("notes.md")).toBe(true);
expect(isTextPreviewableName("image.png")).toBe(false);
expect(isTextPreviewableName("noext")).toBe(false);
describe("isTextPreviewableType", () => {
it("is true for text/* mimetypes, with or without parameters", () => {
for (const mt of ["text/markdown", "text/lyrics", "text/plain; charset=utf-8", "text/x-go"]) {
expect(isTextPreviewableType(mt), mt).toBe(true);
}
});
it("is true for text-extension mimetypes that are not text/*", () => {
for (const mt of ["application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"]) {
expect(isTextPreviewableType(mt), mt).toBe(true);
}
});
it("follows the allowlist served in globalVars", () => {
const original = globalsMock.textPreviewMimeTypes;
try {
globalsMock.textPreviewMimeTypes = ["application/x-custom"];
expect(isTextPreviewableType("application/x-custom")).toBe(true);
expect(isTextPreviewableType("application/json")).toBe(false);
} finally {
globalsMock.textPreviewMimeTypes = original;
}
});
it("is false for non-text mimetypes and missing types", () => {
for (const mt of ["application/x-sqlite3", "application/postscript", "application/wasm", "application/backup", "application/x-msdownload", "application/octet-stream", "image/svg+xml", "image/png", "directory", "", null, undefined]) {
expect(isTextPreviewableType(mt), String(mt)).toBe(false);
}
});
});
+21 -15
View File
@@ -1,5 +1,5 @@
import { state } from "@/store/index.ts";
import { globalVars } from "@/utils/constants.js";
import { state } from "@/store/index.ts";
import type { TextPreviewPayload } from "@/utils/textPreviewRender.ts";
// Text previews are small (about 16KB), so the frontend keeps a bounded in-memory cache.
@@ -27,23 +27,29 @@ export function languageFromPath(path: string | null | undefined): string | null
return name.slice(dot + 1).toLowerCase();
}
/**
* Every extension the text preview endpoint supports, from the backend's kind map.
* Built once per page rather than once per listing item.
*/
let textPreviewExtSet: Set<string> | null = null;
export function getTextPreviewExtensions(): Set<string> {
if (textPreviewExtSet === null) {
const groups = (globalVars.textPreviewExtensions ?? {}) as Record<string, string[]>;
textPreviewExtSet = new Set(Object.values(groups).flat());
// Non-text/* mimetypes (JSON, YAML, SQL, shell, ...) served by the backend in globalVars.
// The backend is the single source of truth; the Set is rebuilt only when the served array changes.
let allowlistSource: string[] | undefined;
let allowlistSet = new Set<string>();
function textPreviewMimeTypes(): Set<string> {
const source = globalVars?.textPreviewMimeTypes as string[] | undefined;
if (source !== allowlistSource) {
allowlistSource = source;
allowlistSet = new Set(source ?? []);
}
return textPreviewExtSet;
return allowlistSet;
}
/** True when the filename has an extension the text preview endpoint supports. */
export function isTextPreviewableName(filename: string | null | undefined): boolean {
const ext = languageFromPath(filename);
return ext !== null && getTextPreviewExtensions().has(ext);
/**
* True when an item's mimetype is text/* or on the backend's text allowlist. The backend uses
* the same rule, so an item the listing marks as text is the one the text preview endpoint
* renders. Binary types such as sqlite, postscript, wasm and backup never reach the endpoint.
*/
export function isTextPreviewableType(mimetype: string | null | undefined): boolean {
// split always yields at least one element; the ?? "" satisfies noUncheckedIndexedAccess.
const base = (mimetype ?? "").split(";")[0] ?? "";
return base.startsWith("text/") || textPreviewMimeTypes().has(base);
}
// Text fetches are queued so a folder full of text files does not fire dozens of
-8
View File
@@ -86,14 +86,6 @@ vi.mock('@/utils/constants', () => {
return {
globalVars: {
baseURL: "/files/",
textPreviewExtensions: {
markdown: ["md"],
code: ["go", "js", "ts"],
csv: ["csv", "tsv"],
html: ["html"],
plain: ["txt"],
rtf: ["rtf"],
},
},
baseURL: "/files/",
};
-2
View File
@@ -8,7 +8,6 @@ import stripLegacyCSS from "./scripts/postcss-strip-legacy.ts";
import protectTemplateStyles from "./scripts/vite-protect-template-styles.ts";
const isDevBuild = process.env.DEV_BUILD === "true";
const backendWebDist = path.resolve(import.meta.dirname, "../backend/internal/web/dist");
// Docker builds skip vue-tsc here; CI type-checks in regular-tests.yaml.
const skipTypecheck = process.env.SKIP_TYPECHECK === "1";
@@ -122,7 +121,6 @@ export default defineConfig(({ command }) => {
base: "",
define,
build: {
outDir: backendWebDist,
emptyOutDir: true,
// Optimize for watch mode stability
watch: isDevBuild ? {
+16 -7
View File
@@ -19,7 +19,7 @@ backend_dev_tool = $$(cd $(BACKEND_BUILD) && go tool -n $(1))
.SILENT:
.PHONY: setup update build build-docker build-docker-media build-backend build-frontend dev run generate-docs
.PHONY: setup update build build-docker build-docker-media build-backend build-frontend embed-frontend dev run generate-docs
.PHONY: lint-frontend lint-backend lint test test-backend test-frontend check-all
.PHONY: check-translations sync-translations test-playwright test-playwright-project test-playwright-performance playwright-base playwright-perf-base perf-check perf-smoke perf-baseline perf-dashboard run-proxy screenshots
.PHONY: check-icons generate-icons sync-icons
@@ -52,7 +52,13 @@ build-docker-slim:
build-docker-media:
DOCKER_BUILDKIT=1 docker build --build-arg="VERSION=testing" --build-arg="REVISION=n/a" -t gtstef/filebrowser-media -f _docker/Dockerfile.media .
build-backend:
embed-frontend:
@test -d frontend/dist || (echo "Missing frontend/dist; run 'make build-frontend' first." >&2 && exit 1)
mkdir -p backend/internal/web/dist
find backend/internal/web/dist -mindepth 1 ! -name '.gitkeep' ! -name 'config.generated.yaml' -delete
cp -r frontend/dist/. backend/internal/web/dist/
build-backend: embed-frontend
@echo "Building backend..."
cd backend && go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=testing'"
@echo "✓ Backend built successfully"
@@ -64,6 +70,7 @@ dev: generate-docs generate-icons
bash ./scripts/dev.sh
run: build-frontend generate-docs
$(MAKE) embed-frontend
cd backend && $(call backend_dev_tool,swag) init --output swagger/docs
@if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' '/func init/,+3d' backend/swagger/docs/docs.go; \
@@ -138,7 +145,7 @@ PLAYWRIGHT_PROJECTS := sharing settings noauth general jwt proxy previews oidc n
# make test-playwright-project PROJECT=previews
# or:
# make test-playwright PROJECT=previews
test-playwright: build-frontend
test-playwright: build-frontend embed-frontend
cd backend && GOOS=linux go build -o filebrowser .
ifeq ($(PROJECT),)
$(foreach p,$(PLAYWRIGHT_PROJECTS),docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-$(p) . ;)
@@ -166,7 +173,7 @@ playwright-perf-base:
--build-arg PLAYWRIGHT_BROWSERS="$(PERF_PLAYWRIGHT_BROWSERS)" \
-f _docker/Dockerfile.playwright-perf-base .
test-playwright-performance: build-frontend
test-playwright-performance: build-frontend embed-frontend
cd backend && GOOS=linux go build -o filebrowser .
DOCKER_BUILDKIT=1 docker build --target ci -t filebrowser-playwright-performance \
-f _docker/Dockerfile.playwright-performance .
@@ -182,7 +189,9 @@ PERF_BROWSERS ?= chromium,firefox,webkit
# Only chromium feeds the CI baseline; the others are advisory insight.
perf-check:
ifndef PERF_SKIP_BUILD
$(MAKE) build-frontend
$(MAKE) build-frontend embed-frontend
else
$(MAKE) embed-frontend
endif
cd backend && GOOS=linux go build -o filebrowser .
ifndef PERF_SKIP_PLAYWRIGHT_BASE
@@ -213,7 +222,7 @@ perf-dashboard:
# get version from environment variable, for example
# cd frontend && npm i @playwright/test && npx playwright install --with-deps chromium
# make PLAYWRIGHT_TEST=settings test-playwright-ui
test-playwright-ui: build-frontend
test-playwright-ui: build-frontend embed-frontend
docker stop local-playwright-tests || true
docker rm local-playwright-tests || true
rm -rf _docker/src/tmp/ || true && mkdir -p _docker/src/tmp/
@@ -234,7 +243,7 @@ run-jwt: build-frontend
# optional: install playwright locally
# once local playwright server is running, you can also watch the tests interactively with:
# cd frontend && npx playwright test --project dark-screenshots --ui
screenshots: build-frontend
screenshots: build-frontend embed-frontend
cd backend && GOOS=linux go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=latest'" .
@echo "Running screenshots..."
cd _docker && docker compose down && docker compose up --build local-playwright-screenshots