mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-07 01:52:37 -05:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd07f4ac1d | ||
|
|
da548cb7b3 | ||
|
|
8cbca1ab3a | ||
|
|
f547d0de32 | ||
|
|
e517ad2777 | ||
|
|
a76b7b0415 | ||
|
|
74f2951688 | ||
|
|
943a915cf3 | ||
|
|
efe5379f3f | ||
|
|
0d2451e009 | ||
|
|
20af71da61 | ||
|
|
324a9c3802 | ||
|
|
7f88f829ef | ||
|
|
3aa5b88a95 | ||
|
|
972ba638df |
No files matched your search
+3
-1
@@ -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/**
|
||||
@@ -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
@@ -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" ]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
+3
-8
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/",
|
||||
};
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user