Beta/v0.7.6 (#697)

This commit is contained in:
Graham Steffaniak authored and GitHub committed 2025-06-02 09:31:01 -05:00
1 parent 40374752a3
commit 0a9af58b07
41 files changed
+626 -288

No files matched your search

+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile
file: ./_docker/Dockerfile.slim
push: false # Do not push the image for now
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -55,8 +55,8 @@ jobs:
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64
file: ./_docker/Dockerfile
platforms: linux/amd64,linux/arm64
file: ./_docker/Dockerfile.slim
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
-125
View File
@@ -1,125 +0,0 @@
name: Manual Docker Build
on:
workflow_dispatch:
inputs:
branch:
description: "Tag or Branch (format: v0.0.0-beta)"
required: true
type: string
permissions:
contents: write
jobs:
push_release_to_registry:
name: Push release
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses the Personal Access Token
ref: "${{ github.event.inputs.branch }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
tags: |
type=raw,value=${{ github.event.inputs.branch }}
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(sub(":v"; ":")) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end |
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
- name: Modify version names
id: modify-json-media
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(
sub(":v"; ":") + "-media"
) |
if any(.tags[]; test("beta-media")) then
.tags += [
"gtstef/filebrowser:beta-media",
"ghcr.io/gtsteffaniak/filebrowser:beta-media",
"gtstef/filebrowser:media",
"ghcr.io/gtsteffaniak/filebrowser:media"
]
else . end |
if any(.tags[]; test("stable-media")) then
.tags += [
"gtstef/filebrowser:stable-media",
"ghcr.io/gtsteffaniak/filebrowser:stable-media",
"gtstef/filebrowser:media",
"ghcr.io/gtsteffaniak/filebrowser:media"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push media image
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
file: ./_docker/Dockerfile.media
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.modify-json-media.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+68 -1
View File
@@ -91,8 +91,75 @@ jobs:
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
push_slim_release_to_registry:
name: Push slim release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify tags for slim image
id: modify-tags-slim
run: |
TAG_OR_BRANCH="${{ github.ref_name }}"
CLEAN_TAG=$(echo "$TAG_OR_BRANCH" | sed 's/^v//')
BASE_TAG="gtstef/filebrowser:${CLEAN_TAG}-slim"
TAGS=("$BASE_TAG")
if echo "$CLEAN_TAG" | grep -qi "beta"; then
TAGS+=(
"gtstef/filebrowser:beta-slim"
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
"gtstef/filebrowser:slim"
"ghcr.io/gtsteffaniak/filebrowser:slim"
)
elif echo "$CLEAN_TAG" | grep -qi "stable"; then
TAGS+=(
"gtstef/filebrowser:stable-slim"
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
"gtstef/filebrowser:slim"
"ghcr.io/gtsteffaniak/filebrowser:slim"
)
fi
TAGS_CSV=$(IFS=, ; echo "${TAGS[*]}")
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push slim image
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./_docker/Dockerfile.slim
latest: false
push: true
tags: ${{ steps.modify-json-slim.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+33
View File
@@ -2,6 +2,39 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.7.6-beta
NOTE: if using docker arm32 image, you will need to switch to the slim images. The regular docker images are much larger now and support generating office previews out of the box without any only office running. However, they don't support arm32. Also, be aware the docker images are much larger now (600MB I believe) because of the office document preview support -- if thats not something you care about you can switch to the slim images.
**New Features**:
- native document preview generation enabled for certain document types on the regular docker image (no office integration needed)
- supported native document preview types:
- ".pdf", // PDF
- ".xps", // XPS
- ".epub", // EPUB
- ".mobi", // MOBI
- ".fb2", // FB2
- ".cbz", // CBZ
- ".svg", // SVG
- ".txt", // TXT
- ".doc", // DOC
- ".docx", // DOCX
- ".ppt", // PPT
- ".pptx", // PPTX
- ".xls", // excel xls
- ".xlsx", // exel XLSX
- ".hwp", // HWP
- ".hwp", // HWPX
- proxy logout redirectUrl support via `auth.methods.proxy.logoutRedirectUrl` https://github.com/gtsteffaniak/filebrowser/issues/684
**Notes**:
- image loading placeholders added and remain if image can't be loaded.
- no more arm32 support on main image -- use a `slim` tagged image.
**BugFixes**:
- onlyoffice and other cache issues https://github.com/gtsteffaniak/filebrowser/issues/686
- gallery size indicator centering https://github.com/gtsteffaniak/filebrowser/issues/652
## v0.7.5-beta
**New Features**
+7 -19
View File
@@ -1,3 +1,4 @@
FROM gtstef/ffmpeg:7.1.1 AS ffmpeg
FROM golang:1.24-alpine AS base
ARG VERSION
ARG REVISION
@@ -5,12 +6,12 @@ WORKDIR /app
COPY ./backend ./
#RUN swag init --output swagger/docs
RUN ln -s swagger /usr/local/go/src/
RUN go build -ldflags="-w -s \
RUN go build -tags pdf -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
FROM node:lts-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
@@ -18,23 +19,10 @@ COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
RUN apk add --no-cache curl xz && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;; \
aarch64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ;; \
armv7l) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
mkdir -p /tmp/ffmpeg && \
curl -L "$URL" | tar -xJ -C /tmp/ffmpeg && \
mv /tmp/ffmpeg/ffmpeg-*/ffmpeg /usr/local/bin/ && \
mv /tmp/ffmpeg/ffmpeg-*/ffprobe /usr/local/bin/ && \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
rm -rf /tmp/ffmpeg && \
apk del curl xz && \
rm -rf /var/cache/apk/*
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
# requirements to enable pdf support
RUN apk add --no-cache libffi mupdf && \
find / -name "libmupdf.so*" -type f -exec mv {} /usr/lib/libmupdf.so \;
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
+58
View File
@@ -0,0 +1,58 @@
FROM golang:1.24-alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
#RUN swag init --output swagger/docs
RUN ln -s swagger /usr/local/go/src/
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:lts-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
RUN apk add --no-cache curl xz && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;; \
aarch64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ;; \
armv7l) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
mkdir -p /tmp/ffmpeg && \
curl -L "$URL" | tar -xJ -C /tmp/ffmpeg && \
mv /tmp/ffmpeg/ffmpeg-*/ffmpeg /usr/local/bin/ && \
mv /tmp/ffmpeg/ffmpeg-*/ffprobe /usr/local/bin/ && \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
rm -rf /tmp/ffmpeg && \
apk del curl xz && \
rm -rf /var/cache/apk/*
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
ENTRYPOINT [ "./filebrowser" ]
+1
View File
@@ -23,6 +23,7 @@ auth:
enabled: true
header: "X-Username"
createUser: true
logoutRedirectUrl: "https://localhost/api/auth/logout2"
userDefaults:
darkMode: true
+118 -19
View File
@@ -9,13 +9,14 @@ import (
"fmt"
"hash"
"io"
"unicode"
"unicode/utf8"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
@@ -23,12 +24,9 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var OnlyOfficeCache = cache.NewCache(48 * time.Hour)
func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
response := iteminfo.ExtendedFileInfo{}
if opts.Source == "" {
@@ -70,7 +68,7 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content && strings.HasPrefix(info.Type, "text") {
if opts.Content {
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(realPath)
if err != nil {
@@ -98,11 +96,11 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
}
func generateOfficeId(realPath string) string {
key, ok := OnlyOfficeCache.Get(realPath).(string)
key, ok := utils.OnlyOfficeCache.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
OnlyOfficeCache.Set(realPath, documentKey)
utils.OnlyOfficeCache.Set(realPath, documentKey)
return documentKey
}
return key
@@ -271,28 +269,129 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
return idx.RefreshFileInfo(opts)
}
// getContent reads and returns the file content if it's UTF-8 readable.
// getContent reads and returns the file content if it's considered an editable text file.
func getContent(realPath string) (string, error) {
// Read the entire file in one go. This is more efficient.
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
if err != nil {
return "", err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return "", err
}
actualHeader := headerBytes[:n]
// --- Start of new heuristic checks ---
if n > 0 {
// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
if !utf8.Valid(actualHeader) {
return "", nil // Not an error, just not the text file we want
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return "", nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return "", nil // Found problematic control character
}
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
}
// Optional: Use http.DetectContentType for an additional check on the header
// contentType := http.DetectContentType(actualHeader)
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
// return "", nil
// }
}
// --- End of new heuristic checks for header ---
// Now read the full file (original logic)
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Check if the file content is valid UTF-8.
// This allows for all characters, including ASCII and emojis.
if !utf8.Valid(content) {
// File is not valid text (e.g., a binary image), so we reject it.
return "", nil
}
// Handle the special case for an empty file, as in your original code.
// Handle empty file (original logic - returns specific string)
if len(content) == 0 {
return "empty-file-x6OlSil", nil
}
// The file is valid, so return its string content.
return string(content), nil
stringContent := string(content)
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
return "", nil
}
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return "", nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return "", nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return stringContent, nil
}
func IsNamedPipe(mode os.FileMode) bool {
+4 -3
View File
@@ -38,9 +38,10 @@ type PasswordAuthConfig struct {
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
}
type Recaptcha struct {
+7 -1
View File
@@ -5,6 +5,7 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
@@ -279,10 +280,15 @@ func loadEnvConfig() {
}
func setDefaults() Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
if cpus := runtime.NumCPU(); cpus > 0 {
numCpus = cpus
}
return Settings{
Server: Server{
Port: 80,
NumImageProcessors: 4,
NumImageProcessors: numCpus,
BaseURL: "",
Database: "database.db",
SourceMap: map[string]Source{},
+2 -1
View File
@@ -21,7 +21,7 @@ type Settings struct {
}
type Server struct {
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
@@ -42,6 +42,7 @@ type Server struct {
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
DefaultSource Source `json:"-" validate:"omitempty"`
PdfAvailable bool `json:"-"` // used internally if compiled with pdf support
}
type Integrations struct {
+15
View File
@@ -0,0 +1,15 @@
package utils
import (
"time"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
DiskUsageCache = cache.NewCache(30*time.Second, 24*time.Hour)
RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
SearchResultsCache = cache.NewCache(15*time.Second, 1*time.Hour)
OnlyOfficeCache = cache.NewCache(48*time.Hour, 1*time.Hour)
JwtCache = cache.NewCache(1*time.Hour, 72*time.Hour)
)
+4 -1
View File
@@ -7,8 +7,9 @@ require (
github.com/coreos/go-oidc/v3 v3.14.1
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.14
github.com/go-playground/validator/v10 v10.26.0
github.com/goccy/go-yaml v1.17.1
github.com/goccy/go-yaml v1.18.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063
@@ -70,6 +71,7 @@ require (
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -125,6 +127,7 @@ require (
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.4.1 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
+8 -2
View File
@@ -168,6 +168,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -188,6 +190,8 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gen2brain/go-fitz v1.24.14 h1:09weRkjVtLYNGo7l0J7DyOwBExbwi8SJ9h8YPhw9WEo=
github.com/gen2brain/go-fitz v1.24.14/go.mod h1:0KaZeQgASc20Yp5R/pFzyy7SmP01XcoHKNF842U2/S4=
github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ=
github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w=
@@ -260,8 +264,8 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -408,6 +412,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.4.1 h1:Obg+xpuA+rRkRTEN7xu3IagjNDMO8fRUhOPpLEztPYI=
github.com/jupiterrider/ffi v0.4.1/go.mod h1:Ba3hjU7Sz2+TO8HupjZwGssVnzo7RG3xF77byUBueB4=
github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI=
github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
+19 -7
View File
@@ -124,7 +124,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
return printToken(w, r, d.user) // Pass the data object
}
// logoutHandler handles user logout, specifically used for OIDC.
// logoutHandler handles user logout, specifically used for OIDC or proxy users.
// @Summary User Logout
// @Description logs a user out of the application.
// @Tags Auth
@@ -136,14 +136,26 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
logoutUrl := fmt.Sprintf("%s/login", origin)
proxyRedirectUrl := config.Auth.Methods.ProxyAuth.LogoutRedirectUrl
oidcRedirectUrl := settings.Config.Auth.Methods.OidcAuth.LogoutRedirectUrl
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
oidcCfg := settings.Config.Auth.Methods.OidcAuth
if oidcCfg.Enabled && oidcCfg.LogoutRedirectUrl != "" {
logoutUrl = oidcCfg.LogoutRedirectUrl
http.Redirect(w, r, logoutUrl, http.StatusFound)
return 302, nil
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
if proxyRedirectUrl != "" {
logoutUrl = proxyRedirectUrl
}
} else {
if oidcRedirectUrl != "" {
logoutUrl = oidcRedirectUrl
}
}
// clear cookie
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: "",
MaxAge: -1,
Path: "/",
})
http.Redirect(w, r, logoutUrl, http.StatusFound)
return 302, nil
}
+2 -6
View File
@@ -9,7 +9,6 @@ import (
"net/url"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -17,12 +16,9 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var OnlyOfficeCache = cache.NewCache(48 * time.Hour)
const (
onlyOfficeStatusDocumentClosedWithChanges = 2
onlyOfficeStatusDocumentClosedWithNoChanges = 4
@@ -199,7 +195,7 @@ func getOnlyOfficeId(source, path string) (string, error) {
realpath, _, _ := idx.GetRealPath(path)
// error is intentionally ignored in order treat errors
// the same as a cache-miss
cachedDocumentKey, ok := OnlyOfficeCache.Get(realpath).(string)
cachedDocumentKey, ok := utils.OnlyOfficeCache.Get(realpath).(string)
if ok {
return cachedDocumentKey, nil
}
@@ -213,7 +209,7 @@ func deleteOfficeId(source, path string) {
return
}
realpath, _, _ := idx.GetRealPath(path)
OnlyOfficeCache.Delete(realpath)
utils.OnlyOfficeCache.Delete(realpath)
}
func onlyofficeGetTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
+1
View File
@@ -58,6 +58,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
"OidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"PasswordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"MediaAvailable": config.Integrations.Media.FfmpegPath != "",
"PdfAvailable": config.Server.PdfAvailable,
}
b, err := json.Marshal(data)
+32
View File
@@ -20,6 +20,25 @@ var AllFiletypeOptions = []string{
"text",
}
var PdfConvertable = []string{
".pdf", // PDF
".xps", // XPS
".epub", // EPUB
".mobi", // MOBI
".fb2", // FB2
".cbz", // CBZ
".svg", // SVG
".txt", // TXT
".doc", // DOC
".docx", // DOCX
".ppt", // PPT
".pptx", // PPTX
".xls", // XLS
".xlsx", // XLSX
".hwp", // HWP
".hwp", // HWPX
}
// Known bundle-style extensions that are technically directories but treated as files
var BundleExtensions = []string{
".app", // macOS application bundle
@@ -355,3 +374,16 @@ func hasBundleExtension(name string) bool {
}
return false
}
func HasDocConvertableExtension(name string) bool {
if !settings.Config.Server.PdfAvailable {
return false
}
ext := strings.ToLower(filepath.Ext(name))
for _, e := range PdfConvertable {
if ext == e {
return true
}
}
return false
}
+3 -6
View File
@@ -3,18 +3,15 @@ package indexing
import (
"fmt"
"path/filepath"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/shirou/gopsutil/v3/disk"
)
var DiskUsageCache = cache.NewCache(30 * time.Second)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo) bool {
idx.mu.Lock()
@@ -108,7 +105,7 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
}
sourcePath := idx.Path
cacheKey := "usageCache-" + sourceName
_, ok = DiskUsageCache.Get(cacheKey).(bool)
_, ok = utils.DiskUsageCache.Get(cacheKey).(bool)
if !ok {
usage, err := disk.Usage(sourcePath)
if err != nil {
@@ -121,7 +118,7 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
Used: usage.Used,
}
idx.SetUsage(latestUsage)
DiskUsageCache.Set(cacheKey, true)
utils.DiskUsageCache.Set(cacheKey, true)
}
return idx.ReducedIndex, nil
}
+49
View File
@@ -0,0 +1,49 @@
//go:build pdf
// +build pdf
package preview
import (
"bytes"
"context"
"fmt"
"image/jpeg"
"github.com/gen2brain/go-fitz"
)
func docEnabled() bool {
// This function checks if the PDF support is enabled.
// In a real implementation, this might check a build tag or configuration.
return true
}
func (s *Service) GenerateImageFromDoc(docPath string, pageNumber int) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
return nil, err
}
defer s.release()
doc, err := fitz.New(docPath)
if err != nil {
return nil, fmt.Errorf("failed to open PDF: %w", err)
}
defer doc.Close()
// Get the image from the doc page
img, err := doc.Image(pageNumber) // Assuming page numbers are 0-indexed as per go-fitz common usage
if err != nil {
return nil, fmt.Errorf("failed to get image from page %d: %w", pageNumber, err)
}
// Create a new buffer to hold the image bytes
var buf bytes.Buffer
// Encode the image directly into the buffer
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
if err != nil {
return nil, fmt.Errorf("failed to encode image to jpeg: %w", err)
}
// Return the byte slice from the buffer
return buf.Bytes(), nil
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !pdf
// +build !pdf
package preview
func docEnabled() bool {
// This function checks if the PDF support is enabled.
// In a real implementation, this might check a build tag or configuration.
return false
}
func (s *Service) GenerateImageFromDoc(pdfPath string, pageNumber int) ([]byte, error) {
return nil, nil
}
+2 -2
View File
@@ -119,8 +119,8 @@ func WithQuality(quality Quality) Option {
}
}
func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
if err := s.acquire(ctx); err != nil {
func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options ...Option) error {
if err := s.acquire(context.Background()); err != nil {
return err
}
defer s.release()
+1 -2
View File
@@ -2,7 +2,6 @@ package preview
import (
"bytes"
"context"
"errors"
"image"
"image/gif"
@@ -282,7 +281,7 @@ func TestService_Resize(t *testing.T) {
defer source.Close()
buf := &bytes.Buffer{}
err := svc.Resize(context.Background(), source, test.width, test.height, buf, test.options...)
err := svc.Resize(source, test.width, test.height, buf, test.options...)
if (err != nil) != test.wantErr {
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
}
+5
View File
@@ -2,6 +2,7 @@ package preview
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -21,6 +22,10 @@ type officePreviewResponse struct {
// GenerateOfficePreview generates a preview for an office document using OnlyOffice.
func (s *Service) GenerateOfficePreview(filetype, key, title, url string) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
return nil, err
}
defer s.release()
data := []byte{}
// Create the request payload
requestPayload := map[string]interface{}{
+12 -18
View File
@@ -63,6 +63,7 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
logger.Infof("Media Enabled : %v", errprobe == nil)
settings.Config.Integrations.Media.FfmpegPath = filepath.Base(ffmpegMainPath)
}
settings.Config.Server.PdfAvailable = docEnabled()
return &Service{
sem: make(chan struct{}, concurrencyLimit),
ffmpegPath: ffmpegMainPath,
@@ -99,7 +100,12 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
)
// Generate an image from office document
if file.OnlyOfficeId != "" {
if iteminfo.HasDocConvertableExtension(file.Name) {
imageBytes, err = service.GenerateImageFromDoc(file.RealPath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
} else if file.OnlyOfficeId != "" {
imageBytes, err = service.GenerateOfficePreview(filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
@@ -118,14 +124,11 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
hasher := sha1.New() //nolint:gosec
_, _ = hasher.Write([]byte(CacheKey(file.RealPath, previewSize, file.ItemInfo.ModTime, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "video", hash) + ".jpg"
outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "video", hash)
defer os.Remove(outPathPattern) // cleanup
if err = service.GenerateVideoPreview(file.RealPath, outPathPattern, seekPercentage); err != nil {
return nil, fmt.Errorf("failed to generate video preview: %w", err)
}
imageBytes, err = os.ReadFile(outPathPattern)
imageBytes, err = service.GenerateVideoPreview(file.RealPath, outPathPattern, seekPercentage)
if err != nil {
return nil, fmt.Errorf("failed to read video thumbnail: %w", err)
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
} else {
return nil, fmt.Errorf("unsupported media type: %s", ext)
@@ -163,7 +166,7 @@ func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error)
input := bytes.NewReader(data)
output := &bytes.Buffer{}
if err := s.Resize(context.Background(), input, width, height, output, options...); err != nil {
if err := s.Resize(input, width, height, output, options...); err != nil {
return nil, err
}
@@ -188,9 +191,6 @@ func AvailablePreview(file iteminfo.ExtendedFileInfo) bool {
if strings.HasPrefix(file.Type, "video") && service.ffmpegPath != "" {
return true
}
if file.OnlyOfficeId != "" {
return true
}
if file.Type == "application/pdf" {
return true
}
@@ -199,13 +199,7 @@ func AvailablePreview(file iteminfo.ExtendedFileInfo) bool {
case ".jpg", ".jpeg", ".png", ".bmp", ".tiff":
return true
}
if file.OnlyOfficeId != "" {
return true
}
if file.Type == "application/pdf" {
return true
}
return false
return file.OnlyOfficeId != ""
}
// CheckValidFFmpeg checks for a valid ffmpeg executable.
+15 -7
View File
@@ -2,6 +2,7 @@ package preview
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
@@ -15,7 +16,11 @@ import (
// videoPath: path to the input video file.
// outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg").
// seekTime: how many seconds into the video to seek before capturing the frame.
func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) error {
func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
return nil, err
}
defer s.release()
// Step 1: Get video duration from the container format
probeCmd := exec.Command(
s.ffprobePath,
@@ -33,23 +38,23 @@ func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageS
}
if err := probeCmd.Run(); err != nil {
logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err)
return fmt.Errorf("ffprobe failed: %w", err)
return nil, fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(probeOut.String())
if durationStr == "" || durationStr == "N/A" {
logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr)
return fmt.Errorf("could not determine video duration")
return nil, fmt.Errorf("could not determine video duration")
}
durationFloat, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
// The original error you saw would be caught here if "N/A" was still the output
return fmt.Errorf("invalid duration: %v", err)
return nil, fmt.Errorf("invalid duration: %v", err)
}
if durationFloat <= 0 {
return fmt.Errorf("video duration must be positive")
return nil, fmt.Errorf("video duration must be positive")
}
// The rest of your function remains the same...
@@ -76,6 +81,9 @@ func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageS
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
return cmd.Run()
err = cmd.Run()
if err != nil {
return nil, fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
}
return os.ReadFile(outputPath)
}
+5 -1
View File
@@ -2006,6 +2006,10 @@ const docTemplate = `{
"header": {
"description": "required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.",
"type": "string"
},
"logoutRedirectUrl": {
"description": "if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.",
"type": "string"
}
}
},
@@ -2081,7 +2085,7 @@ const docTemplate = `{
"type": "integer"
},
"numImageProcessors": {
"description": "number of concurrent image processing jobs",
"description": "number of concurrent image processing jobs used to create previews, default is number of cpu cores available.",
"type": "integer"
},
"port": {
+5 -1
View File
@@ -1995,6 +1995,10 @@
"header": {
"description": "required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.",
"type": "string"
},
"logoutRedirectUrl": {
"description": "if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.",
"type": "string"
}
}
},
@@ -2070,7 +2074,7 @@
"type": "integer"
},
"numImageProcessors": {
"description": "number of concurrent image processing jobs",
"description": "number of concurrent image processing jobs used to create previews, default is number of cpu cores available.",
"type": "integer"
},
"port": {
+6 -1
View File
@@ -285,6 +285,10 @@ definitions:
description: 'required header to use for authentication. Security Warning:
FileBrowser blindly accepts the header value as username.'
type: string
logoutRedirectUrl:
description: if provider logout url is provided, filebrowser will also redirect
to logout url. Custom logout query params are respected.
type: string
type: object
settings.Recaptcha:
properties:
@@ -341,7 +345,8 @@ definitions:
to be archived (in GB)
type: integer
numImageProcessors:
description: number of concurrent image processing jobs
description: number of concurrent image processing jobs used to create previews,
default is number of cpu cores available.
type: integer
port:
description: port to listen on
+2 -1
View File
@@ -1,5 +1,5 @@
server:
numImageProcessors: 4 # number of concurrent image processing jobs
numImageProcessors: 14 # number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
socket: "" # socket to listen on
tlsKey: "" # path to TLS key
tlsCert: "" # path to TLS cert
@@ -49,6 +49,7 @@ auth:
enabled: false
createUser: false # create user if not exists
header: "" # required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
noauth: false # if set to true, overrides all other auth methods and disables authentication
password: # validate:omitempty
enabled: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+3 -1
View File
@@ -19,7 +19,8 @@
</li>
</ul>
<div v-if="isCardView" class="gallery-size">
{{ $t('files.size') }}<span class="sr-only">:</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("files.size") }}<span class="sr-only">:</span>
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<input
v-model="gallerySize"
type="range"
@@ -215,6 +216,7 @@ export default {
}
.gallery-size {
display: flex;
background: var(--alt-background);
width: fit-content;
padding: 0.5em;
+1 -1
View File
@@ -127,7 +127,7 @@
:aria-label="baseName(s.path)"
>
<a :href="getRelative(s.path)" @contextmenu="addSelected(event, s)">
<Icon :mimetype="s.type" />
<Icon :mimetype="s.type" :filename="s.name" />
<span class="text-container">
{{ basePath(s.path, s.type == "directory") }}/{{ baseName(s.path) }}/<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</span>
+113 -54
View File
@@ -1,5 +1,5 @@
<template>
<span v-if="isPreviewImg">
<span v-if="isPreviewImg && imageState !== 'error'">
<i
v-if="hasMotion"
class="material-icons"
@@ -7,27 +7,35 @@
>animation</i
>
<img
:key="thumbnailUrl"
:src="imageDisplaySrc"
@mouseenter="handleMouseEnter($event)"
@mouseleave="handleMouseLeave($event)"
v-lazy="thumbnailUrl"
:src="currentThumbnail"
class="icon"
ref="thumbnail"
/>
</span>
<span v-else>
<!-- Material Icon -->
<i :class="[classes, { active: active }]" class="icon"> {{ materialIcon }} </i>
</span>
</template>
<script>
import { onlyOfficeUrl, mediaAvailable } from "@/utils/constants";
import { onlyOfficeUrl, mediaAvailable, pdfAvailable, baseURL } from "@/utils/constants";
import { getTypeInfo } from "@/utils/mimetype";
import { mutations, state } from "@/store";
// NEW: Define placeholder and error image URLs for easy configuration
const PLACEHOLDER_URL = baseURL + "static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = baseURL + "static/img/placeholder.png";
export default {
name: "Icon",
props: {
filename: {
type: String,
required: true,
},
mimetype: {
type: String,
required: true,
@@ -45,11 +53,46 @@ export default {
materialIcon: "",
classes: "",
svgPath: "",
previewTimeouts: [], // Store timeout IDs
currentThumbnail: this.thumbnailUrl,
previewTimeouts: [],
// UPDATED: Manage image state directly
imageState: "loading", // Can be 'loading', 'loaded', or 'error'
imageTargetSrc: this.thumbnailUrl, // The URL we currently want to display
};
},
computed: {
pdfConvertable() {
const ext = "." + this.filename.split(".").pop().toLowerCase(); // Ensure lowercase and dot
const pdfConvertCompatibleFileExtensions = {
".pdf": true,
".xps": true,
".epub": true,
".mobi": true,
".fb2": true,
".cbz": true,
".svg": true,
".txt": true,
".doc": true,
".docx": true,
".ppt": true,
".pptx": true,
".xls": true,
".xlsx": true,
".hwp": true,
".hwpx": true, // fix duplication and add this one
};
return !!pdfConvertCompatibleFileExtensions[ext];
},
// NEW: A single computed property to determine the final image src
imageDisplaySrc() {
if (this.imageState === "error") {
return ERROR_URL;
}
// Show placeholder only for the initial load, not during hover animations
if (this.imageState === "loading" && this.imageTargetSrc === this.thumbnailUrl) {
return PLACEHOLDER_URL;
}
return this.imageTargetSrc;
},
showLarger() {
return state.user.viewMode === "gallery" || state.user.viewMode === "normal";
},
@@ -68,10 +111,12 @@ export default {
if (this.thumbnailUrl == "") {
return false;
}
// todo support webp previews
if (this.mimetype == "text/csv") {
return false;
}
if (this.pdfConvertable && pdfAvailable) {
return true;
}
if (this.getIconForType().simpleType === "image" && state.user.preview?.image) {
return true;
}
@@ -89,25 +134,41 @@ export default {
) {
return true;
}
if (
this.getIconForType().simpleType === "pdf" &&
state.user.preview?.office &&
onlyOfficeUrl != ""
) {
return true;
}
return false;
},
},
methods: {
handleMouseEnter() {
if (state.user.viewMode === "gallery" && !state.user.preview.highQuality) {
// NEW: Centralized method to load any image and handle its state
loadImage(url) {
if (!url) {
this.imageState = "error";
return;
}
mutations.setPreviewSource(this.thumbnailUrl);
this.imageState = "loading";
const targetImage = new Image();
if (!state.user.preview.motionVideoPreview) {
targetImage.onload = () => {
// Prevent race conditions: only update if this is the image we still want.
if (this.imageTargetSrc === url) {
this.imageState = "loaded";
}
};
targetImage.onerror = () => {
// Prevent race conditions: only show an error if this is the image that failed.
if (this.imageTargetSrc === url) {
this.imageState = "error";
}
};
targetImage.src = url;
},
handleMouseEnter() {
if (this.imageState == "loaded") {
mutations.setPreviewSource(this.thumbnailUrl);
}
if (!state.user.preview.motionVideoPreview || !this.hasMotion) {
return;
}
@@ -117,64 +178,62 @@ export default {
this.thumbnailUrl + "&atPercentage=50",
this.thumbnailUrl + "&atPercentage=75",
];
let index = 0;
const updateThumbnailUrl = () => {
const img = new Image();
const updateThumbnail = () => {
if (state.popupPreviewSource === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
}
// Set the thumbnail or popup preview
if (state.user.preview.popup) {
mutations.setPreviewSource(sequence[index]);
} else {
this.currentThumbnail = sequence[index];
}
const currentUrl = sequence[index];
// Preload the next image
const nextIndex = (index + 1) % sequence.length;
const preloadImg = new Image();
preloadImg.src = sequence[nextIndex];
img.onload = () => {
if (state.popupPreviewSource === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
}
// Set the thumbnail or popup preview
if (state.user.preview.popup) {
mutations.setPreviewSource(currentUrl);
} else {
this.currentThumbnail = currentUrl;
}
// Preload the next image if it exists
const nextIndex = (index + 1) % sequence.length;
const nextUrl = sequence[nextIndex];
const preloadImg = new Image();
preloadImg.src = nextUrl;
// Schedule next update
index = nextIndex;
const timeoutId = setTimeout(updateThumbnailUrl, 750);
this.previewTimeouts.push(timeoutId);
};
img.src = currentUrl;
// Schedule next update
index = nextIndex;
const timeoutId = setTimeout(updateThumbnail, 750);
this.previewTimeouts.push(timeoutId);
};
updateThumbnailUrl();
updateThumbnail();
},
handleMouseLeave() {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
mutations.setPreviewSource("");
this.currentThumbnail = this.thumbnailUrl;
// UPDATED: Reset to the base thumbnail URL. The watcher will handle reloading it.
this.imageTargetSrc = this.thumbnailUrl;
},
getIconForType() {
return getTypeInfo(this.mimetype);
},
},
watch: {
// UPDATED: Added a check for isPreviewImg
imageTargetSrc: {
handler(newSrc) {
// ONLY trigger the image loader if the component is meant to show a preview.
if (this.isPreviewImg) {
this.loadImage(newSrc);
}
},
immediate: true, // Run this watcher on component mount
},
},
mounted() {
const result = this.getIconForType();
this.classes = result.classes || "material-icons"; // Default class
this.color = result.color || "lightgray"; // Default color
this.classes = result.classes || "material-icons";
this.color = result.color || "lightgray";
this.materialIcon = result.materialIcon || "";
this.svgPath = result.svgPath || ""; // For SVG file paths
this.svgPath = result.svgPath || "";
},
};
</script>
@@ -29,7 +29,7 @@
@mouseup="cancelContext($event)"
>
<div @click="toggleClick" :class="{ 'gallery-div': galleryView }">
<Icon :mimetype="type" :active="isSelected" :thumbnailUrl="thumbnailUrl" />
<Icon :mimetype="type" :active="isSelected" :thumbnailUrl="thumbnailUrl" :filename="name" />
</div>
<div class="text" :class="{ activecontent: isMaximized && isSelected }">
@@ -48,6 +48,7 @@
<Icon
@click="downloadFile"
v-if="quickDownloadEnabled"
:filename="name"
mimetype="file_download"
style="padding-right: 0.5em"
/>
@@ -113,6 +113,7 @@ export default {
overflow: hidden;
z-index: 1000;
transition: all 0.3s ease-in-out;
background: gray;
}
.popup-preview img {
+1
View File
@@ -2,6 +2,7 @@ import { reactive } from 'vue';
import { detectLocale } from "@/i18n";
export const state = reactive({
previousHash: "",
multiButtonState: "menu",
multiButtonLastState: "menu",
showOverflowMenu: false,
+1 -3
View File
@@ -57,9 +57,7 @@ export function generateRandomCode(length) {
}
export function logout() {
if (state.user.loginMethod == "oidc") {
document.cookie = "auth=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
mutations.setCurrentUser(null);
if (state.user.loginMethod == "oidc" || state.user.loginMethod == "proxy") {
let apiPath = getApiPath("api/auth/logout")
window.location.href = apiPath;
return
+2
View File
@@ -21,6 +21,7 @@ const serverHasMultipleSources = window.FileBrowser.SourceCount > 1;
const oidcAvailable = window.FileBrowser.OidcAvailable;
const passwordAvailable = window.FileBrowser.PasswordAvailable;
const mediaAvailable = window.FileBrowser.MediaAvailable;
const pdfAvailable = window.FileBrowser.PdfAvailable;
const origin = window.location.origin;
const settings = [
@@ -32,6 +33,7 @@ const settings = [
];
export {
pdfAvailable,
mediaAvailable,
oidcAvailable,
passwordAvailable,
+1 -1
View File
@@ -28,7 +28,7 @@ run: build-frontend
else \
sed -i '/func init/,+3d' ./swagger/docs/docs.go; \
fi && \
FILEBROWSER_NO_EMBEDED=true go run \
FILEBROWSER_NO_EMBEDED=true go run -tags "pdf" \
--ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'" . -c test_config.yaml
build-frontend: