Compare commits

...
Author SHA1 Message Date
Graham Steffaniak c6a4b435d6 fixed build 2026-11-02 12:28:12 -06:00
Graham Steffaniak 470f3fad92 use dist folder instead of embed 2026-11-02 11:04:59 -06:00
Graham Steffaniak 129168c691 don't hunt for right path 2026-11-01 14:28:23 -06:00
Graham Steffaniak d4fcfe62d5 phase 2 build optimizations 2026-11-01 14:24:04 -06:00
Graham Steffaniak e0730e8c7a Build optimizations (#3318) 2026-11-01 12:55:28 -06:00
Kevin B. 435ded56c6 share ListingView logic with adv search using composables (#3299) 2026-11-01 11:14:18 -06:00
Graham Steffaniak 6f54f77d64 Merge v2.1.4 v2.2.0 (#3317) 2026-11-01 11:03:25 -06:00
Graham Steffaniak 77a70cde6b introduce go-media package for faster processing (#3298) 2026-11-01 10:19:36 -06:00
Graham Steffaniak 77f5e2fe36 move ListingView and advancedSearch to composition api + TS (#3294) 2026-10-30 13:20:31 -05:00
Kevin B. 9766eee0d0 update url.js 2026-10-29 20:41:08 -05:00
Kevin B. fdb107e2bb Merge branch 'dev/v2.2.0' into adv-search-and-list-view-to-composition 2026-10-29 20:07:16 -05:00
Graham Steffaniak f9dddb117c add separate tsconfig for tests files (#3295) 2026-10-29 19:26:12 -05:00
Graham Steffaniak e17f544920 improve navigation in search results (#3275) 2026-10-29 19:16:44 -05:00
Graham Steffaniak c6cc8d56b4 feat: add wrap method option to editor config (#3282) 2026-10-29 19:10:48 -05:00
Kevin B. 802bcd8911 update vite config 2026-10-29 09:21:34 -05:00
Kevin B. db13271bf3 fix slow typecheck reruns 2026-10-29 01:24:51 -05:00
Kevin B. bb8c5f9aa8 add typecheck to CI 2026-10-29 00:04:41 -05:00
Kevin B. 968ce8d066 add separate tsconfig for tests
This to avoid tests files from failing the build if some has a type error.
2026-10-28 23:44:51 -05:00
Kevin B. 356013a137 tests to ts 2026-10-28 22:29:12 -05:00
Graham Steffaniak 7c68c38479 Add HLS media transcoding support (#3168) 2026-10-28 16:38:23 -05:00
Kevin B. dcca322f48 move ListingView and advancedSearch to composition api + TS
This is also part of the changes to create composables for adv search and listing view.

- Updated the tests that were calling functions directly, which don’t exist with <script setup>
- Added missing types.

All should still behaving the same as before.
2026-10-28 15:58:31 -05:00
Kevin B. 4a6e8edc28 use single wrap dropdown 2026-10-28 12:44:02 -05:00
Kevin B. d9c91e1125 Merge branch 'dev/v2.2.0' into feat/editor-wrap-method 2026-10-28 11:23:58 -05:00
Kevin B. 4cdf1e71e4 address coderabbit comment 2026-10-27 22:50:03 -05:00
Kevin B. 9e6c2cd0a3 add playwright tests 2026-10-27 22:19:42 -05:00
Kevin B. c102fe7556 add natural ordering to quick search 2026-10-27 21:28:42 -05:00
Kevin B. d0591b9de4 address coderabbit review 2026-10-27 21:28:42 -05:00
Kevin B. ebe47ce643 improve navigation in search results
- sort header sticky in adv search
- next/previous navigation follows search results of both quick and adv search
- closing preview from quick or adv search shows the previous results and scrolls to the item we just closed
  like the normal listing
- fix previous sorting don't persisting in adv search
2026-10-27 21:28:42 -05:00
Graham Steffaniak fbbf40f877 Fix styling v2.2.0 (#3257) 2026-10-27 19:30:37 -05:00
Kevin B. 1508d9ff21 Improve caches and listing navigation (#3193) 2026-10-27 19:30:03 -05:00
Kevin B. 0e318d6eaa feat: add wrap method option to editor config 2026-10-26 11:47:18 -05:00
Emmanuel Salomon fd64a470d3 fix: sign Collabora sessions with a key derived from auth.key only (#3173) 2026-10-21 18:50:56 -05:00
+5 751cc56809 Merge main v2.2.0 v2.1.3 (#3248)
Co-authored-by: Paolo Valletta <11146190+paoValle@users.noreply.github.com>
Co-authored-by: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com>
Co-authored-by: vulragrag-star <320132805+vulragrag-star@users.noreply.github.com>
Co-authored-by: Anatolii Prylutskyi <5327697+anpryl@users.noreply.github.com>
Co-authored-by: Kevin B. <178341547+Kurami32@users.noreply.github.com>
Co-authored-by: Som Samantray <92726151+SomSamantray@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: HawkForge <75696486+HawkForge@users.noreply.github.com>
Co-authored-by: JUSHUANGHUI LI <71743926+oodadoudou@users.noreply.github.com>
Co-authored-by: anonymous-writer <35840715+anonymous-writer@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 12:55:25 -05:00
Graham Steffaniak c5112cd51c feat: edit office files in Collabora Online through WOPI (#3108) 2026-10-15 19:18:05 -05:00
Graham Steffaniak 90354e468d Feat/quick search fts5 (#3146) 2026-10-14 16:37:19 -05:00
Graham Steffaniak c35e4f33c8 updated config message 2026-10-13 18:47:50 -05:00
Graham SteffaniakandDevin 474cd90e74 Invalidate in-flight search as soon as the query changes
During the 250ms debounce window the sequence counter was not yet bumped,
so a response for the previous query could still pass the stale-response
guard and briefly repopulate results. abortSearch() now bumps the sequence
so any late response is ignored, and onInput() calls it before scheduling
the debounced search.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-13 18:44:59 -05:00
Graham SteffaniakandDevin a483504426 Fix the "no results" message after debouncing quick search
The debounce refactor moved the in-flight counter decrement into a
finally block, so the "no results" check ran while the request was still
counted as in flight and the message never appeared. Evaluate it after
the counter drops, and only for a request that actually completed.

Fixes the settings access.spec.ts search assertion.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-13 18:43:59 -05:00
Graham SteffaniakandDevin 36dba16fd6 Make the FTS5 search index opt-in via indexSqlConfig.fts5
FTS5 speeds up substring searches but costs roughly 40% more indexing
time and 15% more index file size, so it now defaults to off.

- Add indexSqlConfig.fts5 (default false). When off, search uses the
  existing unindexed query and no FTS table or triggers are created.
- When enabled, the index is built from existing rows on startup (no
  rescan); disabling it drops the table and triggers.
- Regenerate config.yaml and swagger docs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-13 18:24:14 -05:00
Graham SteffaniakandDevin 5f3049ebc6 Debounce quick search and add an FTS5 trigram index
Quick search sent a request on every keystroke and each one ran an
unindexed lower(name) GLOB scan, so searches piled up and the last one
finished last. This:

- debounces the search input by 250ms, aborts the superseded request, and
  ignores late responses so the UI cannot show stale results;
- passes the request context into the query (15s timeout) so an abandoned
  search is interrupted instead of holding the index DB connection;
- removes the session-cancellation map, which could not interrupt the scan;
- adds one global FTS5 trigram index over (name, source:path) so substring
  searches use an index. It is derived data: a schema or health mismatch
  deletes and rebuilds the index DB, and it falls back to the old query
  when FTS5 is unavailable.

Also fixes GLOB escaping: SQLite GLOB has no backslash escape, so literal
* ? [ in a search term and in the scope prefix now use bracket classes.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-13 16:51:35 -05:00
manu 74923fbe1b test: view-only session can still read the file through GetFile 2026-10-12 10:48:37 +02:00
manu 59bfdbcb54 fix: allow printing with view permission, as OnlyOffice does
The OnlyOffice integration lets a user print with View alone; the WOPI
editor disabled printing without Download. Align them, so the choice of
editor does not change what a user may do. Export and copy still need
Download.
2026-10-10 04:21:20 +02:00
manu b079dd19e7 fix: match only advertised office extensions
Object.hasOwn instead of `in`, which also matched inherited properties:
a file named report.constructor would have been routed to the editor.

Also correct the serveWopi comment and test name: a moved file is
rejected, a file replaced in place is reported as changed in storage.
2026-10-10 04:21:20 +02:00
manu b3cbd0925e refactor: configure Collabora through integrations.office
Review follow-up on #3108.

- There is no integrations.wopi block any more: integrations.office gains
  product (onlyoffice, the default, or collabora) and tokenExpirationHours.
  secret stays required for OnlyOffice and becomes optional for Collabora.
  Existing OnlyOffice configs are unchanged. With product collabora, the
  OnlyOffice-specific paths (document keys, previews via /converter, CSP
  script origins, onlyOfficeUrl) stand down.
- The SPA receives officeProduct and officeExtensions instead of the wopi*
  globals. WOPI stays the name of the protocol code and routes.
- Discovery is refreshed in the background until shutdown: every 30s while
  it has never loaded (the editor often starts after FileBrowser), then every
  10 minutes. Before, a failed first fetch left Collabora disabled until
  restart.
- WopiEditor shows its close button when the session request fails, instead
  of leaving the user on a blank view.
- Fix the router import broken by the upstream switch to a named export.
2026-10-10 04:21:20 +02:00
manu 9753cfbf8b feat: edit office files in Collabora Online through WOPI
FileBrowser gains a WOPI host, so office documents can open in Collabora
Online (or any WOPI editor, OnlyOffice Docs included) next to the existing
OnlyOffice integration. Refs #641.

- integrations.wopi: url, internalUrl, secret, product, tokenExpirationHours,
  viewOnly. Discovery is fetched from the editor and cached; extensions it
  declares open in the editor, except formats FileBrowser already views
  itself (images, plain text).
- GET /api/wopi/session returns the editor URL and a signed access token
  bound to one file, one user and a mode. The token has its own audience and
  a key derived from the auth key (or integrations.wopi.secret), so it never
  doubles as a session token.
- /wopi/files/{id}[/contents]: CheckFileInfo, GetFile, PutFile and the lock
  operations. Permissions are re-checked on every call, so revoking access
  takes effect mid-session. PutFile receives the whole body before writing,
  and answers Collabora's COOLStatusCode 1010 when the file changed in
  storage since it was loaded, letting the user overwrite or reload.
- access_token is redacted from request logs.
- WopiEditor.vue form-posts the token into an iframe and handles UI_Close.
  Public shares keep using OnlyOffice for now.
2026-10-10 04:21:20 +02:00
644 changed files with 48232 additions and 9616 deletions

No files matched your search

-1
View File
@@ -9,7 +9,6 @@ backend/filebrowser.sqlite*
backend/tmp
backend/dist
backend/backend*
backend/internal/web/dist/**
backend/vendor/**
frontend/dist/**
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Builds the comma-separated Docker tag list for a release image.
#
# Usage: docker-release-tags.sh <metadata.json> <variant> <update_track> <update_major>
# metadata.json JSON output of docker/metadata-action (uses .tags)
# variant default | slim | media
# default and media: floating tags include latest
# slim: every tag gets a -slim suffix and latest is not added
# update_track true when the semver policy allows floating beta/stable/latest tags
# update_major true when the semver policy allows major-only version tags
#
# Image repositories are fixed per variant so that each image only gets its own tags.
set -euo pipefail
if [[ $# -ne 4 ]]; then
echo "usage: $0 <metadata.json> <default|slim|media> <true|false> <true|false>" >&2
exit 2
fi
metadata_file=$1
variant=$2
update_track=$3
update_major=$4
case "$variant" in
default)
suffix=""
add_latest=true
repos=(gtstef/filebrowser ghcr.io/gtsteffaniak/filebrowser)
;;
slim)
suffix="-slim"
add_latest=false
repos=(gtstef/filebrowser ghcr.io/gtsteffaniak/filebrowser)
;;
media)
suffix=""
add_latest=true
repos=(gtstef/filebrowser-media ghcr.io/gtsteffaniak/filebrowser-media)
;;
*)
echo "unknown variant: $variant" >&2
exit 2
;;
esac
jq -r \
--arg suffix "$suffix" \
--argjson addLatest "$add_latest" \
--argjson updateTrack "$update_track" \
--argjson updateMajor "$update_major" \
--arg dh "${repos[0]}" \
--arg gh "${repos[1]}" '
# Strip the v prefix, drop metadata latest, and append the variant suffix.
.tags |= map(
sub(":v"; ":")
| select(test(":latest") | not)
| . + $suffix
) |
# Version tags with a prerelease suffix (X.Y.Z-suffix) get major.minor, and major when allowed.
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $tag_suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "\($dh):\($major_minor)-\($tag_suffix)",
"\($gh):\($major_minor)-\($tag_suffix)",
if $updateMajor then
"\($dh):\($major)-\($tag_suffix)",
"\($gh):\($major)-\($tag_suffix)"
else empty end
] |
# Floating beta tag.
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta" + $suffix)) then
.tags += ["\($dh):beta\($suffix)", "\($gh):beta\($suffix)"]
else . end |
# Floating stable tag, plus latest for variants that have it.
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable" + $suffix)) then
.tags += (
["\($dh):stable\($suffix)", "\($gh):stable\($suffix)"]
+ (if $addLatest then ["\($dh):latest", "\($gh):latest"] else [] end)
)
else . end |
.tags | join(",")
' "$metadata_file"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run build
run: npm ci && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
+29 -6
View File
@@ -37,7 +37,7 @@ jobs:
node-version-file: frontend/.nvmrc
- name: Build frontend
working-directory: frontend
run: npm i && npm run build
run: npm ci && npm run build
- name: Upload frontend build artifact
uses: actions/upload-artifact@v4
with:
@@ -97,7 +97,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: dist
path: frontend/dist
path: backend/internal/web/dist
- name: Download backend build
uses: actions/download-artifact@v4
with:
@@ -105,10 +105,6 @@ jobs:
path: backend/
- name: Make backend executable
run: chmod +x backend/filebrowser
- name: Copy frontend build to backend
run: |
mkdir -p backend/internal/web/embed
cp -r frontend/dist/* backend/internal/web/embed
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
@@ -166,3 +162,30 @@ jobs:
REVISION=${{ steps.meta.outputs.revision }}
cache-from: type=gha
cache-to: type=gha,mode=max
build_media_docker_image:
name: Build media docker image
runs-on: ubuntu-24.04-arm
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: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.media
push: false # Do not push the image for now
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
cache-from: type=gha,scope=pr-media
cache-to: type=gha,mode=max,scope=pr-media
+22 -4
View File
@@ -8,6 +8,7 @@ on:
jobs:
test-backend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -17,6 +18,7 @@ jobs:
run: go test -race -v ./... -timeout 30s
lint-backend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -28,6 +30,7 @@ jobs:
cd backend && "$GOLANGCI_LINT" run --path-prefix=backend
format-backend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -37,33 +40,47 @@ jobs:
run: go fmt ./...
lint-frontend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run lint
run: npm ci && npm run lint
test-frontend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run test
run: npm ci && npm run test
typecheck-frontend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm ci && npm run typecheck
check-translations:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run i18n:check
run: npm ci && npm run i18n:check
check-docs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -73,10 +90,11 @@ jobs:
- run: git diff --exit-code
check-icons:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run icons:check
run: npm ci && npm run icons:check
+71 -88
View File
@@ -15,15 +15,22 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies and build frontend
run: npm i && npm run build
run: npm ci && npm run build
working-directory: frontend
- name: Install OS Requirements
run: sudo apt update && sudo apt install -y upx build-essential musl-tools
run: sudo apt update && sudo apt install -y musl-tools
- uses: goto-bus-stop/setup-zig@v2
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
@@ -68,50 +75,8 @@ jobs:
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# Add suffix-only tags for beta when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
# Add suffix-only tags for stable (and latest) when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-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
printf '%s' "$JSON" > metadata.json
TAGS_CSV=$(bash .github/scripts/docker-release-tags.sh metadata.json default '${{ steps.floating.outputs.update_track }}' '${{ steps.floating.outputs.update_major }}')
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
@@ -125,6 +90,62 @@ jobs:
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-main
cache-to: type=gha,mode=max,scope=release-main
push_media_release_to_registry:
name: Push media 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-media
ghcr.io/gtsteffaniak/filebrowser-media
- name: Decide floating Docker tags
id: floating
env:
IMAGE_REPO: gtstef/filebrowser-media
run: bash .github/scripts/docker-floating-tag-policy.sh
- name: Modify tags (strip 'v' and add version tags)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
printf '%s' "$JSON" > metadata.json
TAGS_CSV=$(bash .github/scripts/docker-release-tags.sh metadata.json media '${{ steps.floating.outputs.update_track }}' '${{ steps.floating.outputs.update_major }}')
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
file: ./_docker/Dockerfile.media
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-media
cache-to: type=gha,mode=max,scope=release-media
push_slim_release_to_registry:
name: Push slim release
runs-on: ubuntu-latest
@@ -162,48 +183,8 @@ jobs:
id: modify-json-slim
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# Add suffix-only tags for beta-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
# Add suffix-only tags for stable-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
printf '%s' "$JSON" > metadata.json
TAGS_CSV=$(bash .github/scripts/docker-release-tags.sh metadata.json slim '${{ steps.floating.outputs.update_track }}' '${{ steps.floating.outputs.update_major }}')
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push slim image
uses: docker/build-push-action@v6
@@ -217,3 +198,5 @@ jobs:
push: true
tags: ${{ steps.modify-json-slim.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-slim
cache-to: type=gha,mode=max,scope=release-slim
+2 -2
View File
@@ -43,8 +43,8 @@ frontend/.tmp-icons-*
/backend/*test_config.yaml
/backend/generated.yaml
/backend/srv
/backend/internal/web/dist
/backend/internal/web/embed
/backend/internal/web/dist/*
!/backend/internal/web/dist/.gitkeep
/backend/debug
/backend/http
+95
View File
@@ -2,6 +2,101 @@
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).
## v2.2.0
**New Features**:
- Core web assets are loaded into memory instead of served from disk.
- Media enhancements
- All server media settings can be managed in settings by admins.
- Transcoding for media playback with quality presets -- requires ffmpeg.
- Hardware Acceleration device selector (Software / detected GPU devices)
- More operations use native api operations rather than ffmpeg, improving performance in many cases. Added temporary config option to force ffmpeg to be used
- Running without ffmpeg can still view metadata and create thumbnails for many common file formats.
# office previews can use embedded thumbnail if possible
# text file previews are more efficient direct creation
**Notes**:
- Smaller binaries: the Swagger UI serves only the four files it needs instead of the full swagger-ui bundle
- UPX compression is removed from release builds.
- Runtime debug tooling (pprof) is only compiled in with `-tags debug`. Dev targets (`make run`, `make dev`) enable it automatically.
- The CGO SQLite driver and the `cgosql` build tag are removed.
- A `filebrowser-media` image (`_docker/Dockerfile.media`) is published alongside the main and slim images.
- Non-dev builds (including Docker) load the frontend into memory at startup when preload succeeds; if preload fails, assets are served from disk. Dev mode still serves from disk.
- Frontend build output lives only in `backend/internal/web/dist`. Release builds compile that tree into the binary with `go:embed`; Docker and other disk-only runs preload or serve the same `dist` folder at runtime.
- Release image tags are generated by one script (`.github/scripts/docker-release-tags.sh`) for the main, slim and media images.
- `make build-docker-media` builds the media image locally.
**Bug Fixes**:
- Closing a video preview from the header bar now stops playback immediately instead of leaving audio running in the background.
- Closing a preview no longer navigates back to the same file when a stale "previous item" was remembered from an earlier navigation.
- Transcoding recovers from unreliable hardware decoding without discarding the segments already produced: the retry resumes at the encode head with software decode instead of wiping the generation and re-encoding from the start.
- The player no longer aborts a segment request after 10 seconds while the transcode producer is repositioning or restarting, which previously surfaced as a 503 and a skipped segment.
- getDirectoryPreview no longer requests metadata, so folder previews skip the per-file probe and subtitle scan.
- Media probes are cached by file identity and concurrent reads are coalesced, so opening a file no longer triggers repeated ffprobe runs.
- Client-initiated stream cancels (seek/navigation) no longer log spurious write-error responses.
- Gzipped `.woff` font files are no longer generated without being served; only `.js` and `.woff2` are precompressed.
## v2.1.4
**Security**:
- [Low] `GET /api/settings` and `GET /api/settings?property=auth` no longer return cleartext secrets (`auth.key`, `totpSecret`, `adminPassword`, OIDC `clientSecret`, LDAP bind password, recaptcha secret, OnlyOffice secret); secret fields are replaced with `**hidden**` in the response, and the YAML export now redacts the recaptcha and LDAP secrets whose comments lacked the `secret:` marker (GHSA-892f-9h5p-mfc8) reporter @paulmossman
**New Features**:
- feat(search): sidebar File actions and close button in advanced search (#3303) (#2479) (#3303)
**Bug Fixes**:
- Deleting a directory now releases folder quotas rooted inside it instead of leaving them charged for files that no longer exist; applies to API and WebDAV deletes (#3163) (#3235)
- Bulk and public share deletes now release accounted quota usage; nested folder quotas reset when an ancestor is removed (#3163) (#3305)
- Directory listings skip entries that return permission denied or not-found on per-entry stat instead of failing the whole folder (e.g. unreadable NTFS junctions on CIFS); skipped entries are omitted from the index like `ls -l` (#3184)
- Preview generation for webp broken (#3254) (#3259)
- Share banner never shows to public visitors, returns bannerUrl but not banner (#3246) (#3250)
- fix html/markdown relative file preview support (#3131) (#3129) (#2355)
- fix(archive): extract tar hard links instead of dropping them (#3234)
- fix(auth): apply stored permission caps for customized API tokens with no global flags enabled (#3255)
- Archive extraction writes regular and hard-link entries at the entry path instead of following an existing symlink at that path (tar and zip) (#3265)
- fix(move-copy): require choosing a destination source in advanced search when none can be inferred, instead of silently copying to the source root (#3276)
- fix(stream): single-file shares can now stream their own media on Windows by using slash-based path handling (#3270) (#3271)
- fix(users): `lockPassword` is now enforced on self-service password changes; non-admins get 403 while admin resets still work (#3279) (#3283)
- fix(analytics): the FreeBSD release binary now reports `freebsd` in `system.os` instead of `other` (#3244) (#3284)
- fix(frontend): share expiration now reads backend Unix-seconds timestamps correctly instead of showing dates decades off (#3247)
- fix(frontend): video on iPhone now uses the native fullscreen player instead of the in-page fallback (#3113) (#3288)
- fix(frontend): the video loading spinner no longer stays on for the rest of playback when the fetch stalls but the buffer can continue (#3287) (#3289)
- fix(frontend): the share banner is now shown to public visitors, using `bannerUrl` instead of the never-set `banner` field (#3246) (#3250)
- fix(preview): camera RAW files (Panasonic `.rw2`/`.raw`/`.rwl`, Olympus `.orf`, `.cr2`/`.dng`) extract embedded previews from TIFF strips/SubIFDs and no longer return 415 when EXIF decoding fails (#3219) (#3243)
- fix(indexing): restore lazy listing persistence with atomic folder-size deltas and ancestor propagation, and force a full scan for scanners that missed their slot while the process was down (#3260)
- fix(indexing): directory listings skip entries that cannot be accessed or have vanished instead of failing the whole listing; remaining errors still fail and fully-vanished nonempty directories return an error (#3292)
- fix(frontend): redirects from `/files` stay inside the files area so a source named "Files" no longer leaves an empty view after login (#3167) (#3304)
## v2.1.3
**Security**:
- [Moderate] Passkey registration and deletion now require the account password when a passkey is already configured, and passkeys cannot be altered through generic user profile PATCH (GHSA-r744-4vm3-2px3) reporter @tonghuaroot
- [Medium] Storage quota accounting now records upload byte deltas using the pre-replace file size, and upload paths without size metadata or with resumed chunk offsets cannot skip quota checks (GHSA-m57f-66qj-8jxg) reporter @jayhemnani9910
- [High] Sources marked `readOnly` are now enforced on WebDAV writes, server-side archive/unarchive, and OnlyOffice save callbacks (GHSA-4v62-884x-2466) reporter @anpryl
- [Moderate] Moving or renaming a folder updates only shares whose paths are under the moved prefix, preserving per-file share roots instead of retargeting unrelated substring matches (GHSA-26fh-r34c-qxh6) reporters @jayhemnani9910 @dylanzonix
- [Moderate] WebDAV PUT now requires Create permission when the target path does not yet exist, so Modify-only users cannot create new files (GHSA-8655-2ph3-q7q7) reporter @levanduy181
- [Low] Failed-login lockout and auth rate limits now use the rightmost `X-Forwarded-For` hop when proxy headers are trusted, so a client cannot spoof the client IP with a leftmost forwarded value (GHSA-wjp8-g49g-xrhx) reporter @LECOQQ
- [Low] Duplicate finder results are cached per requesting user so filtered file names from one account are not served to another (GHSA-4fhx-8pf6-5gmr) reporter @Nazarami
- [Moderate] Authenticated `GET /api/resources/view` no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
- [High] Incomplete symlink-containment remediation: an authenticated non-admin user could create a relative symlink via archive extraction, relocate it with WebDAV MOVE, and write through it to arbitrary host paths with the service account's privileges (GHSA-3j46-5w28-f8p2) reporter @Saku0512
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
- [Moderate] Preview reads resolve symlinks within the caller's scope so authenticated preview cannot follow an out-of-scope symlink target (GHSA-g2q5-r6ch-v25f) reporter @tao0845
- [Moderate] Archive downloads and extraction no longer read or write through symlinks that resolve outside the caller's share or user scope; escaping archive-walk entries are skipped (GHSA-qqf3-2wvq-q4w9) reporter @SmokinCaterpillar
- [Moderate] Share directory-archive downloads now apply the share owner's access rules to every entry, closing a bypass where anonymous share visitors could download admin-denied content (GHSA-9fcr-gm58-p839) reporter @Tan-JunWei
- [Moderate] Access rule paths are stored and matched case-insensitively so rules cannot be bypassed via path casing on case-insensitive filesystems; existing rules are migrated at startup (GHSA-4vrc-935w-xxq2) reporter @FrogApprentice
**New Features**:
- chunked uploads for large files like rclone `--owncloud-chunk-size` (#3112)
**Bug Fixes**:
- keep requested permissions when creating a user (#3130)
- add missing file modify logging to activity viewer (#3152)
- fix(frontend): restore upload prompt close, tab-reload, and in-app navigation guards while uploads are active (including paused uploads)
- fix(frontend): load the default source when browsing `/files/` with a single configured source ([#3178](https://github.com/gtsteffaniak/filebrowser/issues/3178)) (related: [#3143](https://github.com/gtsteffaniak/filebrowser/issues/3143), [#3144](https://github.com/gtsteffaniak/filebrowser/issues/3144))
- Path picker list and thumbnails not working in shares in the markdown editor (#3223).
- perf(indexing): single statx per Linux directory entry (#1890)
## v2.1.2
**Security**:
+8 -5
View File
@@ -1,24 +1,27 @@
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
# syntax=docker/dockerfile:1.4
FROM gtstef/ffmpeg:9.0.2 AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend/go.mod ./backend/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev
RUN go mod download && go run ./scripts/setup-gofitz-cgo
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go run ./scripts/setup-gofitz-cgo
ENV CGO_ENABLED=1
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics,mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:jod-slim AS nbuild
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN cd frontend && npm ci --maxsockets 1
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
+46
View File
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1.4
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend/go.mod ./backend/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go run ./scripts/setup-gofitz-cgo
ENV CGO_ENABLED=1
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics,mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
# copy config to two locations for compatibility
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/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1
ENTRYPOINT [ "./filebrowser" ]
+81
View File
@@ -0,0 +1,81 @@
# syntax=docker/dockerfile:1.4
# GPU-capable media image: mostly-static glibc FFmpeg + dynamic VA/Vulkan/OpenCL drivers (amd64).
# Codecs/TLS are linked into gtstef/ffmpeg:<ver> (full track, not *-static).
#
# amd64: libva/Mesa/Vulkan/OpenCL userspace below; NVIDIA libs come from the host toolkit.
# arm64 (e.g. Raspberry Pi): h264_v4l2m2m uses kernel V4L2 only — no extra GPU .debs; pass
# /dev/video10 or /dev/video11 (and group video). Pi 5 VAAPI via /dev/dri is optional (mesa on arm64).
FROM gtstef/ffmpeg:9.0.2 AS ffmpeg
FROM golang:bookworm AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend/go.mod ./backend/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY ./backend ./
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
&& rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go run ./scripts/setup-gofitz-cgo
ENV CGO_ENABLED=1
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics,mupdf -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
FROM debian:bookworm-slim AS runtime
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
mailcap \
tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& (dpkg --print-architecture | grep -q amd64 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
libva2 \
libva-drm2 \
libdrm2 \
libvulkan1 \
mesa-va-drivers \
mesa-vulkan-drivers \
ocl-icd-libopencl1 \
intel-media-va-driver \
libmfx1 \
&& rm -rf /var/lib/apt/lists/* \
|| true)
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,video,utility,graphics
RUN useradd -m -u 1000 -s /usr/sbin/nologin filebrowser
USER filebrowser
WORKDIR /home/filebrowser
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/" ]
RUN filebrowser version && ffmpeg -version && ffprobe -version
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1
ENTRYPOINT [ "./filebrowser" ]
+1 -1
View File
@@ -12,7 +12,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
# generate duplicate 1.1MB files
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
+1 -1
View File
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser user set testuser1 --password testuser1
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -7,7 +7,7 @@ WORKDIR /app/backend/
RUN mkdir -p ./internal/web/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
# Run tests
WORKDIR /app/frontend
+1 -1
View File
@@ -23,7 +23,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
ENV CI=true
+4 -2
View File
@@ -1,5 +1,8 @@
FROM gtstef/ffmpeg:9.0.2 AS ffmpeg
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/previews/", "./" ]
@@ -10,8 +13,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1 -1
View File
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
+1 -1
View File
@@ -10,7 +10,7 @@ COPY [ "./backend/filebrowser", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+1 -1
View File
@@ -13,7 +13,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.4
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend/go.mod ./backend/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY ./backend ./
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -trimpath -tags analytics -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM --platform=$BUILDPLATFORM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates
RUN --mount=type=cache,target=/root/.npm cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
# copy config to two locations for compatibility
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/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1
ENTRYPOINT [ "./filebrowser" ]
+4
View File
@@ -20,3 +20,7 @@ userDefaults:
account:
permissions:
share: true
integrations:
media:
skipHwCheck: true
+4
View File
@@ -51,3 +51,7 @@ userDefaults:
video: true
office: true
highQuality: true
integrations:
media:
skipHwCheck: true
+4 -1
View File
@@ -64,4 +64,7 @@ userDefaults:
popup: true
video: true
office: true
highQuality: true
highQuality: true
integrations:
media:
skipHwCheck: true
+4 -1
View File
@@ -50,4 +50,7 @@ userDefaults:
permissions:
admin: false
share: false
api: false
api: false
integrations:
media:
skipHwCheck: true
@@ -18,3 +18,7 @@ userDefaults:
account:
permissions:
share: true
integrations:
media:
skipHwCheck: true
+7
View File
@@ -39,3 +39,10 @@ userDefaults:
office: true
models: true
highQuality: true
integrations:
media:
skipHwCheck: true
# Real HLS transcoding for the preview specs (requires ffmpeg in the image).
transcode:
enabled: true
@@ -13,23 +13,20 @@ export default defineConfig({
globalSetup: "./tests/playwright/previews-setup",
timeout: 10000,
testDir: "./tests/playwright/previews",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
fullyParallel: false,
forbidOnly: false,
/* Retry on CI only */
retries: 2,
workers: 10,
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
"--autoplay-policy=no-user-gesture-required",
"--disable-gpu",
"--use-gl=swiftshader",
"--enable-webgl",
"--disable-dev-shm-usage",
"--no-sandbox",
],
},
storageState: "previews.json",
+4
View File
@@ -40,3 +40,7 @@ userDefaults:
account:
permissions:
share: true
integrations:
media:
skipHwCheck: true
+4 -1
View File
@@ -19,4 +19,7 @@ userDefaults:
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
gallerySize: 3
account:
showAdvancedProfile: true
showAdvancedProfile: true
integrations:
media:
skipHwCheck: true
+4 -1
View File
@@ -48,4 +48,7 @@ auth:
userDefaults:
listing:
showHidden: true
showHidden: true
integrations:
media:
skipHwCheck: true
+4 -1
View File
@@ -27,4 +27,7 @@ frontend:
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
disableEventThemes: true
integrations:
media:
skipHwCheck: true
+1 -1
View File
@@ -7,7 +7,7 @@ env_files = [".air.env"]
entrypoint = "./tmp/filebrowser"
args_bin = ["-c", "test_config.yaml"]
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
poll = true
poll_interval = 1000
log = "air.log"
+1 -1
View File
@@ -17,7 +17,7 @@ linters:
exhaustive:
check:
- switch
- map
- mapliteral
exhaustruct:
exclude:
- ^net/http.Client$
+16 -14
View File
@@ -21,19 +21,20 @@ builds:
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
flags:
- -trimpath
goos:
- linux
goarch:
- amd64
hooks:
post:
- upx {{ .Path }}
# Build configuration for linux arm64
- id: linux-arm64
ldflags: *ldflags
main: main.go
binary: filebrowser
flags:
- -trimpath
env:
- CGO_ENABLED=1
- CC=zig cc -target aarch64-linux-musl
@@ -45,15 +46,14 @@ builds:
- linux
goarch:
- arm64
hooks:
post:
- upx {{ .Path }}
# Build configuration for linux riscv64
- id: linux-riscv64
ldflags: *ldflags
main: main.go
binary: filebrowser
flags:
- -trimpath
env:
- CC=zig cc -target riscv64-linux-musl
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
@@ -70,6 +70,8 @@ builds:
ldflags: *ldflags
main: main.go
binary: filebrowser
flags:
- -trimpath
tags:
- analytics
goos:
@@ -79,9 +81,6 @@ builds:
goarm:
- "6"
- "7"
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm
- id: windows
@@ -95,19 +94,20 @@ builds:
ldflags: *ldflags
main: main.go
binary: filebrowser
flags:
- -trimpath
goos:
- windows
goarch:
- amd64
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for freebsd without arm & upx
# Build configuration for freebsd without arm
- id: freeBSD and freebsd-arm
ldflags: *ldflags
main: main.go
binary: filebrowser
flags:
- -trimpath
tags:
- analytics
goos:
@@ -115,11 +115,13 @@ builds:
goarch:
- amd64
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
# Build configuration for macos (no mupdf: CGO cross-compile from Linux CI is unsupported)
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
flags:
- -trimpath
tags:
- analytics
goos:
+23 -23
View File
@@ -28,7 +28,7 @@ require (
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
@@ -45,7 +45,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/bidichk v0.3.4 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
@@ -54,7 +54,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -101,16 +101,16 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indir
github.com/gohugoio/hashstructure v1.1.0 // indirect
github.com/gohugoio/hugo v0.165.0 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/golangci-lint/v2 v2.15.0 // indirect
github.com/golangci/golines v0.16.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 // indirect
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -129,10 +129,10 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kisielk/errcheck v1.30.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/kunwardeep/paralleltest v1.0.16 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.10.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
@@ -147,17 +147,17 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mgechev/revive v1.17.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.13.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nishanths/exhaustive v0.15.0 // indirect
github.com/nishanths/predeclared v0.3.0 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
@@ -200,15 +200,15 @@ github.com/mgechev/revive v1.17.0 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/timonwong/loggercheck v0.13.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.13.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/uudashr/gocognit v1.3.0 // indirect
github.com/uudashr/iface v1.5.3 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v1.2.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
@@ -225,14 +225,14 @@ github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/mod v0.42.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.50.0 // indirect
golang.org/x/tools v0.52.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.8.1 // indirect
honnef.co/go/tools v0.8.2 // indirect
mvdan.cc/gofumpt v0.12.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
+42 -52
View File
@@ -56,8 +56,6 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 h1:dXPJ+uOJLWb8rjlAuLX7JPiRJtZKw/53Ol+WxdDJ1Eo=
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606/go.mod h1:ZH31IJyNpxpStxP2ayCcPQz+3T2G/OnxIQqe4LWvSDw=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -122,8 +120,8 @@ github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+j
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU=
github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM=
github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
github.com/breml/bidichk v0.3.4 h1:70G6w80G6GDZTUURHTC5gBKVjMLMw2736CzXquSf5Ek=
github.com/breml/bidichk v0.3.4/go.mod h1:GOHprnh4i0V7HrvWjtbvbpicTUcqoYt9zYESKcJkk4M=
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
@@ -140,8 +138,6 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 h1:ZGHc3eaN2TIhkpwRibtr8N9jLQJOY1rh6GEByRoRmp4=
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
@@ -286,28 +282,26 @@ github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJ
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8=
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4=
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo=
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f h1:pOnwRNeWLPkADfkgqKcCP7RPQAS9OIzffaBaktRsKnk=
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f/go.mod h1:aXo+AVSX0pC3TRo2wAfR92YwBKfG6C2eal+omjKXwYw=
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
github.com/golangci/golangci-lint/v2 v2.14.0 h1:ot8QffRa4LzAAEgtvNYrVs9esxQ7xoTwoAv6uhp4ngA=
github.com/golangci/golangci-lint/v2 v2.14.0/go.mod h1:amHimCzNDP1GfVno3hIiNssjvqXmuW+CPZAHOdR4cIc=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
github.com/golangci/golines v0.16.0 h1:xKdwzRvwzLd7V131B/dv90vUiEoTA0vQybdeqperPOg=
github.com/golangci/golines v0.16.0/go.mod h1:mCMgRQHiCvuALJOeaNtylu09zbzvS9Ka01dfvLLJios=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 h1:0kPqfqytQizeQ9WBk487NcsGcRdS8YhHeUhXxkfEAhA=
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241/go.mod h1:lczq5oJ8PxpMESQG6dO3iFYTQPhpqoMOYw//n2FEnHA=
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 h1:oN042y1yEcpldrqY81puyNA4ZjQZJzt0U/t7HyYrMic=
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8/go.mod h1:T1Bg58Sk3N352aBbPLJPpHykFWxEEwV3o4/Enn+PPU8=
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
@@ -363,8 +357,8 @@ 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/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.20.0 h1:9rwHBNKzd4wkDWcROy3DvFGNqEPlkxBg305rvk7HabI=
github.com/kisielk/errcheck v1.20.0/go.mod h1:O+f80MKNwX8Oor2jwgpeQ9An7uJm+hRSgT+h22knRJU=
github.com/kisielk/errcheck v1.30.0 h1:ZIxNDAfAd4larXHTceoZP/dYPAB90dHg1OYhA2sVh5k=
github.com/kisielk/errcheck v1.30.0/go.mod h1:uiCAInmFuyOje4VDdbQSP9mgqf5gORYSXrDf19XilsI=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -376,8 +370,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w=
github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
github.com/kunwardeep/paralleltest v1.0.16 h1:I0gQkLNgfigjpxWAGhx7PYj3VpswzfGrSV/715t6g+o=
github.com/kunwardeep/paralleltest v1.0.16/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
@@ -414,16 +408,14 @@ github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
github.com/mgechev/revive v1.17.0 h1:hwTTVC3svSv9X2FA2hiYT17Uws+lfxDWhPxzBZZCREg=
github.com/mgechev/revive v1.17.0/go.mod h1:uiVNUF8ruVh4WHm/aPs7kKEeuo+m6oO3tuOfniyzP/g=
github.com/mgechev/revive v1.17.1 h1:z8hV0L3uJ6yrwOMnJbkc5qBJpxXa2AxYKFKERGJyA4s=
github.com/mgechev/revive v1.17.1/go.mod h1:FRd64pJwWSEE6j8ESoaX0PixPzjh2rpNSfckbEu3rIE=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -442,10 +434,10 @@ github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
github.com/nishanths/exhaustive v0.13.0 h1:Rof0axOSNQaaPaPmLK5RxcRrJV2Vcv+vfUmuRy+2EAM=
github.com/nishanths/exhaustive v0.13.0/go.mod h1:3/adQjJgvD/UFHHSYBjpK9Wbz8DOvf0FBtzx+zI0Rlo=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nishanths/exhaustive v0.15.0 h1:uFjiO/WvrwQG+ojSZ+vtl/jAFbqA2oAtfpUWK/iKQ8M=
github.com/nishanths/exhaustive v0.15.0/go.mod h1:2by9rYmg9FnsnLQC4Ixig4fQaWxRsY7RluxJuAu9sa0=
github.com/nishanths/predeclared v0.3.0 h1:W3i3JK/DZS5h7QwsEIyU8ler4Z2vwEwQSxNrZzhnlfk=
github.com/nishanths/predeclared v0.3.0/go.mod h1:gN4uryPExDME/zmZdqkKIPSAqcfprj316ZDI6UTqz70=
github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
@@ -575,14 +567,12 @@ github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
github.com/timonwong/loggercheck v0.13.0 h1:6amIOmLLPFYt24rXUbNWzcJeNGuJc+3c31tZtO85VHs=
github.com/timonwong/loggercheck v0.13.0/go.mod h1:thn3allpox0fI5JeUWTCwiwp45peQxCZZHkHuA1Oc6I=
github.com/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
github.com/tomarrell/wrapcheck/v2 v2.13.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
@@ -591,10 +581,10 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA=
github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
github.com/uudashr/gocognit v1.3.0 h1:Xdw5JwAoYmX3WAB+wsXY3nxEwdw7+MWAnM0wCAAv7A4=
github.com/uudashr/gocognit v1.3.0/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
github.com/uudashr/iface v1.5.3 h1:N61bzr11L7BHP6Rct+C6zQePHjSTMeyVBq+s03uB/Ds=
github.com/uudashr/iface v1.5.3/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
@@ -659,8 +649,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -670,8 +660,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -679,8 +669,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -694,8 +684,8 @@ golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -716,8 +706,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -734,8 +724,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
honnef.co/go/tools v0.8.2 h1:+jPbCzuYxYrcoOvCecYUFDj+XJQRmzIhJmlbpps21Zk=
honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew=
+16 -11
View File
@@ -293,18 +293,23 @@ func createConfig(configpath string, noInput bool) error {
return nil
}
// setupConfigYAMLCandidates lists on-disk setup templates for config.generated.yaml.
var setupConfigYAMLCandidates = []string{
"frontend/public/config.generated.yaml",
"../frontend/public/config.generated.yaml",
"internal/web/dist/config.generated.yaml",
"../internal/web/dist/config.generated.yaml",
"backend/internal/web/dist/config.generated.yaml",
}
func readSetupConfigTemplateYAML() (string, error) {
return settings.ReadFirstExistingFile(setupConfigYAMLCandidates)
}
func generateSetupConfigYAML(config *settings.Settings) (string, error) {
embeddedPaths := []string{
"frontend/public/config.generated.yaml",
"../frontend/public/config.generated.yaml",
"internal/web/dist/config.generated.yaml",
"../internal/web/dist/config.generated.yaml",
}
for _, path := range embeddedPaths {
embeddedYaml, err := os.ReadFile(path)
if err == nil && len(embeddedYaml) > 0 {
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
}
embeddedYaml, err := readSetupConfigTemplateYAML()
if err == nil {
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, embeddedYaml)
}
return settings.GenerateConfigYaml(config, true, false, false)
}
+47
View File
@@ -0,0 +1,47 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func writeSetupConfigYAML(t *testing.T, root, relPath, content string) {
t.Helper()
path := filepath.Join(root, filepath.FromSlash(relPath))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadSetupConfigTemplateYAML_fromDist(t *testing.T) {
root := t.TempDir()
writeSetupConfigYAML(t, root, "internal/web/dist/config.generated.yaml", "dist-content")
t.Chdir(root)
got, err := readSetupConfigTemplateYAML()
if err != nil {
t.Fatal(err)
}
if got != "dist-content" {
t.Fatalf("readSetupConfigTemplateYAML() = %q, want dist-content", got)
}
}
func TestReadSetupConfigTemplateYAML_prefersFrontendPublic(t *testing.T) {
root := t.TempDir()
writeSetupConfigYAML(t, root, "frontend/public/config.generated.yaml", "from-public")
writeSetupConfigYAML(t, root, "internal/web/dist/config.generated.yaml", "from-dist")
t.Chdir(root)
got, err := readSetupConfigTemplateYAML()
if err != nil {
t.Fatal(err)
}
if got != "from-public" {
t.Fatalf("readSetupConfigTemplateYAML() = %q, want from-public", got)
}
}
+3
View File
@@ -289,6 +289,9 @@ func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
prevVersion := user.Version
if len(user.BackendScopes) == 0 {
settings.ApplyUserDefaults(user)
if !user.Permissions.Admin {
settings.ExpandBackendScopesForCreateUserDir(user)
}
}
if prevVersion < users.SourcePermissionsMigrationVersion {
// ApplyUserDefaults bumps Version; restore pre-defaults version so legacy global file ops migrate to scopes.
+4 -1
View File
@@ -27,6 +27,9 @@ func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T
prevVersion := user.Version
if len(user.BackendScopes) == 0 {
settings.ApplyUserDefaults(user)
if !user.Permissions.Admin {
settings.ExpandBackendScopesForCreateUserDir(user)
}
}
if prevVersion < users.SourcePermissionsMigrationVersion {
user.Version = prevVersion
@@ -50,7 +53,7 @@ func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T
t.Fatalf("scope permissions after migration: %+v", perms)
}
if user.BackendScopes[0].Scope != "/legacy-bolt-user" {
t.Fatalf("createUserDir scope=%q", user.BackendScopes[0].Scope)
t.Fatalf("scope after applying migration defaults=%q", user.BackendScopes[0].Scope)
}
}
+19 -12
View File
@@ -9,14 +9,13 @@ import (
"syscall"
"time"
_ "net/http/pprof"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/analytics"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/icons"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/transcoding"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
"github.com/gtsteffaniak/filebrowser/backend/internal/web"
@@ -52,6 +51,12 @@ func initializeDatabase(configFile string, cliMode bool) bool {
if err != nil {
logger.Fatalf("could not initialize state: %v", err)
}
// state.Open applies persisted media settings, so initialize ffmpeg once here
// (before wiring, which reads Env.FFmpegAvailable) instead of during config
// load, which would run the multi-second hardware codec probe twice.
if !cliMode {
settings.InitFFmpeg()
}
runtimeApp, err = app.WireServices(store)
if err != nil {
logger.Fatalf("could not wire services: %v", err)
@@ -87,14 +92,7 @@ func StartFilebrowser() {
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
// Dev mode enables development features like template hot-reloading
_, err := os.Stat("internal/web/dist")
// In dev mode, always use filesystem assets. Otherwise, check if internal/web/dist exists
if !settings.Env.IsDevMode {
settings.Env.EmbeddedFs = os.IsNotExist(err)
}
err = utils.SetInvalidPasswordHash()
err := utils.SetInvalidPasswordHash()
if err != nil {
logger.Fatalf("Failed to set security hash: %v", err)
}
@@ -180,6 +178,13 @@ func StartFilebrowser() {
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
<-shutdownComplete
if transcodeMgr := transcoding.Default(); transcodeMgr != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := transcodeMgr.Close(shutdownCtx); err != nil {
logger.Warningf("transcode shutdown: %v", err)
}
shutdownCancel()
}
if err := fileutils.ClearDirectoryContents(settings.DownloadCacheDir()); err != nil {
logger.Warningf("failed to clear download spool on shutdown: %v", err)
}
@@ -194,14 +199,16 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
numWorkers := settings.Config.Server.NumImageProcessors
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
if !settings.Env.IsDevMode && web.EmbeddedFrontendAvailable() {
embeddedAssets := web.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
subAssets, err := fs.Sub(embeddedAssets, "dist")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
settings.Env.EmbeddedFs = true
fileutils.InitAssetFS(subAssets, true, false)
} else {
settings.Env.EmbeddedFs = false
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
}
+4
View File
@@ -62,3 +62,7 @@ userDefaults:
#office:
# url: "http://localhost:9052"
# secret: "6H1H7GzUk2alRkNxAPav0W02g8JS5oUd"
integrations:
media:
skipHwCheck: true
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
{
"generatedAt": "2026-10-27T14:46:52Z",
"gitHead": "880900c18dc7655899398cfb7327ab4e191fc3f0",
"profileSha256": "371d012d4ed5bc3907667f6d35876c998af98a6cc3c71f6ea4ddf57ab4ed4bbd",
"workloads": [
"internal/thumb BenchmarkMJPEGSmall/Large",
"internal/thumb BenchmarkMP4H264Large",
"internal/thumb BenchmarkMP4H264Small (full pipeline)",
"internal/thumb BenchmarkMP4H264Baseline1080Small (CAVLC + thumb)",
"internal/thumb BenchmarkMP4H264Main1080Large",
"internal/thumb BenchmarkMP4H264High1080Large",
"internal/thumb BenchmarkThumbnailLargeWebM",
"internal/h264 BenchmarkBaselineThumbnailDecode (CAVLC IDR)"
],
"hotPathGlobs": [
"internal/thumb/**/*.go",
"internal/h264/**/*.go",
"internal/kernels/**/*.go",
"internal/isobmff/**/*.go",
"internal/matroska/**/*.go",
"internal/jpegfast/**/*.go"
]
}
+28 -28
View File
@@ -1,6 +1,6 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.27.0
go 1.27.1
require (
github.com/alecthomas/kong v1.16.1
@@ -10,8 +10,7 @@ require (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/evanoberholster/imagemeta v1.1.0
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-playground/validator/v10 v10.30.5
github.com/go-webauthn/webauthn v0.18.2
github.com/goccy/go-yaml v1.19.2
@@ -19,42 +18,48 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
github.com/gtsteffaniak/go-ffmpeg v1.6.2
github.com/gtsteffaniak/go-fitz v1.0.1
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.52
github.com/gtsteffaniak/go-media v1.1.1-0.20261101153307-79181a6c2ed9
github.com/kovidgoyal/imaging v1.9.0
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0
golang.org/x/net v0.59.0
golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0
golang.org/x/mod v0.42.0
golang.org/x/net v0.61.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
golang.org/x/text v0.43.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.50.0
golang.org/x/tools v0.52.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.60.1
)
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/Eyevinn/mp4ff v0.61.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/abema/go-mp4 v1.7.4 // indirect
github.com/at-wat/ebml-go v0.19.4 // indirect
github.com/boombuler/barcode v1.1.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/dustin/go-humanize v1.1.0 // indirect
github.com/ebitengine/purego v0.11.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ebitengine/purego v0.11.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
github.com/gen2brain/h265 v0.2.4 // indirect
github.com/gen2brain/vpx v0.2.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
@@ -74,33 +79,28 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/gtsteffaniak/go-av1 v0.0.0-20261101031208-03767868b89d // indirect
github.com/gtsteffaniak/go-vpx v1.0.1 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/philhofer/fwd v1.2.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tinylib/msgp v1.6.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sync v0.23.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gtsteffaniak/go-ffmpeg v1.1.0
)
+75 -54
View File
@@ -2,10 +2,14 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Eyevinn/mp4ff v0.61.0 h1:uX3GBauEouxuGepRbv15JB7X2RyVyE2EZEfLpgimL6o=
github.com/Eyevinn/mp4ff v0.61.0/go.mod h1:97zsDIJUiDTYqKpqq/b1K9foamxXmvsgE4GdPrPK3ks=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/abema/go-mp4 v1.7.4 h1:MztDjMx3nac+Lktig9wrUa962Mnyi4+ZtDtpbHuckww=
github.com/abema/go-mp4 v1.7.4/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E=
@@ -16,6 +20,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/at-wat/ebml-go v0.19.4 h1:6F767ThzH/JqNUsF79PwRFP0pwUF5ALXWJ6pXuiolI8=
github.com/at-wat/ebml-go v0.19.4/go.mod h1:wu3fQj+DWo6SOOl9c4yDi+gFs9Oa96Z2B5qxo1OxFYY=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -23,6 +29,7 @@ github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBq
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -47,16 +54,18 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/evanoberholster/imagemeta v1.1.0 h1:0iBtgVxLORZTZAlKxRcNodBmjMfHCSFwUh8Asmi83oI=
github.com/evanoberholster/imagemeta v1.1.0/go.mod h1:nmf6le+qSyoRAXgp2psPbtfkxcEgf/AZGgai9pnN8d0=
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
github.com/gen2brain/h265 v0.2.4 h1:JUSbGkRAYZpd7ucCL192yx4ViEo75ZO6OEdm1TQt9qM=
github.com/gen2brain/h265 v0.2.4/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw=
github.com/gen2brain/vpx v0.2.1 h1:UD6tibcs/zlaIOKNFXeVyZuHBEaW9GYfxKSxvAN9cc8=
github.com/gen2brain/vpx v0.2.1/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -65,10 +74,10 @@ github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
@@ -104,6 +113,8 @@ github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dm
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
@@ -139,14 +150,23 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLz
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gtsteffaniak/go-av1 v0.0.0-20261101031208-03767868b89d h1:2Z15ATqB4V8B6kIM08DAxSOD8N7y3GPhU9woKzM/a58=
github.com/gtsteffaniak/go-av1 v0.0.0-20261101031208-03767868b89d/go.mod h1:C/dIrjfdUcHgdyChr2YXwLY3pIVjENI9npSd98PU4lE=
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-ffmpeg v1.2.0 h1:WSFfRL5xVUpEXBNI4D3f2Uz8yxbc3OEqTtjp4Z9yPUo=
github.com/gtsteffaniak/go-ffmpeg v1.2.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-ffmpeg v1.6.2 h1:gtvBg3G4PHO1Z/ZIPEK/XP4XT/g+l9S0Rpmmq6v5tnA=
github.com/gtsteffaniak/go-ffmpeg v1.6.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-fitz v1.0.1 h1:SKNVc0ES73Y99NQ1TtOwa2Uf8xpfQpFtMnTTwfVusBk=
github.com/gtsteffaniak/go-fitz v1.0.1/go.mod h1:L3RzZGaMEY+KAzY1KR9hraHqcgG5VmoK9iw8SGjkiaI=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-media v1.1.1-0.20261101153307-79181a6c2ed9 h1:q9HnKLhwuETihYSZnZrljULviE27cK2k+7xnSrPMK3w=
github.com/gtsteffaniak/go-media v1.1.1-0.20261101153307-79181a6c2ed9/go.mod h1:WMn3EjlWNcuaoc47+7YyYHXc7C5sVTYBt2zaaPf3KwU=
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=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -171,13 +191,14 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.23 h1:4WsboyQ/E8tic2kX49P93Rvscg33SPsCwTBG5EZZF54=
github.com/kovidgoyal/imaging v1.8.23/go.mod h1:k3Iot3H0v2rSWXIxQISfT3e9wIryqzAHLHCuhG7z9lM=
github.com/kovidgoyal/imaging v1.9.0 h1:TxHvhClPANvDW7eLm7PYDTZU1KE15xbqlFv95DA6DmY=
github.com/kovidgoyal/imaging v1.9.0/go.mod h1:t2vWMc3uISm/jMP87nBCcZUcAK6EDUdgumwaXSuz0Jc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
@@ -185,12 +206,12 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -209,12 +230,10 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
@@ -233,13 +252,13 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -250,16 +269,16 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -269,29 +288,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
@@ -302,29 +318,34 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+20 -12
View File
@@ -141,6 +141,14 @@ func TestUserUpdateChangesExpandsNestedStructFields(t *testing.T) {
}
}
// Mock source paths used only as lookup keys in tests. They never touch the
// filesystem. They are relative (and not equal to the source names) so the
// tests are portable across machines.
const (
testDownloadsPath = "./testdata/Downloads"
testAccessPath = "./testdata/playwright-files"
)
type activityChangePair struct {
from string
to string
@@ -150,9 +158,9 @@ func testDownloadsSourceConfig() *users.SourceConfigProvider {
return &users.SourceConfigProvider{
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
switch path {
case "/Users/steffag/Downloads":
case testDownloadsPath:
return users.SourceInfo{Path: path, Name: "Downloads"}, true
case "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files":
case testAccessPath:
return users.SourceInfo{Path: path, Name: "access"}, true
default:
return users.SourceInfo{}, false
@@ -229,8 +237,8 @@ func TestUserUpdateChangesExpandsPreviewBulkToggle(t *testing.T) {
func TestUserUpdateChangesExpandsBackendSourcePermissions(t *testing.T) {
withTestSourceConfig(t, testDownloadsSourceConfig())
downloadsPath := "/Users/steffag/Downloads"
accessPath := "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files"
downloadsPath := testDownloadsPath
accessPath := testAccessPath
beforePerms := users.MarkSourceFilePermissionsConfigured(users.SourceFilePermissions{
View: true, Download: true,
})
@@ -274,8 +282,8 @@ func TestUserUpdateChangesExpandsBackendSourcePermissions(t *testing.T) {
func TestUserUpdateChangesExpandsScopePermissions(t *testing.T) {
withTestSourceConfig(t, testDownloadsSourceConfig())
downloadsPath := "/Users/steffag/Downloads"
accessPath := "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files"
downloadsPath := testDownloadsPath
accessPath := testAccessPath
basePerms := users.MarkSourceFilePermissionsConfigured(users.SourceFilePermissions{
View: true, Download: true,
})
@@ -353,9 +361,9 @@ func TestSidebarLinksFieldChangeIgnoresPathVsName(t *testing.T) {
withTestSourceConfig(t, &users.SourceConfigProvider{
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
switch path {
case "/Users/steffag/Downloads":
case testDownloadsPath:
return users.SourceInfo{Path: path, Name: "Downloads"}, true
case "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files":
case testAccessPath:
return users.SourceInfo{Path: path, Name: "access"}, true
default:
return users.SourceInfo{}, false
@@ -364,9 +372,9 @@ func TestSidebarLinksFieldChangeIgnoresPathVsName(t *testing.T) {
GetSourceByName: func(name string) (users.SourceInfo, bool) {
switch name {
case "Downloads":
return users.SourceInfo{Path: "/Users/steffag/Downloads", Name: "Downloads"}, true
return users.SourceInfo{Path: testDownloadsPath, Name: "Downloads"}, true
case "access":
return users.SourceInfo{Path: "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files", Name: "access"}, true
return users.SourceInfo{Path: testAccessPath, Name: "access"}, true
default:
return users.SourceInfo{}, false
}
@@ -380,8 +388,8 @@ func TestSidebarLinksFieldChangeIgnoresPathVsName(t *testing.T) {
ShowToolsInSidebar: true,
SidebarLinks: []users.SidebarLink{
toolsLink,
{Name: "Downloads", Category: "source", Target: "/", SourceName: "/Users/steffag/Downloads"},
{Name: "access", Category: "source", Target: "/", SourceName: "/Users/steffag/git/personal/filebrowser/frontend/tests/playwright-files"},
{Name: "Downloads", Category: "source", Target: "/", SourceName: testDownloadsPath},
{Name: "access", Category: "source", Target: "/", SourceName: testAccessPath},
},
},
},
+27 -3
View File
@@ -163,8 +163,18 @@ func RecordPatchItem(r *http.Request, actor *Actor, action string, item MoveCopy
RecordUser(r, actor, entry)
}
// recordFileEvent records a file operation. Anonymous share visitors have no user ID,
// so their uploads and deletes are attributed to the share owner.
func recordFileEvent(r *http.Request, actor *Actor, entry activitydb.Entry) {
if actor != nil && actor.Share.Hash != "" && (actor.User == nil || actor.User.ID == 0) {
RecordShareOwner(r, actor, entry)
return
}
RecordUser(r, actor, entry)
}
func RecordDelete(r *http.Request, actor *Actor, source, path string) {
RecordUser(r, actor, activitydb.Entry{
recordFileEvent(r, actor, activitydb.Entry{
EventType: activitydb.EventDelete,
Source: source,
Path: path,
@@ -198,7 +208,7 @@ func RecordBulkDelete(r *http.Request, actor *Actor, succeeded []BulkDeleteItem)
entry.Details.Source = succeeded[0].Source
entry.Details.Path = succeeded[0].Path
}
RecordUser(r, actor, entry)
recordFileEvent(r, actor, entry)
}
func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool) {
@@ -210,7 +220,7 @@ func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool
Source: source,
Path: pathLabel,
}
RecordUser(r, actor, activitydb.Entry{
recordFileEvent(r, actor, activitydb.Entry{
EventType: activitydb.EventUpload,
Source: source,
Path: pathLabel,
@@ -218,6 +228,20 @@ func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool
})
}
// RecordFileModify appends an audit row for an in-place file content update (PUT).
func RecordFileModify(r *http.Request, actor *Actor, source, path string) {
entry := activitydb.Entry{
EventType: activitydb.EventFileModify,
Source: source,
Path: path,
Details: activitydb.Details{
Source: source,
Path: path,
},
}
recordFileEvent(r, actor, entry)
}
func RecordShareMutation(r *http.Request, actor *Actor, eventType activitydb.EventType, hash, sourceName, path string, changes []activitydb.FieldChange) {
details := activitydb.Details{
Source: sourceName,
+84
View File
@@ -1,9 +1,13 @@
package activity
import (
"net/http/httptest"
"testing"
"time"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestAccessRuleCreateChanges(t *testing.T) {
@@ -28,6 +32,86 @@ func TestAccessRuleDeleteChanges(t *testing.T) {
assertFieldChange(t, changes[4], "count", "", "3")
}
func TestShareFileEventsRecordedForShareOwner(t *testing.T) {
store := &mockActivityStore{}
globalMu.Lock()
old := globalRecorder
globalMu.Unlock()
defer func() {
globalMu.Lock()
globalRecorder = old
globalMu.Unlock()
}()
rec := &Recorder{
store: store,
buffer: make([]activitydb.Entry, 0, 4),
flushCh: make(chan struct{}, 1),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
maxBuffer: 10000,
flushInterval: time.Hour,
enabled: true,
}
globalMu.Lock()
globalRecorder = rec
globalMu.Unlock()
go rec.loop()
actor := &Actor{
User: &users.User{FrontendUser: users.FrontendUser{Username: "anonymous"}},
Share: share.Share{Hash: "share-hash", UserID: 7},
}
r := httptest.NewRequest("POST", "/public/api/resources", nil)
RecordUpload(r, actor, "main", "/pub/new.txt", false)
RecordDelete(r, actor, "main", "/pub/old.txt")
RecordBulkDelete(r, actor, []BulkDeleteItem{{Source: "main", Path: "/pub/a.txt"}})
loggedIn := &Actor{
User: &users.User{ID: 5, FrontendUser: users.FrontendUser{Username: "bob"}},
Share: share.Share{Hash: "share-hash", UserID: 7},
}
RecordUpload(r, loggedIn, "main", "/pub/bob.txt", false)
RecordFileModify(r, loggedIn, "main", "/pub/edited.txt")
RecordFileModify(r, actor, "main", "/pub/anon-edit.txt")
rec.Stop()
var got []activitydb.Entry
for _, batch := range store.inserts {
got = append(got, batch...)
}
want := []activitydb.EventType{
activitydb.EventUpload,
activitydb.EventDelete,
activitydb.EventBulkDelete,
activitydb.EventUpload,
activitydb.EventFileModify,
activitydb.EventFileModify,
}
if len(got) != len(want) {
t.Fatalf("expected %d entries, got %d: %+v", len(want), len(got), got)
}
for i, e := range got {
if e.EventType != want[i] {
t.Fatalf("entry %d: event type %q, want %q", i, e.EventType, want[i])
}
}
for i := 0; i < 3; i++ {
e := got[i]
if e.UserID != 7 || e.Details.ShareHash != "share-hash" {
t.Fatalf("anonymous share entry %d: userID=%d shareHash=%q", i, e.UserID, e.Details.ShareHash)
}
}
if got[3].UserID != 5 {
t.Fatalf("logged-in share visitor upload: userID=%d, want 5", got[3].UserID)
}
if got[4].UserID != 5 {
t.Fatalf("logged-in share visitor modify: userID=%d, want 5", got[4].UserID)
}
if got[5].UserID != 7 || got[5].Details.ShareHash != "share-hash" {
t.Fatalf("anonymous share modify: userID=%d shareHash=%q", got[5].UserID, got[5].Details.ShareHash)
}
}
func assertFieldChange(t *testing.T, change activitydb.FieldChange, field, from, to string) {
t.Helper()
if change.Field != field || change.From != from || change.To != to {
+9 -5
View File
@@ -3,7 +3,6 @@ package activity
import (
"net"
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
@@ -11,14 +10,19 @@ import (
func remoteIP(r *http.Request) string {
cfg := &settings.Config
// Forwarded client headers are only honored when the socket peer is a
// configured trusted proxy; otherwise a direct client could spoof its IP.
trustedPeer := cfg.Http.TrustProxyHeaders && settings.IsTrustedProxyPeer(r)
xff := r.Header.Get("X-Forwarded-For")
if cfg.Http.TrustProxyHeaders && xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
if trustedPeer && xff != "" {
if ip := settings.TrustedProxyClientIP(xff); ip != "" {
return ip
}
}
xri := r.Header.Get("X-Real-IP")
if cfg.Http.TrustProxyHeaders && xri != "" {
if trustedPeer && xri != "" {
return xri
}
+126 -9
View File
@@ -12,11 +12,11 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func Test_GetRealPath(t *testing.T) {
@@ -129,7 +129,7 @@ func TestWriteFilePreservesExecutableBit(t *testing.T) {
waitForScannerReady(t, idx)
indexPath := "/" + scriptName
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
if err := WriteFile(sourceName, "", indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -182,7 +182,7 @@ func TestWriteFileRejectsExistingDirectory(t *testing.T) {
defer stopPoll()
waitForScannerReady(t, idx)
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
err := WriteFile(sourceName, "", "/"+dirName, strings.NewReader("file content"))
if err == nil {
t.Fatal("expected error when path is an existing directory")
}
@@ -571,7 +571,7 @@ func TestDeleteFilesRootProtection(t *testing.T) {
}
// Test: Attempting to delete the root directory should return an error
err := DeleteFiles("test_root_protection", realTmpDir, true)
err := DeleteFiles("test_root_protection", "", "/", true)
if err == nil {
t.Error("DeleteFiles should return an error when trying to delete root directory")
}
@@ -583,13 +583,13 @@ func TestDeleteFilesRootProtection(t *testing.T) {
}
// Test with trailing slash - should still be blocked
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
err = DeleteFiles("test_root_protection", "", "/", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with trailing slash")
}
// Test with cleaned path variations
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
err = DeleteFiles("test_root_protection", "", "/", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with /. suffix")
}
@@ -670,7 +670,7 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
waitForScannerReady(t, idx)
err := DeleteFiles(sourceName, subfolderPath, true)
err := DeleteFiles(sourceName, "", "/"+subfolderName, true)
stopPolling()
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
@@ -694,13 +694,130 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
t.Logf("iteration %d: cleaned scanners and indices", iter)
}
// TestDeleteFiles_RemovesSymlinkNotTarget verifies that deleting a symlink leaf
// removes the link itself and never follows it to the target.
func TestDeleteFiles_RemovesSymlinkNotTarget(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(filepath.Dir(root), "outside-victim.txt")
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
sourceName := "test_delete_symlink"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
if err := DeleteFiles(sourceName, "", "/link", false); err != nil {
t.Fatalf("DeleteFiles: %v", err)
}
if _, err := os.Lstat(filepath.Join(root, "link")); !os.IsNotExist(err) {
t.Fatal("symlink should have been removed")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != "keep me" {
t.Fatalf("delete followed the symlink and removed the target: %v %q", err, got)
}
}
// TestDeleteFiles_RejectsEscapingIntermediate verifies a delete under an
// intermediate symlink escaping the source is refused.
func TestDeleteFiles_RejectsEscapingIntermediate(t *testing.T) {
root := t.TempDir()
outsideDir := filepath.Join(filepath.Dir(root), "outside-dir")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(root, "evil")); err != nil {
t.Fatal(err)
}
victim := filepath.Join(outsideDir, "victim.txt")
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
sourceName := "test_delete_escape"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
err := DeleteFiles(sourceName, "", "/evil/victim.txt", false)
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
if _, err := os.Stat(victim); err != nil {
t.Fatal("delete escaped the bound and removed the outside file")
}
}
// TestWriteFile_RejectsSymlinkEscape verifies a write through a symlink leaf
// resolving outside the source is refused before any bytes are written.
func TestWriteFile_RejectsSymlinkEscape(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(filepath.Dir(root), "outside.txt")
if err := os.WriteFile(outside, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "evil")); err != nil {
t.Fatal(err)
}
sourceName := "test_write_symlink"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
err := WriteFile(sourceName, "", "/evil", strings.NewReader("pwned"))
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
got, _ := os.ReadFile(outside)
if string(got) != "original" {
t.Fatal("write went through the symlink")
}
}
// TestWriteFile_RejectsMissingLeafUnderEscapingParent verifies the j764/3j46
// primitive: writing a new file under a symlinked directory that escapes the
// source is refused even though the leaf does not exist yet.
func TestWriteFile_RejectsMissingLeafUnderEscapingParent(t *testing.T) {
root := t.TempDir()
outsideDir := filepath.Join(filepath.Dir(root), "outside-parent")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(root, "evil_dir")); err != nil {
t.Fatal(err)
}
sourceName := "test_write_escape_parent"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
err := WriteFile(sourceName, "", "/evil_dir/hack.txt", strings.NewReader("pwned"))
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
if _, err := os.Lstat(filepath.Join(outsideDir, "hack.txt")); !os.IsNotExist(err) {
t.Fatal("write landed outside the source through the symlinked parent")
}
}
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
t.Helper()
// Give the scanners a moment to initialize, but don't wait for full "ready" status
// The initial scan can take longer than the test timeout, but DeleteFiles will work
// as long as the scanners are initialized and running
time.Sleep(500 * time.Millisecond)
// Verify scanners exist
status := idx.GetScannerStatus()
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
+227 -78
View File
@@ -16,11 +16,13 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/go-media/pkg/media"
)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
@@ -71,13 +73,18 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
var wg sync.WaitGroup
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true).String()
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
// Expanded listings already persisted index rows and the folder size from
// the freshly read directory (persistDirectoryListing); only unexpanded
// directory requests still need a refresh walk.
if !opts.Expand {
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true).String()
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
}
if opts.Metadata && metadataCount > 0 {
addMetadataToChildren(response, opts, &wg)
@@ -88,7 +95,7 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping).
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
// Add OnlyOffice ID if applicable
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
if settings.Config.Integrations.OnlyOffice.Secret != "" && !settings.Config.Integrations.OnlyOffice.IsCollabora() && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
@@ -330,6 +337,15 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
if info.Type == "directory" && opts.HideFileExt != "" {
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
}
if info.Type == "directory" && opts.Expand {
// Expanded listings without extended attrs skip persistDirectoryListing
// (their placeholder preview values must not overwrite index rows), so
// refresh the index lazily — otherwise folder sizes here never update.
// Capture indexPath now: the deferred finalizeResponse strips the user
// scope from response.Path before returning.
// Coalesced per path so repeated listings do not run parallel refreshes.
idx.RefreshDirectoryAsync(idx.MakeIndexPath(response.Path, true).String())
}
return response, nil
}
// Process directory metadata if requested
@@ -366,7 +382,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
logger.Debugf("failed to extract video metadata for file: %s (%s): %v", info.RealPath, info.Name, err)
} else {
info.Metadata = extItem.Metadata
}
@@ -376,10 +392,13 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
if exists {
info.GetSubtitles(parentInfo)
}
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
subtitles, ok := gomedia.EmbeddedSubtitles(info.RealPath, info.ModTime, func() ([]utils.SubtitleTrack, bool) {
return ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime), true
})
if !ok {
subtitles = ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
}
info.Subtitles = append(info.Subtitles, subtitles...)
return
}
@@ -449,6 +468,29 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return fmt.Errorf("file with size %d MB exceeds metadata check limit: %d MB", fileInfo.Size()/1024/1024, maxSize)
}
// go-media fast path: tags, lyrics availability, and artwork. Unsupported or
// failed reads fall through to the existing dhowden/tag code below.
if info, ok := gomedia.Audio(realPath, getArt, func() (gomedia.AudioInfo, bool) { return legacyAudioInfo(realPath, getArt) }); ok {
item.Metadata = &iteminfo.MediaMetadata{
Title: info.Tags.Title,
Artist: info.Tags.Artist,
Album: info.Tags.Album,
Year: info.Tags.Year,
Genre: info.Tags.Genre,
Track: info.Tags.Track,
HasLyrics: info.HasLyrics,
}
if getDuration {
audioDuration(ctx, item, realPath, ffmpegService)
}
if n := len(info.Artwork); n > 0 && n <= 5*1024*1024 {
item.Metadata.AlbumArt = info.Artwork
} else if n > 5*1024*1024 {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
}
return nil
}
m, err := tag.ReadFrom(file)
if err != nil {
return err
@@ -472,15 +514,7 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
if getDuration {
service := ffmpegService
if service == nil {
service = ffmpeg.Get()
}
if service != nil {
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
item.Metadata.Duration = int(duration)
}
}
audioDuration(ctx, item, realPath, ffmpegService)
}
if !getArt {
@@ -500,6 +534,30 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// audioDuration sets the duration on item.Metadata. go-media reads it from the
// container header first, then ffmpeg is used.
func audioDuration(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) {
service := ffmpegService
if service == nil {
service = ffmpeg.Get()
}
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
shadowCtx := context.WithoutCancel(ctx)
legacy := func() (float64, bool) {
d, err := service.GetMediaDuration(shadowCtx, realPath)
return d, err == nil
}
if duration, ok := gomedia.Duration(realPath, legacy); ok {
item.Metadata.Duration = int(duration)
return
}
if service != nil {
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
item.Metadata.Duration = int(duration)
}
}
}
var lyricsSidecarExts = []string{".elrc", ".lrc", ".srt", ".vtt"}
func lyricsSidecar(dir, nameWithoutExt, ext string) (path string, ok bool) {
@@ -569,6 +627,11 @@ func hasLyricsSidecar(realPath string) bool {
// Returns raw lyrics from an audio file (from embedded tags or from sidecar files with the same name).
func ExtractLyrics(realPath string) (string, string, error) {
// go-media fast path: sidecar first, then embedded tags. Falls through to the
// existing code below when unsupported or failed.
if text, format, ok := gomedia.Lyrics(realPath, func() (gomedia.LyricsResult, bool) { return legacyLyrics(realPath) }); ok {
return text, format, nil
}
if data, ext, ok := findLyricsSidecar(realPath); ok {
return string(data), ext, nil
}
@@ -587,27 +650,100 @@ func ExtractLyrics(realPath string) (string, string, error) {
return "", "", nil
}
// legacyAudioInfo is the dhowden tag read used as the shadow-mode comparison
// for gomedia.Audio. It returns the same fields go-media returns, so the two
// can be compared. Cover bytes are read only when withArt is true.
func legacyAudioInfo(realPath string, withArt bool) (gomedia.AudioInfo, bool) {
f, err := os.Open(realPath)
if err != nil {
return gomedia.AudioInfo{}, false
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
return gomedia.AudioInfo{}, false
}
track, _ := m.Track()
info := gomedia.AudioInfo{
Tags: media.Tags{
Title: m.Title(),
Artist: m.Artist(),
Album: m.Album(),
Year: m.Year(),
Genre: m.Genre(),
Track: track,
},
HasLyrics: strings.TrimSpace(m.Lyrics()) != "" || hasLyricsSidecar(realPath),
}
if withArt {
if pic := m.Picture(); pic != nil {
info.Artwork = pic.Data
}
}
return info, true
}
// legacyLyrics is the sidecar and dhowden lyrics read used as the shadow-mode
// comparison for gomedia.Lyrics. Sidecar first, then embedded, as in go-media.
func legacyLyrics(realPath string) (gomedia.LyricsResult, bool) {
if data, ext, ok := findLyricsSidecar(realPath); ok {
return gomedia.LyricsResult{Text: strings.TrimSpace(string(data)), Format: ext}, true
}
f, err := os.Open(realPath)
if err != nil {
return gomedia.LyricsResult{}, false
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
return gomedia.LyricsResult{}, false
}
text := strings.TrimSpace(m.Lyrics())
if text == "" {
return gomedia.LyricsResult{}, false
}
return gomedia.LyricsResult{Text: text, Format: "embedded"}, true
}
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
service := ffmpegService
if service == nil {
service = ffmpeg.Get()
// go-media fast path. Works without ffmpeg installed.
probeService := ffmpegService
if probeService == nil {
probeService = ffmpeg.Get()
}
if service == nil {
return nil
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
shadowCtx := context.WithoutCancel(ctx)
legacyProbe := func() (gomedia.ProbeResult, bool) {
info, err := probeService.ProbeFile(shadowCtx, realPath)
if err != nil || info == nil {
return gomedia.ProbeResult{}, false
}
return *info, true
}
info, ok := gomedia.Probe(realPath, legacyProbe)
if !ok {
service := ffmpegService
if service == nil {
service = ffmpeg.Get()
}
if service == nil {
return nil
}
info, err := service.ProbeFile(ctx, realPath)
if err != nil {
duration, durErr := service.GetMediaDuration(ctx, realPath)
if durErr != nil {
return err
probed, err := service.ProbeFile(ctx, realPath)
if err != nil {
duration, durErr := service.GetMediaDuration(ctx, realPath)
if durErr != nil {
return err
}
if duration > 0 {
item.Metadata = &iteminfo.MediaMetadata{Duration: int(duration)}
}
return nil
}
if duration > 0 {
item.Metadata = &iteminfo.MediaMetadata{Duration: int(duration)}
}
return nil
info = *probed
}
meta := &iteminfo.MediaMetadata{}
@@ -629,47 +765,53 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
func DeleteFiles(source, absPath string, isDir bool) error {
// DeleteFiles deletes the file or directory at indexPath. boundIndexPath limits
// symlink resolution to the caller's scope ("" or "/" = source root only). The
// entry itself is removed: a symlink leaf is deleted, never followed, and an
// intermediate symlinked component cannot carry the delete outside the bound.
func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
// Safety guard: never allow deletion of the source root directory itself
cleanAbs := filepath.Clean(absPath)
if cleanAbs == filepath.Clean(index.Path) {
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
if utils.CleanIndexPathSegment(indexPath) == "" {
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
}
if !index.Config.ResolvedRules.IndexingDisabled {
indexPath := index.MakeIndexPath(absPath, isDir).String()
// Perform the physical deletion
err := os.RemoveAll(absPath)
if err != nil {
return err
}
// Clear cache entries
indexing.RealPathCache.Delete(absPath)
indexing.IsDirCache.Delete(absPath + ":isdir")
// Remove metadata from index
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
if !deleteSuccess {
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
}
// Refresh the parent directory to recalculate sizes and update counts
refreshConfig := utils.FileOptions{
Path: index.MakeIndexPath(filepath.Dir(absPath), true).String(),
IsDir: true,
}
err = index.RefreshFileInfo(refreshConfig)
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
if err != nil {
return err
}
// Indexing disabled, just delete the file
return os.RemoveAll(absPath)
if index.Config.ResolvedRules.IndexingDisabled {
// Indexing disabled, just delete the file
return os.RemoveAll(realPath)
}
indexPath = index.MakeIndexPath(indexPath, isDir).String()
// Perform the physical deletion
err = os.RemoveAll(realPath)
if err != nil {
return err
}
// Clear cache entries
indexing.RealPathCache.Delete(realPath)
indexing.IsDirCache.Delete(realPath + ":isdir")
// Remove metadata from index
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
if !deleteSuccess {
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
}
// Refresh the parent directory to recalculate sizes and update counts
refreshConfig := utils.FileOptions{
Path: index.MakeIndexPath(filepath.Dir(strings.TrimSuffix(indexPath, "/")), true).String(),
IsDir: true,
}
err = index.RefreshFileInfo(refreshConfig)
return err
}
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
@@ -896,15 +1038,19 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
return nil
}
func WriteDirectory(opts utils.FileOptions) error {
// WriteDirectory creates a directory at opts.Path. boundIndexPath limits symlink
// resolution to the caller's scope ("" or "/" = source root only).
func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
realPath, err := idx.GetRealPathForMutation(boundIndexPath, opts.Path)
if err != nil {
return err
}
var stat os.FileInfo
var err error
// Check if the destination exists and is a file
if stat, err = os.Stat(realPath); err == nil && !stat.IsDir() {
// If it's a file and we're trying to create a directory, remove the file first
@@ -948,21 +1094,24 @@ func WriteDirectory(opts utils.FileOptions) error {
// WriteFileFunc is a variable that can be mocked in tests
var WriteFileFunc = writeFile
func WriteFile(source, path string, in io.Reader) error {
return WriteFileFunc(source, path, in)
// WriteFile writes stream contents to the file at index path path. boundIndexPath
// limits symlink resolution to the caller's scope ("" or "/" = source root only).
func WriteFile(source, boundIndexPath, path string, in io.Reader) error {
return WriteFileFunc(source, boundIndexPath, path, in)
}
func writeFile(source, path string, in io.Reader) error {
func writeFile(source, boundIndexPath, path string, in io.Reader) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
realPath, _, _ := idx.GetRealPath(path)
// Strip trailing slash from realPath if it's meant to be a file
realPath = strings.TrimRight(realPath, "/")
realPath, err := idx.GetRealPathForMutation(boundIndexPath, path)
if err != nil {
return err
}
// Ensure the parent directories exist
parentDir := filepath.Dir(realPath)
err := os.MkdirAll(parentDir, fileutils.PermDir)
err = os.MkdirAll(parentDir, fileutils.PermDir)
if err != nil {
return err
}
+52 -49
View File
@@ -55,6 +55,8 @@ const (
var types = map[string]string{
".3dm": "x-world/x-3dmf",
".3dmf": "x-world/x-3dmf",
".3g2": "video/3gpp2",
".3gp": "video/3gpp",
".7z": "application/x-7z-compressed",
".a": "application/octet-stream",
".aab": "application/x-authorware-bin",
@@ -525,6 +527,7 @@ var types = map[string]string{
".wmlc": "application/vnd.wap.wmlc",
".wmls": "text/vnd.wap.wmlscript",
".wmlsc": "application/vnd.wap.wmlscriptc",
".wmv": "video/x-ms-wmv",
".word": "application/msword",
".wp5": "application/wordperfect",
".wp6": "application/wordperfect",
@@ -650,55 +653,55 @@ var types = map[string]string{
".tmp": "text/tmp",
".temp": "text/tmp",
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
".adp": "audio/adpcm",
".mp4a": "audio/mp4",
".mp2a": "audio/mpeg",
".m3a": "audio/mpeg",
".sil": "audio/silk",
".uva": "audio/vnd.dece.audio",
".uvva": "audio/vnd.dece.audio",
".eol": "audio/vnd.digital-winds",
".dra": "audio/vnd.dra",
".dts": "audio/vnd.dts",
".dtshd": "audio/vnd.dts.hd",
".lvp": "audio/vnd.lucent.voice",
".pya": "audio/vnd.ms-playready.media.pya",
".rip": "audio/vnd.rip",
".mka": "audio/x-matroska",
".h261": "video/h261",
".h263": "video/h263",
".h264": "video/h264",
".jpgv": "video/jpeg",
".jpm": "video/jpm",
".jpgm": "video/jpm",
".mj2": "video/mj2",
".mjp2": "video/mj2",
".m2t": "video/mp2t",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
".mp4v": "video/mp4",
".mpg4": "video/mp4",
".uvh": "video/vnd.dece.hd",
".uvvh": "video/vnd.dece.hd",
".uvm": "video/vnd.dece.mobile",
".uvvm": "video/vnd.dece.mobile",
".uvp": "video/vnd.dece.pd",
".uvvp": "video/vnd.dece.pd",
".uvs": "video/vnd.dece.sd",
".uvvs": "video/vnd.dece.sd",
".uvv": "video/vnd.dece.video",
".uvvv": "video/vnd.dece.video",
".dvb": "video/vnd.dvb.file",
".fvt": "video/vnd.fvt",
".mxu": "video/vnd.mpegurl",
".pyv": "video/vnd.ms-playready.media.pyv",
".uvu": "video/vnd.uvvu.mp4",
".uvvu": "video/vnd.uvvu.mp4",
".f4v": "video/x-f4v",
".mk3d": "video/x-matroska",
".mks": "video/x-matroska",
".vob": "video/x-ms-vob",
".smv": "video/x-smv",
".adp": "audio/adpcm",
".mp4a": "audio/mp4",
".mp2a": "audio/mpeg",
".m3a": "audio/mpeg",
".sil": "audio/silk",
".uva": "audio/vnd.dece.audio",
".uvva": "audio/vnd.dece.audio",
".eol": "audio/vnd.digital-winds",
".dra": "audio/vnd.dra",
".dts": "audio/vnd.dts",
".dtshd": "audio/vnd.dts.hd",
".lvp": "audio/vnd.lucent.voice",
".pya": "audio/vnd.ms-playready.media.pya",
".rip": "audio/vnd.rip",
".mka": "audio/x-matroska",
".h261": "video/h261",
".h263": "video/h263",
".h264": "video/h264",
".jpgv": "video/jpeg",
".jpm": "video/jpm",
".jpgm": "video/jpm",
".mj2": "video/mj2",
".mjp2": "video/mj2",
".m2t": "video/mp2t",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
".mp4v": "video/mp4",
".mpg4": "video/mp4",
".uvh": "video/vnd.dece.hd",
".uvvh": "video/vnd.dece.hd",
".uvm": "video/vnd.dece.mobile",
".uvvm": "video/vnd.dece.mobile",
".uvp": "video/vnd.dece.pd",
".uvvp": "video/vnd.dece.pd",
".uvs": "video/vnd.dece.sd",
".uvvs": "video/vnd.dece.sd",
".uvv": "video/vnd.dece.video",
".uvvv": "video/vnd.dece.video",
".dvb": "video/vnd.dvb.file",
".fvt": "video/vnd.fvt",
".mxu": "video/vnd.mpegurl",
".pyv": "video/vnd.ms-playready.media.pyv",
".uvu": "video/vnd.uvvu.mp4",
".uvvu": "video/vnd.uvvu.mp4",
".f4v": "video/x-f4v",
".mk3d": "video/x-matroska",
".mks": "video/x-matroska",
".vob": "video/x-ms-vob",
".smv": "video/x-smv",
}
//nolint:gochecknoinits
@@ -0,0 +1,25 @@
package files
import (
"mime"
"testing"
)
// Video containers that browsers cannot play must still be detected as video so
// the preview routes to the transcode UI instead of showing "no preview".
func TestVideoContainerExtensionsAreRegisteredAsVideo(t *testing.T) {
tests := map[string]string{
".wmv": "video/x-ms-wmv",
".3gp": "video/3gpp",
".3g2": "video/3gpp2",
".mp4": "video/mp4",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
}
for ext, want := range tests {
got := mime.TypeByExtension(ext)
if got != want {
t.Errorf("mime.TypeByExtension(%q) = %q, want %q", ext, got, want)
}
}
}
+4 -2
View File
@@ -5,8 +5,8 @@ import (
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -19,11 +19,13 @@ func MakeUserDir(fullPath string) error {
}
func MakeUserDirs(u *users.User, createDir bool) error {
if u.Permissions.Admin {
return nil
}
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
}
settings.ExpandBackendScopesForCreateUserDir(u)
for i, scope := range u.BackendScopes {
source, ok := settings.Config.Server.SourceMap[scope.Path]
if !ok {
@@ -0,0 +1,54 @@
package files
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMakeUserDirsSkipsAdmins(t *testing.T) {
root := t.TempDir()
saved := settings.Config
t.Cleanup(func() { settings.Config = saved })
settings.Config = settings.Settings{
Server: settings.Server{
Sources: []*settings.Source{{
Path: root,
Config: settings.SourceConfig{
CreateUserDir: true,
},
}},
SourceMap: map[string]*settings.Source{
root: {
Path: root,
Config: settings.SourceConfig{
CreateUserDir: true,
},
},
},
},
}
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
Permissions: users.Permissions{
Admin: true,
},
},
BackendScopes: []users.BackendScope{{Path: root, Scope: "/"}},
}
if err := MakeUserDirs(u, true); err != nil {
t.Fatalf("MakeUserDirs: %v", err)
}
if got := u.BackendScopes[0].Scope; got != "/" {
t.Fatalf("scope=%q want /", got)
}
if _, err := os.Stat(filepath.Join(root, "admin")); !os.IsNotExist(err) {
t.Fatalf("admin directory stat error=%v, want not-exist", err)
}
}
@@ -3,6 +3,8 @@ package fileutils
import (
"io/fs"
"net/http"
"github.com/gtsteffaniak/go-logger/logger"
)
var (
@@ -28,7 +30,17 @@ func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool, devMode bool) {
// Dev mode: serve frontend source assets (templates, fonts, icons)
assetFs = dirFS{Dir: http.Dir("../frontend/public")}
} else {
assetFs = dirFS{Dir: http.Dir("internal/web/dist")}
// Non-dev disk build (e.g. Docker): dist is shipped beside the binary. Preload it
// into memory when possible; otherwise serve from disk.
diskAssets := dirFS{Dir: http.Dir("internal/web/dist")}
cached, err := loadMemFS(diskAssets)
if err != nil {
logger.Warningf("could not preload frontend assets into memory, serving from disk: %v", err)
assetFs = diskAssets
} else {
logger.Debugf("preloaded %d frontend assets (%d bytes) into memory", cached.Len(), cached.Size())
assetFs = cached
}
}
}
@@ -55,6 +55,22 @@ func unixModeToFileMode(u uint32) os.FileMode {
return m
}
// removeDestinationSymlinkIfPresent deletes dest when it is a symlink so a copy or
// cross-device move replaces the link entry instead of writing through it.
func removeDestinationSymlinkIfPresent(dest string) error {
info, err := os.Lstat(dest)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return os.Remove(dest)
}
return nil
}
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst are on different volumes,
// CopyFile is used as a fallback and the source is removed synchronously on success.
@@ -134,6 +150,10 @@ func copySingleFile(source, dest string) error {
return err
}
if err = removeDestinationSymlinkIfPresent(dest); err != nil {
return err
}
// Create the destination file with source permissions
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
if err != nil {
@@ -356,6 +356,74 @@ func TestCopySymlinkReplacesExistingDestination(t *testing.T) {
}
}
func TestCopyFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "src.txt")
if err := os.WriteFile(src, []byte("copied"), 0o644); err != nil {
t.Fatal(err)
}
if err := CopyFile(src, link); err != nil {
t.Fatalf("CopyFile: %v", err)
}
info, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Fatal("destination should be a regular file replacing the symlink entry")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != "keep me" {
t.Fatalf("copy followed the symlink target: %v %q", err, got)
}
if body, err := os.ReadFile(link); err != nil || string(body) != "copied" {
t.Fatalf("link path content = %q, err=%v", body, err)
}
}
func TestMoveFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "src.txt")
if err := os.WriteFile(src, []byte("moved"), 0o644); err != nil {
t.Fatal(err)
}
if err := MoveFile(src, link); err != nil {
t.Fatalf("MoveFile: %v", err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatal("source should be gone after move")
}
info, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Fatal("destination should be a regular file replacing the symlink entry")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != "keep me" {
t.Fatalf("move followed the symlink target: %v %q", err, got)
}
}
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
const content = "complete file contents"
src := struct{ io.Reader }{bytes.NewBufferString(content)}
@@ -0,0 +1,110 @@
package fileutils
import (
"bytes"
"io/fs"
"time"
)
// memFS is a read-only fs.FS that holds every file's contents in memory.
// It is built once at startup from a disk-backed filesystem so that asset
// requests never touch the disk.
type memFS struct {
files map[string]memFile
}
type memFile struct {
data []byte
modTime time.Time
}
// loadMemFS reads every regular file under src into memory.
func loadMemFS(src fs.FS) (*memFS, error) {
m := &memFS{files: make(map[string]memFile)}
err := fs.WalkDir(src, ".", func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(src, name)
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
m.files[name] = memFile{data: data, modTime: info.ModTime()}
return nil
})
if err != nil {
return nil, err
}
return m, nil
}
// Size returns the total number of bytes held in memory.
func (m *memFS) Size() int {
total := 0
for _, f := range m.files {
total += len(f.data)
}
return total
}
// Len returns the number of files held in memory.
func (m *memFS) Len() int {
return len(m.files)
}
// Open implements fs.FS. Only regular files are supported, which is all the
// asset callers need (ReadFile, Stat and template parsing).
func (m *memFS) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
f, ok := m.files[name]
if !ok {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
return &memOpenFile{
Reader: bytes.NewReader(f.data),
info: memFileInfo{
name: baseName(name),
size: int64(len(f.data)),
modTime: f.modTime,
},
}, nil
}
type memOpenFile struct {
*bytes.Reader
info memFileInfo
}
func (f *memOpenFile) Stat() (fs.FileInfo, error) { return f.info, nil }
func (f *memOpenFile) Close() error { return nil }
type memFileInfo struct {
name string
size int64
modTime time.Time
}
func (i memFileInfo) Name() string { return i.name }
func (i memFileInfo) Size() int64 { return i.size }
func (i memFileInfo) Mode() fs.FileMode { return 0o444 }
func (i memFileInfo) ModTime() time.Time { return i.modTime }
func (i memFileInfo) IsDir() bool { return false }
func (i memFileInfo) Sys() any { return nil }
func baseName(name string) string {
for i := len(name) - 1; i >= 0; i-- {
if name[i] == '/' {
return name[i+1:]
}
}
return name
}
@@ -0,0 +1,69 @@
package fileutils
import (
"io/fs"
"net/http"
"os"
"path/filepath"
"testing"
)
func TestLoadMemFSServesFromMemory(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"index.html": "<html>index</html>",
"assets/app.js.gz": "gzip-bytes",
"fonts/icons.woff2": "font-bytes",
}
for name, content := range files {
path := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
m, err := loadMemFS(dirFS{Dir: http.Dir(dir)})
if err != nil {
t.Fatalf("loadMemFS: %v", err)
}
if m.Len() != len(files) {
t.Fatalf("loaded %d files, want %d", m.Len(), len(files))
}
// Remove everything from disk: the cache must still serve every file.
if removeErr := os.RemoveAll(dir); removeErr != nil {
t.Fatal(removeErr)
}
for name, want := range files {
got, readErr := fs.ReadFile(m, name)
if readErr != nil {
t.Fatalf("ReadFile(%s) after disk removal: %v", name, readErr)
}
if string(got) != want {
t.Errorf("ReadFile(%s) = %q, want %q", name, got, want)
}
info, statErr := fs.Stat(m, name)
if statErr != nil {
t.Fatalf("Stat(%s): %v", name, statErr)
}
if info.IsDir() || info.Size() != int64(len(want)) || info.Name() != filepath.Base(name) {
t.Errorf("Stat(%s) = %+v, unexpected", name, info)
}
}
// Template-style glob on a plain name must resolve via Stat.
matches, globErr := fs.Glob(m, "index.html")
if globErr != nil || len(matches) != 1 {
t.Fatalf("Glob(index.html) = %v, %v", matches, globErr)
}
if _, readErr := fs.ReadFile(m, "missing.css"); readErr == nil {
t.Error("ReadFile(missing.css) should fail")
}
if _, readErr := fs.ReadFile(m, "../escape"); readErr == nil {
t.Error("invalid path should be rejected")
}
}
+3 -3
View File
@@ -205,8 +205,8 @@ func collectPayload(versionLabel string) (snapshotPayload, error) {
Integrations: integrationsPayload{
OnlyOfficeConfigured: strings.TrimSpace(settings.Config.Integrations.OnlyOffice.Url) != "",
OnlyOfficeViewOnly: settings.Config.Integrations.OnlyOffice.ViewOnly,
MediaEnabled: settings.MediaEnabled(),
HardwareAcceleration: settings.Config.Integrations.Media.HardwareAcceleration,
MediaEnabled: settings.FFmpegEnabled(),
HardwareAcceleration: settings.HardwareAccelerationEnabled(),
},
Features: featuresPayload{
DisablePreviews: settings.Config.Server.DisablePreviews,
@@ -301,7 +301,7 @@ func detectRuntime() string {
func mapSystemOS(goos string) string {
switch goos {
case "linux", "windows", "darwin":
case "linux", "windows", "darwin", "freebsd":
return goos
default:
return "other"
@@ -0,0 +1,25 @@
package analytics
import "testing"
func TestMapSystemOS(t *testing.T) {
tests := []struct {
name string
goos string
want string
}{
{name: "linux", goos: "linux", want: "linux"},
{name: "windows", goos: "windows", want: "windows"},
{name: "darwin", goos: "darwin", want: "darwin"},
{name: "freebsd release build", goos: "freebsd", want: "freebsd"},
{name: "unsupported goos", goos: "plan9", want: "other"},
{name: "empty", goos: "", want: "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mapSystemOS(tt.goos); got != tt.want {
t.Errorf("mapSystemOS(%q) = %q, want %q", tt.goos, got, tt.want)
}
})
}
}
+28
View File
@@ -1,11 +1,16 @@
package app
import (
"context"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/transcoding"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// App holds wired dependencies for the running process.
@@ -17,6 +22,11 @@ type App struct {
// WireServices connects domain packages to the state store after Open.
func WireServices(store *state.Store) (*App, error) {
return WireServicesWithContext(context.Background(), store)
}
// WireServicesWithContext wires services using ctx for background workers.
func WireServicesWithContext(ctx context.Context, store *state.Store) (*App, error) {
filesSvc := files.New(store, store, store)
authSvc := auth.New(store)
files.SetDefault(filesSvc)
@@ -26,6 +36,24 @@ func WireServices(store *state.Store) (*App, error) {
if err := auth.InitWebAuthn(store); err != nil {
return nil, err
}
// Always create the manager: the engine re-checks ffmpeg availability per
// request, so a runtime admin settings change takes effect without restart.
storeDir, err := transcoding.NewStore(settings.TranscodeCacheDir())
if err != nil {
return nil, err
}
// Delivery URLs stay root-relative so a subpath deployment or alternate
// hostname still resolves and the session cookie is sent.
transcodeMgr, err := transcoding.NewManager(ctx, transcoding.NewMediaEngine(), storeDir,
strings.TrimRight(settings.Config.Http.BaseURL, "/"))
if err != nil {
return nil, err
}
// Exported so the web layer (and shutdown) can reach it without threading
// it through StartHttp's Deps.
transcoding.SetDefault(transcodeMgr)
return &App{
Store: store,
Files: filesSvc,
+1
View File
@@ -57,6 +57,7 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
},
},
}
claim.Minimal = &minimal
if !minimal {
claim.Permissions = users.SanitizeTokenPermissions(perms)
}
+81 -76
View File
@@ -3,7 +3,6 @@ package access
import (
"fmt"
"maps"
"path/filepath"
"slices"
"sort"
"strings"
@@ -125,6 +124,9 @@ type RuleUpsert struct {
type SQLPersister interface {
SaveAccessRule(source, path string, rule *AccessRule) error
DeleteAccessRule(source, path string) error
MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules RuleMap) error
AccessRulesLowercaseMigrated() (bool, error)
SetAccessRulesLowercaseMigrated() error
SaveGroup(name string, members StringSet) error
DeleteGroup(name string) error
DeleteGroupWithRules(name string, save []RuleUpsert, remove []RuleKey) error
@@ -264,7 +266,7 @@ func (s *Storage) restoreRuleSQLNL(key RuleKey, rule *AccessRule) error {
// RemoveRuleByPath removes a rule by normalized index path.
func (s *Storage) RemoveRuleByPath(sourcePath string, indexPath utils.IndexPath) {
s.RemoveRuleByPathKey(sourcePath, ruleKey(indexPath))
s.RemoveRuleByPathKey(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
}
// RemoveRuleByPathKey removes a rule by its exact storage key (for legacy migration paths).
@@ -277,6 +279,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
return
}
pathKey = strings.ToLower(pathKey)
if _, exists := rulesBySource[pathKey]; !exists {
return
}
@@ -289,7 +292,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
if s.sqlStore != nil {
_ = s.sqlStore.DeleteAccessRule(sourcePath, pathKey)
normalized := utils.AddTrailingSlashIfNotExists(pathKey)
if normalized != pathKey {
if normalized != pathKey && rulesBySource[normalized] == nil {
_ = s.sqlStore.DeleteAccessRule(sourcePath, normalized)
}
}
@@ -298,7 +301,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
// The caller must hold the lock.
func (s *Storage) getOrCreateRuleNL(sourcePath string, indexPath utils.IndexPath) *AccessRule {
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
if _, ok := s.AllRules[sourcePath]; !ok {
s.AllRules[sourcePath] = make(RuleMap)
}
@@ -309,6 +312,19 @@ func (s *Storage) getOrCreateRuleNL(sourcePath string, indexPath utils.IndexPath
Allow: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
}
s.AllRules[sourcePath][normalizedPath] = rule
} else {
if rule.Deny.Users == nil {
rule.Deny.Users = make(StringSet)
}
if rule.Deny.Groups == nil {
rule.Deny.Groups = make(StringSet)
}
if rule.Allow.Users == nil {
rule.Allow.Users = make(StringSet)
}
if rule.Allow.Groups == nil {
rule.Allow.Groups = make(StringSet)
}
}
return rule
}
@@ -324,7 +340,7 @@ func (s *Storage) DenyUser(sourcePath string, indexPath utils.IndexPath, usernam
}
rule.Deny.Users[username] = struct{}{}
s.clearAllCaches()
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
return nil
}
@@ -339,7 +355,7 @@ func (s *Storage) AllowUser(sourcePath string, indexPath utils.IndexPath, userna
}
rule.Allow.Users[username] = struct{}{}
s.clearAllCaches()
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
return nil
}
@@ -357,7 +373,7 @@ func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupn
}
rule.Deny.Groups[groupname] = struct{}{}
s.clearAllCaches()
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
return nil
}
@@ -375,7 +391,7 @@ func (s *Storage) AllowGroup(sourcePath string, indexPath utils.IndexPath, group
}
rule.Allow.Groups[groupname] = struct{}{}
s.clearAllCaches()
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
return nil
}
@@ -389,13 +405,13 @@ func (s *Storage) DenyAll(sourcePath string, indexPath utils.IndexPath) error {
}
rule.DenyAll = true
s.clearAllCaches()
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
return nil
}
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath string, indexPath utils.IndexPath, username string) bool {
indexPath = checkPath(indexPath)
indexPath = s.checkPathForSource(sourcePath, indexPath)
pathStr := indexPath.String()
// Get current version for the sourcePath
@@ -468,7 +484,7 @@ func (s *Storage) getRuleAtExactPath(sourcePath string, indexPath utils.IndexPat
if !ok {
return nil, false
}
rule, ok := rulesBySource[ruleKey(indexPath)]
rule, ok := rulesBySource[s.ruleKeyForNL(sourcePath, indexPath)]
return rule, ok
}
@@ -527,7 +543,7 @@ func (s *Storage) GetFrontendRules(sourcePath string, indexPath utils.IndexPath)
}
// Check if path exists on filesystem
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath.String()))
pathExists := checkRulePathExists(sourcePath, indexPath.String())
frontendRules := FrontendAccessRule{
SourceDenyDefault: sourceDenyDefault,
@@ -545,7 +561,7 @@ func (s *Storage) GetFrontendRules(sourcePath string, indexPath utils.IndexPath)
if !ok {
return frontendRules, false
}
rule, ok := rulesBySource[ruleKey(indexPath)]
rule, ok := rulesBySource[s.ruleKeyForNL(sourcePath, indexPath)]
if !ok || rule == nil {
return frontendRules, false
}
@@ -592,7 +608,7 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
frontendPath := indexPath
// Check if path exists on filesystem
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
pathExists := checkRulePathExists(sourcePath, indexPath)
// Convert AccessRule to FrontendAccessRule
frontendRules[frontendPath] = FrontendAccessRule{
@@ -1020,7 +1036,7 @@ func (s *Storage) RenameUserInRules(oldName, newName string) error {
func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
@@ -1031,11 +1047,8 @@ func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath,
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if !accessRuleHasPayload(rule) {
s.deleteStoredRuleNL(sourcePath, normalizedPath)
}
if removed {
s.clearAllCaches()
@@ -1049,7 +1062,7 @@ func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath,
func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
@@ -1060,11 +1073,8 @@ func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath,
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if !accessRuleHasPayload(rule) {
s.deleteStoredRuleNL(sourcePath, normalizedPath)
}
if removed {
s.clearAllCaches()
@@ -1078,7 +1088,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath,
func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, username string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
@@ -1089,11 +1099,8 @@ func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, u
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if !accessRuleHasPayload(rule) {
s.deleteStoredRuleNL(sourcePath, normalizedPath)
}
if removed {
s.clearAllCaches()
@@ -1107,7 +1114,7 @@ func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, u
func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath, groupname string) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
@@ -1118,11 +1125,8 @@ func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath,
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if !accessRuleHasPayload(rule) {
s.deleteStoredRuleNL(sourcePath, normalizedPath)
}
if removed {
s.clearAllCaches()
@@ -1136,7 +1140,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath,
func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (bool, error) {
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
@@ -1147,11 +1151,8 @@ func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (b
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if !accessRuleHasPayload(rule) {
s.deleteStoredRuleNL(sourcePath, normalizedPath)
}
if removed {
s.clearAllCaches()
@@ -1193,10 +1194,7 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
if rule.DenyAll || len(rule.Allow.Users) > 0 || len(rule.Allow.Groups) > 0 || len(rule.Deny.Users) > 0 || len(rule.Deny.Groups) > 0 {
continue
}
delete(s.AllRules[key.Source], key.Path)
if len(s.AllRules[key.Source]) == 0 {
delete(s.AllRules, key.Source)
}
s.deleteStoredRuleNL(key.Source, key.Path)
}
var persisted []RuleKey
for _, key := range keys {
@@ -1240,10 +1238,7 @@ func (s *Storage) removeAllRulesForGroupNL(groupname string) ([]RuleKey, map[Rul
delete(rule.Deny.Groups, groupname)
// A rule left with only DenyAll must stay, otherwise removing the group would open the path.
if !accessRuleHasPayload(rule) {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
s.deleteStoredRuleNL(sourcePath, indexPath)
}
touched = append(touched, key)
}
@@ -1485,7 +1480,7 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return 0, nil
@@ -1521,7 +1516,7 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
// If rule is now empty, mark it for deletion
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
delete(s.AllRules[sourcePath], rulePath)
s.deleteStoredRuleNL(sourcePath, rulePath)
pathChanged = true
}
if pathChanged {
@@ -1530,11 +1525,6 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
}
}
// If no rules left for this source, remove the source entry
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if changed {
s.clearAllCaches()
for rp := range touchedPaths {
@@ -1553,7 +1543,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := ruleKey(indexPath)
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return 0, nil
@@ -1589,7 +1579,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
// If rule is now empty, mark it for deletion
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
delete(s.AllRules[sourcePath], rulePath)
s.deleteStoredRuleNL(sourcePath, rulePath)
pathChanged = true
}
if pathChanged {
@@ -1598,11 +1588,6 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
}
}
// If no rules left for this source, remove the source entry
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if changed {
s.clearAllCaches()
for rp := range touchedPaths {
@@ -1625,11 +1610,15 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
return 0, nil // No rules for this source, not an error
}
oldKey := ruleKey(oldPath)
newKey := ruleKey(newPath)
oldKey := s.ruleKeyForNL(sourcePath, oldPath)
newKey := s.ruleKeyForNL(sourcePath, newPath)
if oldKey == newKey {
return 0, nil
}
updated := 0
rulesToUpdate := make(map[string]string) // old path -> new path
movedRules := make(map[string]*AccessRule)
// Find all rules that need to be updated
for rulePath := range rulesBySource {
@@ -1641,11 +1630,20 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
}
}
// Update all matched rules
for oldRulePath, newRulePath := range rulesToUpdate {
rule := rulesBySource[oldRulePath]
// Remove all matched rules first so overlapping moves cannot clobber each other
for oldRulePath := range rulesToUpdate {
movedRules[oldRulePath] = rulesBySource[oldRulePath]
delete(rulesBySource, oldRulePath)
rulesBySource[newRulePath] = rule
}
// Insert or merge into target paths
for oldRulePath, newRulePath := range rulesToUpdate {
rule := movedRules[oldRulePath]
if existing, exists := rulesBySource[newRulePath]; exists {
mergeAccessRulesInPlace(existing, rule)
} else {
rulesBySource[newRulePath] = rule
}
logger.Info("access rule updated", "source", sourcePath, "fromPath", oldRulePath, "toPath", newRulePath)
updated++
}
@@ -1653,7 +1651,7 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
if updated > 0 {
s.clearAllCaches()
for oldRulePath := range rulesToUpdate {
if s.sqlStore != nil {
if s.sqlStore != nil && rulesBySource[oldRulePath] == nil {
_ = s.sqlStore.DeleteAccessRule(sourcePath, oldRulePath)
}
}
@@ -1670,8 +1668,8 @@ func (s *Storage) UpdateRulePath(sourcePath string, oldPath, newPath utils.Index
s.mux.Lock()
defer s.mux.Unlock()
oldKey := ruleKey(oldPath)
newKey := ruleKey(newPath)
oldKey := s.ruleKeyForNL(sourcePath, oldPath)
newKey := s.ruleKeyForNL(sourcePath, newPath)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
@@ -1682,9 +1680,16 @@ func (s *Storage) UpdateRulePath(sourcePath string, oldPath, newPath utils.Index
if !ok {
return fmt.Errorf("no rule found for path: %s", oldKey)
}
if oldKey == newKey {
return nil
}
delete(rulesBySource, oldKey)
rulesBySource[newKey] = rule
if existing, exists := rulesBySource[newKey]; exists {
mergeAccessRulesInPlace(existing, rule)
} else {
rulesBySource[newKey] = rule
}
s.clearAllCaches()
if s.sqlStore != nil {
_ = s.sqlStore.DeleteAccessRule(sourcePath, oldKey)
@@ -16,6 +16,11 @@ type stubGroupSQL struct {
func (s *stubGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (s *stubGroupSQL) DeleteAccessRule(string, string) error { return nil }
func (s *stubGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
func (s *stubGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
func (s *stubGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return nil
}
func (s *stubGroupSQL) SaveGroup(string, access.StringSet) error {
return s.saveErr
}
@@ -43,6 +48,11 @@ type recordingGroupSQL struct {
func (s *recordingGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (s *recordingGroupSQL) DeleteAccessRule(string, string) error { return nil }
func (s *recordingGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
func (s *recordingGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
func (s *recordingGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return nil
}
func (s *recordingGroupSQL) SaveGroup(name string, members access.StringSet) error {
if s.groups == nil {
s.groups = make(map[string]access.StringSet)
@@ -0,0 +1,192 @@
package access
import (
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Access rules are stored and matched using lowercase path keys (strings.ToLower).
// checkRulePathExists reports whether rulePath exists under sourcePath, matching
// path segments case-insensitively when the exact path is not found on case-sensitive filesystems.
func checkRulePathExists(sourcePath, rulePath string) bool {
if utils.CheckPathExists(filepath.Join(sourcePath, rulePath)) {
return true
}
rel := strings.Trim(strings.TrimSpace(rulePath), "/")
if rel == "" {
return utils.CheckPathExists(sourcePath)
}
current := sourcePath
for _, part := range strings.Split(rel, "/") {
if part == "" {
continue
}
candidate := filepath.Join(current, part)
if utils.CheckPathExists(candidate) {
current = candidate
continue
}
entries, err := os.ReadDir(current)
if err != nil {
return false
}
partLower := strings.ToLower(part)
matched := ""
for _, e := range entries {
if strings.ToLower(e.Name()) == partLower {
matched = e.Name()
break
}
}
if matched == "" {
return false
}
current = filepath.Join(current, matched)
}
return true
}
// ruleKeyForNL returns the storage key; caller must hold s.mux.
func (s *Storage) ruleKeyForNL(_ string, indexPath utils.IndexPath) string {
return strings.ToLower(ruleKey(indexPath))
}
// ruleKeyFor returns the storage/lookup key for a rule path on this source.
func (s *Storage) ruleKeyFor(sourcePath string, indexPath utils.IndexPath) string {
s.mux.RLock()
defer s.mux.RUnlock()
return s.ruleKeyForNL(sourcePath, indexPath)
}
func (s *Storage) checkPathForSourceNL(_ string, indexPath utils.IndexPath) utils.IndexPath {
p := checkPath(indexPath)
return utils.IndexPathFromNormalized(strings.ToLower(p.String()), p.IsDir)
}
// checkPathForSource normalizes an index path for permission checks on a source.
func (s *Storage) checkPathForSource(sourcePath string, indexPath utils.IndexPath) utils.IndexPath {
s.mux.RLock()
defer s.mux.RUnlock()
return s.checkPathForSourceNL(sourcePath, indexPath)
}
// StartupMigrateAccessRuleKeys lowercases existing rule keys in memory and SQL,
// merging colliding paths. Idempotent when keys are already lowercase.
func (s *Storage) StartupMigrateAccessRuleKeys() error {
s.mux.Lock()
defer s.mux.Unlock()
for sourcePath := range s.AllRules {
if err := s.migrateSourceRulesLowercaseNL(sourcePath); err != nil {
return err
}
}
if s.sqlStore == nil {
return nil
}
migrated, err := s.sqlStore.AccessRulesLowercaseMigrated()
if err != nil {
return err
}
if migrated {
return nil
}
return s.sqlStore.SetAccessRulesLowercaseMigrated()
}
func (s *Storage) migrateSourceRulesLowercaseNL(sourcePath string) error {
rulesBySource, ok := s.AllRules[sourcePath]
if !ok || len(rulesBySource) == 0 {
return nil
}
if !rulesNeedLowercaseMigration(rulesBySource) {
return nil
}
oldPaths := make([]string, 0, len(rulesBySource))
newRules := make(RuleMap)
for path, rule := range rulesBySource {
oldPaths = append(oldPaths, path)
lower := strings.ToLower(path)
if existing, exists := newRules[lower]; exists {
logger.Infof("access rules: merging colliding paths %q into %q on source %s", path, lower, sourcePath)
mergeAccessRulesInPlace(existing, rule)
continue
}
fresh := &AccessRule{}
mergeAccessRulesInPlace(fresh, rule)
newRules[lower] = fresh
if lower != path {
logger.Infof("access rules: migrated rule path %q -> %q on source %s", path, lower, sourcePath)
}
}
s.AllRules[sourcePath] = newRules
if s.sqlStore == nil {
return nil
}
return s.sqlStore.MigrateAccessRulePathsLowercase(sourcePath, oldPaths, newRules)
}
func rulesNeedLowercaseMigration(rules RuleMap) bool {
byLower := make(map[string]string)
for path := range rules {
lower := strings.ToLower(path)
if path != lower {
return true
}
if other, ok := byLower[lower]; ok && other != path {
return true
}
byLower[lower] = path
}
return false
}
func mergeAccessRulesInPlace(dst, src *AccessRule) {
if src == nil || dst == nil {
return
}
if src.DenyAll {
dst.DenyAll = true
}
if dst.Deny.Users == nil {
dst.Deny.Users = StringSet{}
}
if dst.Deny.Groups == nil {
dst.Deny.Groups = StringSet{}
}
if dst.Allow.Users == nil {
dst.Allow.Users = StringSet{}
}
if dst.Allow.Groups == nil {
dst.Allow.Groups = StringSet{}
}
for u := range src.Deny.Users {
dst.Deny.Users[u] = struct{}{}
}
for g := range src.Deny.Groups {
dst.Deny.Groups[g] = struct{}{}
}
for u := range src.Allow.Users {
dst.Allow.Users[u] = struct{}{}
}
for g := range src.Allow.Groups {
dst.Allow.Groups[g] = struct{}{}
}
}
func (s *Storage) deleteStoredRuleNL(sourcePath, rulePath string) {
delete(s.AllRules[sourcePath], rulePath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
@@ -0,0 +1,67 @@
package access_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
func TestStartupMigrationMergesCollidingRules(t *testing.T) {
store, userStore, sqlStore := createTestStorageWithSQL(t)
_ = store
_ = userStore
ruleA := &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"alice": {}}},
}
ruleB := &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"bob": {}}},
}
if err := sqlStore.SaveAccessRule("/src", "/Private/", ruleA); err != nil {
t.Fatal(err)
}
if err := sqlStore.SaveAccessRule("/src", "/private/", ruleB); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
s.SetSQLStore(sqlStore)
if startErr := s.StartupMigrateAccessRuleKeys(); startErr != nil {
t.Fatal(startErr)
}
byPath, err := sqlStore.GetAccessRulesBySource("/src")
if err != nil {
t.Fatal(err)
}
if len(byPath) != 1 {
t.Fatalf("expected one merged rule row, got %d", len(byPath))
}
merged := byPath["/private/"]
if merged == nil {
t.Fatal("expected merged rule at lowercase key")
}
if _, ok := merged.Deny.Users["alice"]; !ok {
t.Fatal("missing alice from merge")
}
if _, ok := merged.Deny.Users["bob"]; !ok {
t.Fatal("missing bob from merge")
}
migrated, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil || !migrated {
t.Fatal("marker should be set after first migration")
}
byPath2, err := sqlStore.GetAccessRulesBySource("/src")
if err != nil {
t.Fatal(err)
}
if len(byPath2) != 1 {
t.Fatal("rules should remain after marker is set without re-running migration")
}
}
@@ -0,0 +1,152 @@
package access_test
import (
"errors"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
type migrateCallCounter struct {
access.SQLPersister
calls int
}
func (m *migrateCallCounter) MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules access.RuleMap) error {
m.calls++
return m.SQLPersister.MigrateAccessRulePathsLowercase(source, oldPaths, newRules)
}
type failAccessRuleMigratePersister struct {
access.SQLPersister
}
func (failAccessRuleMigratePersister) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return errors.New("simulated migration failure")
}
func TestFreshDBSetsAccessRulesLowercaseMarker(t *testing.T) {
s, _, sqlStore := createTestStorageWithSQL(t)
s.SetSQLStore(sqlStore)
if err := s.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
ok, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected migration marker on empty database")
}
}
func TestUpgradeMigratesAndSetsMarker(t *testing.T) {
_, _, sqlStore := createTestStorageWithSQL(t)
rule := &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"alice": {}}},
}
if err := sqlStore.SaveAccessRule("/src", "/Private/", rule); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
counter := &migrateCallCounter{SQLPersister: sqlStore}
s.SetSQLStore(counter)
if err = s.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
if counter.calls != 1 {
t.Fatalf("expected one SQL migration, got %d", counter.calls)
}
ok, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil || !ok {
t.Fatalf("marker should be set after upgrade, ok=%v err=%v", ok, err)
}
byPath, err := sqlStore.GetAccessRulesBySource("/src")
if err != nil {
t.Fatal(err)
}
if byPath["/private/"] == nil {
t.Fatal("expected lowercased rule row")
}
}
func TestSecondStartupSkipsAccessRulesMigration(t *testing.T) {
_, _, sqlStore := createTestStorageWithSQL(t)
if err := sqlStore.SaveAccessRule("/src", "/Private/", &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"u": {}}},
}); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
counter := &migrateCallCounter{SQLPersister: sqlStore}
s.SetSQLStore(counter)
if err = s.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
if counter.calls != 1 {
t.Fatalf("first startup: expected 1 migrate call, got %d", counter.calls)
}
already, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil || !already {
t.Fatal("marker should be set after first startup")
}
s2 := access.NewStorage(nil)
rules2, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s2.AllRules = rules2
s2.SetSQLStore(counter)
if err = s2.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
if counter.calls != 1 {
t.Fatalf("second startup must not invoke SQL migration, got %d calls", counter.calls)
}
}
func TestMigrationFailureDoesNotSetMarker(t *testing.T) {
_, _, sqlStore := createTestStorageWithSQL(t)
if err := sqlStore.SaveAccessRule("/src", "/Private/", &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"u": {}}},
}); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
s.SetSQLStore(failAccessRuleMigratePersister{SQLPersister: sqlStore})
if err = s.StartupMigrateAccessRuleKeys(); err == nil {
t.Fatal("expected migration failure")
}
ok, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("marker must not be set when migration fails")
}
}
@@ -0,0 +1,211 @@
package access
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestPermittedDenyCaseVariant(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/private/secret.txt", false), "alice"); err != nil {
t.Fatal(err)
}
key := s.ruleKeyFor("/data", utils.IndexPathFromNormalized("/private/secret.txt", false))
if key != "/private/secret.txt/" {
t.Fatalf("stored key %q", key)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/PRIVATE/secret.txt", false), "alice") {
t.Fatal("case variant must hit deny rule")
}
}
func TestRemoveRuleByPathKeyCaseVariant(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/private/x", false), "alice"); err != nil {
t.Fatal(err)
}
s.RemoveRuleByPathKey("/data", "/PRIVATE/x/")
if !s.Permitted("/data", utils.IndexPathFromNormalized("/private/x", false), "alice") {
t.Fatal("rule should be removed when key casing differs")
}
}
func TestPermittedHotPathNoFilesystemAccess(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
_ = s.DenyUser("/data", utils.IndexPathFromNormalized("/a/b", false), "u")
if !s.Permitted("/data", utils.IndexPathFromNormalized("/A/B", false), "other") {
t.Fatal("expected permitted for non-denied user")
}
}
func BenchmarkPermittedLowercaseNoFS(b *testing.B) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
_ = s.DenyUser("/data", utils.IndexPathFromNormalized("/x/y", false), "u")
path := utils.IndexPathFromNormalized("/X/Y", false)
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.Permitted("/data", path, "bob")
}
}
func TestRulesNeedLowercaseMigration(t *testing.T) {
if rulesNeedLowercaseMigration(RuleMap{"/ok/": {}}) {
t.Fatal("already lowercase")
}
if !rulesNeedLowercaseMigration(RuleMap{"/Private/": {}}) {
t.Fatal("uppercase segment should need migration")
}
if !rulesNeedLowercaseMigration(RuleMap{"/Private/": {}, "/private/": {}}) {
t.Fatal("case collision should need migration")
}
}
func TestCheckRulePathExistsMixedCase(t *testing.T) {
root := t.TempDir()
mixedDir := filepath.Join(root, "MyFolder", "SubDir")
if err := os.MkdirAll(mixedDir, 0o755); err != nil {
t.Fatal(err)
}
mixedFile := filepath.Join(mixedDir, "Readme.MD")
if err := os.WriteFile(mixedFile, []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
root: {Path: root, Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser(root, utils.IndexPathFromNormalized("/MyFolder/SubDir/Readme.MD", false), "alice"); err != nil {
t.Fatal(err)
}
all, err := s.GetAllRules(root)
if err != nil {
t.Fatal(err)
}
rule, ok := all["/myfolder/subdir/readme.md/"]
if !ok {
t.Fatalf("expected lowercased rule key in GetAllRules, got %v", all)
}
if !rule.PathExists {
t.Fatal("expected PathExists=true for mixed-case path on disk")
}
feRule, ok := s.GetFrontendRules(root, utils.IndexPathFromNormalized("/myfolder/subdir/readme.md", false))
if !ok {
t.Fatal("expected rule from GetFrontendRules with lowercase query")
}
if !feRule.PathExists {
t.Fatal("expected PathExists=true in GetFrontendRules")
}
if checkRulePathExists(root, "/myfolder/subdir/nonexistent.txt/") {
t.Fatal("expected false for missing path")
}
}
func TestUpdateRulesAndUpdateRulePathCaseOnlyAndCollision(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/MyFolder", true), "alice"); err != nil {
t.Fatal(err)
}
// Case-only rename is a no-op and preserves the rule.
updated, err := s.UpdateRules("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/myfolder", true))
if err != nil {
t.Fatal(err)
}
if updated != 0 {
t.Fatalf("expected 0 updated for case-only rename, got %d", updated)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/MyFolder", true), "alice") {
t.Fatal("rule should still deny alice after case-only rename")
}
if err := s.UpdateRulePath("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/myfolder", true)); err != nil {
t.Fatal(err)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/myfolder", true), "alice") {
t.Fatal("rule should still deny alice after case-only UpdateRulePath")
}
// Colliding target rule merges entries instead of overwriting.
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/Archive", true), "bob"); err != nil {
t.Fatal(err)
}
if err := s.UpdateRulePath("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/archive", true)); err != nil {
t.Fatal(err)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/archive", true), "alice") ||
s.Permitted("/data", utils.IndexPathFromNormalized("/archive", true), "bob") {
t.Fatal("expected merged rule at /archive/ to deny both alice and bob")
}
}
func TestRemoveAllowUserPreservesDenyAll(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
p := utils.IndexPathFromNormalized("/Secret", true)
if err := s.DenyAll("/data", p); err != nil {
t.Fatal(err)
}
if err := s.AllowUser("/data", p, "alice"); err != nil {
t.Fatal(err)
}
removed, err := s.RemoveAllowUser("/data", p, "alice")
if err != nil || !removed {
t.Fatalf("RemoveAllowUser failed: removed=%v err=%v", removed, err)
}
if s.Permitted("/data", p, "bob") {
t.Fatal("DenyAll must remain active after removing the last allowed user")
}
}
@@ -14,6 +14,11 @@ type failingRevokePersister struct{}
func (failingRevokePersister) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (failingRevokePersister) DeleteAccessRule(string, string) error { return nil }
func (failingRevokePersister) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
func (failingRevokePersister) SetAccessRulesLowercaseMigrated() error { return nil }
func (failingRevokePersister) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return nil
}
func (failingRevokePersister) SaveGroup(string, access.StringSet) error { return nil }
func (failingRevokePersister) DeleteGroup(string) error { return nil }
func (failingRevokePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
@@ -6,34 +6,35 @@ import "fmt"
type EventType string
const (
EventDownload EventType = "download"
EventMove EventType = "move"
EventCopy EventType = "copy"
EventRename EventType = "rename"
EventUpload EventType = "upload"
EventDelete EventType = "delete"
EventBulkDelete EventType = "bulkDelete"
EventArchive EventType = "archive"
EventUnarchive EventType = "unarchive"
EventShareCreate EventType = "shareCreate"
EventShareUpdate EventType = "shareUpdate"
EventShareDelete EventType = "shareDelete"
EventUserCreate EventType = "userCreate"
EventUserUpdate EventType = "userUpdate"
EventUserDelete EventType = "userDelete"
EventAccessUpdate EventType = "accessUpdate"
EventAccessCreate EventType = "accessCreate"
EventAccessDelete EventType = "accessDelete"
EventQuotaCreate EventType = "quotaCreate"
EventQuotaUpdate EventType = "quotaUpdate"
EventQuotaDelete EventType = "quotaDelete"
EventLogin EventType = "login"
EventLogout EventType = "logout"
EventSignup EventType = "signup"
EventPasskeyRegister EventType = "passkeyRegister"
EventPasskeyDelete EventType = "passkeyDelete"
EventTokenCreate EventType = "tokenCreate"
EventTokenDelete EventType = "tokenDelete"
EventDownload EventType = "download"
EventMove EventType = "move"
EventCopy EventType = "copy"
EventRename EventType = "rename"
EventUpload EventType = "upload"
EventFileModify EventType = "fileModify"
EventDelete EventType = "delete"
EventBulkDelete EventType = "bulkDelete"
EventArchive EventType = "archive"
EventUnarchive EventType = "unarchive"
EventShareCreate EventType = "shareCreate"
EventShareUpdate EventType = "shareUpdate"
EventShareDelete EventType = "shareDelete"
EventUserCreate EventType = "userCreate"
EventUserUpdate EventType = "userUpdate"
EventUserDelete EventType = "userDelete"
EventAccessUpdate EventType = "accessUpdate"
EventAccessCreate EventType = "accessCreate"
EventAccessDelete EventType = "accessDelete"
EventQuotaCreate EventType = "quotaCreate"
EventQuotaUpdate EventType = "quotaUpdate"
EventQuotaDelete EventType = "quotaDelete"
EventLogin EventType = "login"
EventLogout EventType = "logout"
EventSignup EventType = "signup"
EventPasskeyRegister EventType = "passkeyRegister"
EventPasskeyDelete EventType = "passkeyDelete"
EventTokenCreate EventType = "tokenCreate"
EventTokenDelete EventType = "tokenDelete"
EventDuplicateFinder EventType = "duplicateFinder"
)
@@ -44,6 +45,7 @@ var AllEventTypes = []EventType{
EventCopy,
EventRename,
EventUpload,
EventFileModify,
EventDelete,
EventBulkDelete,
EventArchive,
@@ -77,6 +79,7 @@ var FileEventTypes = []EventType{
EventCopy,
EventRename,
EventUpload,
EventFileModify,
EventDelete,
EventBulkDelete,
EventArchive,
@@ -155,7 +158,7 @@ func intersectEventTypes(requested, allowed []EventType) []EventType {
func (e EventType) Valid() bool {
switch e {
case EventDownload, EventMove, EventCopy, EventRename,
EventUpload, EventDelete, EventBulkDelete,
EventUpload, EventFileModify, EventDelete, EventBulkDelete,
EventArchive, EventUnarchive,
EventShareCreate, EventShareUpdate, EventShareDelete,
EventUserCreate, EventUserUpdate, EventUserDelete, EventAccessUpdate, EventAccessCreate, EventAccessDelete,
+2 -3
View File
@@ -45,9 +45,8 @@ type FrontendShareInfo struct {
type ShareLimits struct {
MaxBandwidth int `json:"maxBandwidth,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
QuotaLimitBytes int64 `json:"quotaLimitBytes,omitempty"`
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
Banner string `json:"banner,omitempty"`
+8 -3
View File
@@ -145,15 +145,20 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
if l == nil || l.SourcePath != oldSource {
continue
}
shareWasDir := strings.HasSuffix(l.Path, "/")
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
norm := utils.AddTrailingSlashIfNotExists(l.Path)
if !strings.HasPrefix(norm, oldPath) {
continue
}
l.SourcePath = newSource
l.Path = newPath
suffix := strings.TrimPrefix(norm, oldPath)
l.Path = strings.TrimSuffix(newPath+suffix, "/")
if shareWasDir {
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
}
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
@@ -1,6 +1,3 @@
//go:build !cgosql
// +build !cgosql
package sql
import (
@@ -1,10 +0,0 @@
//go:build cgosql
// +build cgosql
package sql
import (
_ "github.com/mattn/go-sqlite3"
)
const SqliteDriver = "sqlite3"
+131
View File
@@ -0,0 +1,131 @@
package sql
import (
"fmt"
"github.com/gtsteffaniak/go-logger/logger"
)
// supportsFTS5 reports whether the driver can create an FTS5 table with the trigram
// tokenizer (SQLite >= 3.34 with FTS5 compiled in).
func (db *IndexDB) supportsFTS5() bool {
if _, err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.__fts_probe USING fts5(x, tokenize='trigram')`); err != nil {
return false
}
_, _ = db.Exec(`DROP TABLE IF EXISTS temp.__fts_probe`)
return true
}
// ftsTableExists reports whether the FTS search table is present.
func (db *IndexDB) ftsTableExists() bool {
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'index_items_fts'`).Scan(&n); err != nil {
return false
}
return n > 0
}
// FTSReady reports whether the trigram search index is available for queries.
func (db *IndexDB) FTSReady() bool {
return db.ftsReady.Load()
}
const ftsDDL = `
CREATE VIRTUAL TABLE IF NOT EXISTS index_items_fts USING fts5(
name, spath,
content='index_items', content_rowid='rowid',
tokenize='trigram', detail=none
);
CREATE TRIGGER IF NOT EXISTS index_items_fts_ai AFTER INSERT ON index_items BEGIN
INSERT INTO index_items_fts(rowid, name, spath) VALUES (new.rowid, new.name, new.spath);
END;
CREATE TRIGGER IF NOT EXISTS index_items_fts_ad AFTER DELETE ON index_items BEGIN
INSERT INTO index_items_fts(index_items_fts, rowid, name, spath) VALUES ('delete', old.rowid, old.name, old.spath);
END;
CREATE TRIGGER IF NOT EXISTS index_items_fts_au AFTER UPDATE OF name ON index_items
WHEN old.name IS NOT new.name BEGIN
INSERT INTO index_items_fts(index_items_fts, rowid, name, spath) VALUES ('delete', old.rowid, old.name, old.spath);
INSERT INTO index_items_fts(rowid, name, spath) VALUES (new.rowid, new.name, new.spath);
END;
`
// syncSearchIndex creates the FTS5 trigram index when enabled and drops it when
// disabled. The index is derived data: enabling it builds from existing rows (no
// rescan), and disabling it removes the table and triggers.
func (db *IndexDB) syncSearchIndex(enabled bool) {
if !enabled {
db.dropSearchIndex()
return
}
if !db.supportsFTS5() {
logger.Errorf("indexSqlConfig.fts5 is enabled but FTS5 trigram support is unavailable in this build; search will use the unindexed fallback.")
db.ftsReady.Store(false)
return
}
if err := db.ensureSpathColumn(); err != nil {
logger.Errorf("failed to add the spath column (%v); search will use the unindexed fallback", err)
db.ftsReady.Store(false)
return
}
existed := db.ftsTableExists()
if _, err := db.Exec(ftsDDL); err != nil {
logger.Errorf("failed to create the FTS search index (%v); search will use the unindexed fallback", err)
db.ftsReady.Store(false)
return
}
if !existed {
if _, err := db.Exec(`INSERT INTO index_items_fts(index_items_fts) VALUES('rebuild')`); err != nil {
logger.Errorf("failed to populate the FTS search index (%v); search will use the unindexed fallback", err)
db.ftsReady.Store(false)
return
}
}
db.ftsReady.Store(true)
}
// dropSearchIndex removes the FTS table and its triggers (used when the feature is
// disabled or the index needs a rebuild).
func (db *IndexDB) dropSearchIndex() {
db.ftsReady.Store(false)
if !db.ftsTableExists() {
return
}
if _, err := db.Exec(`
DROP TRIGGER IF EXISTS index_items_fts_ai;
DROP TRIGGER IF EXISTS index_items_fts_ad;
DROP TRIGGER IF EXISTS index_items_fts_au;
DROP TABLE IF EXISTS index_items_fts;
`); err != nil {
logger.Warningf("failed to drop the FTS search index: %v", err)
}
}
// checkFTSHealth compares the FTS index against the base table and runs a cheap
// internal consistency check. The full content check (rank=1) is far slower and is
// skipped.
func (db *IndexDB) checkFTSHealth() error {
var items, docs int
if err := db.QueryRow(`SELECT count(*) FROM index_items`).Scan(&items); err != nil {
return fmt.Errorf("count index_items: %w", err)
}
if err := db.QueryRow(`SELECT count(*) FROM index_items_fts_docsize`).Scan(&docs); err != nil {
return fmt.Errorf("count fts docsize: %w", err)
}
if items != docs {
return fmt.Errorf("search index row mismatch: index_items=%d fts=%d", items, docs)
}
if _, err := db.Exec(`INSERT INTO index_items_fts(index_items_fts, rank) VALUES('integrity-check', 0)`); err != nil {
return fmt.Errorf("fts integrity check: %w", err)
}
return nil
}
// optimizeFTS merges the FTS index segments; call after bulk deletes or heavy churn.
func (db *IndexDB) optimizeFTS() {
if !db.FTSReady() {
return
}
if _, err := db.Exec(`INSERT INTO index_items_fts(index_items_fts) VALUES('optimize')`); err != nil {
logger.Warningf("FTS optimize failed: %v", err)
}
}
+49 -3
View File
@@ -7,11 +7,12 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqlitebusy"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -19,6 +20,7 @@ import (
// It wraps the underlying sql.DB connection and provides type-safe methods.
type IndexDB struct {
*TempDB
ftsReady atomic.Bool
}
func createIndexDB(name string, journalMode string, lockingMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, error) {
@@ -111,6 +113,17 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
idxDB.Close()
return nil, false, err
}
// The FTS5 search index is optional (indexSqlConfig.fts5, default off). Enabling it
// builds from existing rows; disabling it drops the table and triggers.
idxDB.syncSearchIndex(settings.Config.Server.IndexSqlConfig.FTS5)
if !disableReuse && !isNewDb && idxDB.FTSReady() {
if err := idxDB.checkFTSHealth(); err != nil {
logger.Warningf("[DB_INIT] FTS search index is unhealthy (%v); rebuilding", err)
idxDB.dropSearchIndex()
idxDB.syncSearchIndex(true)
}
}
go idxDB.startPeriodicCleanup()
return idxDB, isNewDb, nil
}
@@ -234,6 +247,7 @@ func (db *IndexDB) startPeriodicCleanup() {
} else if deletedCount > 0 {
logger.Infof("[DB_MAINTENANCE] Cleaned up %d stale index entries", deletedCount)
}
db.optimizeFTS()
}
}
@@ -259,13 +273,45 @@ func (db *IndexDB) CreateIndexTable() error {
CREATE INDEX IF NOT EXISTS idx_source_size ON index_items(source, size);
CREATE INDEX IF NOT EXISTS idx_last_updated ON index_items(source, last_updated);
`
_, err := db.Exec(query)
if err != nil {
if _, err := db.Exec(query); err != nil {
return err
}
return db.ensureCreatedTimeColumn()
}
// ensureSpathColumn adds the generated spath column (source:path) used by the FTS
// search index when it is missing from an existing table. PRAGMA table_xinfo is used
// because table_info omits generated columns.
func (db *IndexDB) ensureSpathColumn() error {
rows, err := db.Query(`PRAGMA table_xinfo(index_items)`)
if err != nil {
return err
}
found := false
for rows.Next() {
var cid, notNull, pk, hidden int
var name, columnType string
var defaultValue interface{}
if scanErr := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk, &hidden); scanErr != nil {
rows.Close()
return scanErr
}
if name == "spath" {
found = true
}
}
if rowsErr := rows.Err(); rowsErr != nil {
rows.Close()
return rowsErr
}
rows.Close()
if found {
return nil
}
_, err = db.Exec(`ALTER TABLE index_items ADD COLUMN spath TEXT GENERATED ALWAYS AS (source || ':' || path) VIRTUAL`)
return err
}
// ensureCreatedTimeColumn adds the created_time column to index_items when it is
// missing, so databases created before the column existed are upgraded in place.
func (db *IndexDB) ensureCreatedTimeColumn() error {
@@ -6,11 +6,13 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMain(m *testing.M) {
testutil.QuietLogging()
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0o644, 0o755)
}
@@ -54,10 +56,17 @@ func testIndexSQLConfig(mode string) settings.IndexSqlConfig {
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
FTS5: true,
StartupIntegrityCheck: mode,
}
}
func testIndexSQLConfigNoFTS(mode string) settings.IndexSqlConfig {
cfg := testIndexSQLConfig(mode)
cfg.FTS5 = false
return cfg
}
func TestNewIndexDB_StartupProbe_NewFile(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
@@ -127,3 +136,123 @@ func TestNewIndexDB_StartupOff(t *testing.T) {
}
defer db.Close()
}
// TestNewIndexDB_ReusesHealthyDB checks that a matching schema is reused, not recreated.
func TestNewIndexDB_ReusesHealthyDB(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
defer pop()
db, isNew, err := NewIndexDB("reuse_ok", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
if !isNew {
t.Fatalf("fresh database should report isNewDb=true")
}
if err = db.InsertItem("src1", "/a.txt", &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{Name: "a.txt", Type: "text/plain", ModTime: time.Unix(1, 0)},
}); err != nil {
t.Fatal(err)
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
db2, isNew2, err := NewIndexDB("reuse_ok", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db2.Close()
if isNew2 {
t.Fatalf("a healthy database should be reused, not recreated")
}
if !db2.FTSReady() {
t.Fatalf("expected the FTS index to be ready on reuse")
}
}
// TestNewIndexDB_FTSDisabledWhenConfigOff checks that FTS is not created when disabled.
func TestNewIndexDB_FTSDisabledWhenConfigOff(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfigNoFTS(settings.IndexStartupIntegrityOff))
defer pop()
db, _, err := NewIndexDB("fts_off", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if db.FTSReady() {
t.Fatalf("FTS should be disabled when indexSqlConfig.fts5 is false")
}
if db.ftsTableExists() {
t.Fatalf("the FTS table should not exist when disabled")
}
}
// TestNewIndexDB_FTSToggleBuildsAndDrops checks that enabling FTS builds from existing
// rows (no rescan) and disabling it removes the table.
func TestNewIndexDB_FTSToggleBuildsAndDrops(t *testing.T) {
dir := t.TempDir()
// Start with FTS off and insert a row.
pop := pushTestIndexConfig(t, dir, testIndexSQLConfigNoFTS(settings.IndexStartupIntegrityOff))
db, _, err := NewIndexDB("fts_toggle", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
if err = db.InsertItem("s1", "/a/report.txt", &iteminfo.FileInfo{
Path: "/a/report.txt",
ItemInfo: iteminfo.ItemInfo{Name: "report.txt", Type: "text/plain", ModTime: time.Unix(1, 0)},
}); err != nil {
t.Fatal(err)
}
if db.FTSReady() {
t.Fatalf("FTS should be off")
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
pop()
// Reopen with FTS on: it should build from the existing row without a rescan.
pop2 := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
db2, isNew, err := NewIndexDB("fts_toggle", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
if isNew {
t.Fatalf("reused database should not be recreated when enabling FTS")
}
if !db2.FTSReady() {
t.Fatalf("FTS should be ready after enabling")
}
if err = db2.checkFTSHealth(); err != nil {
t.Fatalf("health after enabling: %v", err)
}
filter := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
if got := runSearchItems(t, db2, "s1", "/", filter); !sameSet(got, []string{"/a/report.txt"}) {
t.Fatalf("existing row should be searchable via FTS, got %v", got)
}
if err = db2.Close(); err != nil {
t.Fatal(err)
}
pop2()
// Disable again: the table and triggers should be dropped.
pop3 := pushTestIndexConfig(t, dir, testIndexSQLConfigNoFTS(settings.IndexStartupIntegrityOff))
defer pop3()
db3, _, err := NewIndexDB("fts_toggle", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db3.Close()
if db3.FTSReady() {
t.Fatalf("FTS should be off after disabling")
}
if db3.ftsTableExists() {
t.Fatalf("the FTS table should be dropped when disabled")
}
}
+157 -81
View File
@@ -1,9 +1,12 @@
package sql
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
// SearchItem represents a single search result row from the database
@@ -19,19 +22,15 @@ type SearchItem struct {
HasPreview bool
}
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
func appendNameGlobSQL(query string, args []interface{}, patterns []string, joinAnd, caseExact bool) (string, []interface{}) {
// appendGlob adds "AND (col GLOB ? [OR/AND] ...)" when patterns is non-empty.
func appendGlob(query string, args []interface{}, column string, patterns []string, joinAnd bool) (string, []interface{}) {
if len(patterns) == 0 {
return query, args
}
nameExpr := "name"
if !caseExact {
nameExpr = "lower(name)"
}
parts := make([]string, len(patterns))
for i := range patterns {
parts[i] = nameExpr + " GLOB ?"
args = append(args, patterns[i])
for i, p := range patterns {
parts[i] = column + " GLOB ?"
args = append(args, p)
}
join := " OR "
if joinAnd {
@@ -40,96 +39,173 @@ func appendNameGlobSQL(query string, args []interface{}, patterns []string, join
return query + " AND (" + strings.Join(parts, join) + ")", args
}
// appendLike adds "AND (col LIKE ? [OR/AND] ...)" when patterns is non-empty.
func appendLike(query string, args []interface{}, column string, patterns []string, joinAnd bool) (string, []interface{}) {
if len(patterns) == 0 {
return query, args
}
parts := make([]string, len(patterns))
for i, p := range patterns {
parts[i] = column + " LIKE ?"
args = append(args, p)
}
join := " OR "
if joinAnd {
join = " AND "
}
return query + " AND (" + strings.Join(parts, join) + ")", args
}
// useFTSPath reports whether the trigram index can serve this name filter.
func useFTSPath(db *IndexDB, filter *iteminfo.NameFilter) bool {
return filter != nil && filter.UseFTS && db.FTSReady()
}
// appendNamePrefilter narrows rows using the FTS trigram index (a superset of the
// exact predicate). It is only valid in the FTS query path.
func appendNamePrefilter(query string, args []interface{}, filter *iteminfo.NameFilter) (string, []interface{}) {
if filter == nil || len(filter.FTS) == 0 {
return query, args
}
if filter.PrefilterLike {
return appendLike(query, args, "f.name", filter.FTS, filter.JoinAnd)
}
return appendGlob(query, args, "f.name", filter.FTS, filter.JoinAnd)
}
// appendNameExact adds the exact name predicate applied to the base table. alias is
// "" for the single-table query or "i." in the FTS join.
func appendNameExact(query string, args []interface{}, filter *iteminfo.NameFilter, alias string) (string, []interface{}) {
if filter == nil || len(filter.Exact) == 0 {
return query, args
}
column := alias + "name"
if filter.ExactLower {
column = "lower(" + alias + "name)"
}
return appendGlob(query, args, column, filter.Exact, filter.JoinAnd)
}
// SearchItems queries the database for items matching the search criteria for a single source.
// When nameGlobPatterns is non-empty (and largest is false), rows are restricted with SQLite name GLOB ... OR ....
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
// When the filter is usable and the FTS index is ready, rows are narrowed with the trigram
// index and then filtered by the exact name predicate; otherwise the base table is scanned.
func (db *IndexDB) SearchItems(ctx context.Context, source, scope string, largest bool, filter *iteminfo.NameFilter) (*sql.Rows, error) {
if largest {
query := `
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
WHERE source = ?
`
args := []interface{}{source}
if scope != "" {
query += " AND path GLOB ?"
args = append(args, iteminfo.EscapeGlobLiteral(scope)+"*")
}
query += " ORDER BY size DESC"
return db.QueryContext(ctx, query, args...)
}
if useFTSPath(db, filter) {
query := `
SELECT i.path, i.name, i.size, i.created_time, i.mod_time, i.type, i.is_dir, i.has_preview
FROM index_items_fts f CROSS JOIN index_items i ON i.rowid = f.rowid
WHERE i.source = ?
`
args := []interface{}{source}
if scope != "" {
query += " AND i.path GLOB ?"
args = append(args, iteminfo.EscapeGlobLiteral(scope)+"*")
}
// Narrow by source+scope inside the global FTS index (superset; exact filters below).
query += " AND f.spath LIKE ?"
args = append(args, source+":"+scope+"%")
query, args = appendNamePrefilter(query, args, filter)
query, args = appendNameExact(query, args, filter, "i.")
return db.QueryContext(ctx, query, args...)
}
query := `
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
WHERE source = ?
`
args := []interface{}{source}
// Apply scope filter
if scope != "" {
// Use GLOB for prefix matching which is supported by SQLite and efficient
query += " AND path GLOB ?"
args = append(args, scope+"*")
args = append(args, iteminfo.EscapeGlobLiteral(scope)+"*")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd, caseExact)
}
if largest {
query += " ORDER BY size DESC"
}
return db.Query(query, args...)
query, args = appendNameExact(query, args, filter, "")
return db.QueryContext(ctx, query, args...)
}
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
// When nameGlobPatterns is non-empty (and largest is false), restricts rows with SQLite name GLOB ... OR ....
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
func (db *IndexDB) SearchItemsMultiSource(ctx context.Context, sources []string, sourceScopes map[string]string, largest bool, filter *iteminfo.NameFilter) (*sql.Rows, error) {
if len(sources) == 0 {
return nil, fmt.Errorf("at least one source is required")
}
args := []interface{}{}
placeholders := make([]string, len(sources))
for i, source := range sources {
placeholders[i] = "?"
args = append(args, source)
}
sourceClause := "source = ?"
if len(sources) > 1 {
sourceClause = "source IN (" + strings.Join(placeholders, ",") + ")"
}
// Per-source scope conditions (on the base table).
scopeConditions := []string{}
scopeArgs := []interface{}{}
spathConditions := []string{}
spathArgs := []interface{}{}
for _, source := range sources {
scope, hasScope := sourceScopes[source]
if hasScope && scope != "" {
scopeConditions = append(scopeConditions, "(source = ? AND path GLOB ?)")
scopeArgs = append(scopeArgs, source, iteminfo.EscapeGlobLiteral(scope)+"*")
spathConditions = append(spathConditions, "f.spath LIKE ?")
spathArgs = append(spathArgs, source+":"+scope+"%")
} else {
scopeConditions = append(scopeConditions, "source = ?")
scopeArgs = append(scopeArgs, source)
}
}
if largest {
query := `
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
WHERE ` + sourceClause
query += " AND (" + strings.Join(scopeConditions, " OR ") + ")"
query += " ORDER BY size DESC"
allArgs := append(args, scopeArgs...)
return db.QueryContext(ctx, query, allArgs...)
}
if useFTSPath(db, filter) {
query := `
SELECT i.source, i.path, i.name, i.size, i.created_time, i.mod_time, i.type, i.is_dir, i.has_preview
FROM index_items_fts f CROSS JOIN index_items i ON i.rowid = f.rowid
WHERE i.` + sourceClause
query += " AND (" + strings.Join(scopeConditions, " OR ") + ")"
allArgs := append(args, scopeArgs...)
if len(spathConditions) > 0 {
query += " AND (" + strings.Join(spathConditions, " OR ") + ")"
allArgs = append(allArgs, spathArgs...)
}
query, allArgs = appendNamePrefilter(query, allArgs, filter)
query, allArgs = appendNameExact(query, allArgs, filter, "i.")
return db.QueryContext(ctx, query, allArgs...)
}
query := `
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
`
args := []interface{}{}
whereClauses := []string{}
// Filter by sources using IN clause
if len(sources) == 1 {
whereClauses = append(whereClauses, "source = ?")
args = append(args, sources[0])
} else {
placeholders := make([]string, len(sources))
for i, source := range sources {
placeholders[i] = "?"
args = append(args, source)
}
whereClauses = append(whereClauses, "source IN ("+strings.Join(placeholders, ",")+")")
}
// Apply scope filters - need OR conditions for each source+scope combination
// Build conditions for each source: if it has a scope, filter by it; otherwise include all paths for that source
scopeConditions := []string{}
for _, source := range sources {
scope, hasScope := sourceScopes[source]
if hasScope && scope != "" {
// Source has a scope - filter by it
scopeConditions = append(scopeConditions, "(source = ? AND path GLOB ?)")
args = append(args, source, scope+"*")
} else {
// Source has no scope or empty scope - include all paths for this source
// The source IN clause already filters by source, so we just need to ensure
// this source is included in the OR condition (which it already is via source IN)
// We add a simple source check to make the OR condition work correctly
scopeConditions = append(scopeConditions, "source = ?")
args = append(args, source)
}
}
if len(scopeConditions) > 0 {
whereClauses = append(whereClauses, "("+strings.Join(scopeConditions, " OR ")+")")
}
if len(whereClauses) > 0 {
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd, caseExact)
}
if largest {
query += " ORDER BY size DESC"
}
return db.Query(query, args...)
WHERE ` + sourceClause
query += " AND (" + strings.Join(scopeConditions, " OR ") + ")"
allArgs := append(args, scopeArgs...)
query, allArgs = appendNameExact(query, allArgs, filter, "")
return db.QueryContext(ctx, query, allArgs...)
}
@@ -0,0 +1,231 @@
package sql
import (
"context"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func newSearchTestDB(t *testing.T, name string) *IndexDB {
t.Helper()
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
t.Cleanup(pop)
db, _, err := NewIndexDB(name, "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("NewIndexDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func insertSearchItem(t *testing.T, db *IndexDB, source, path, name string) {
t.Helper()
info := &iteminfo.FileInfo{
Path: path,
ItemInfo: iteminfo.ItemInfo{
Name: name,
Type: "text/plain",
ModTime: time.Unix(1, 0),
},
}
if err := db.InsertItem(source, path, info); err != nil {
t.Fatalf("InsertItem(%s): %v", path, err)
}
}
func runSearchItems(t *testing.T, db *IndexDB, source, scope string, filter *iteminfo.NameFilter) []string {
t.Helper()
rows, err := db.SearchItems(context.Background(), source, scope, false, filter)
if err != nil {
t.Fatalf("SearchItems: %v", err)
}
defer rows.Close()
var got []string
for rows.Next() {
var path, name string
var size, createdTime, modTime int64
var mimeType string
var isDir, hasPreview bool
if err := rows.Scan(&path, &name, &size, &createdTime, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, path)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
return got
}
func seedSearchItems(t *testing.T, db *IndexDB) {
t.Helper()
insertSearchItem(t, db, "s1", "/a/Report 2024.pdf", "Report 2024.pdf")
insertSearchItem(t, db, "s1", "/a/my_file.txt", "my_file.txt")
insertSearchItem(t, db, "s1", "/a/myXfile.txt", "myXfile.txt")
insertSearchItem(t, db, "s1", "/a/abc.txt", "abc.txt")
insertSearchItem(t, db, "s1", "/b/report.txt", "report.txt")
insertSearchItem(t, db, "s2", "/a/report-other.txt", "report-other.txt")
}
func TestSearchItemsFTSDefault(t *testing.T) {
db := newSearchTestDB(t, "search_fts_default")
if !db.FTSReady() {
t.Fatal("expected the FTS index to be ready")
}
seedSearchItems(t, db)
filter := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
if filter == nil || !filter.UseFTS {
t.Fatalf("expected an FTS filter, got %#v", filter)
}
got := runSearchItems(t, db, "s1", "/", filter)
want := []string{"/a/Report 2024.pdf", "/b/report.txt"}
if !sameSet(got, want) {
t.Fatalf("default search got %v, want %v", got, want)
}
// The LIKE prefilter treats '_' as a wildcard; the exact lower(name) GLOB must remove
// the false positive myXfile.txt.
filter = iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"my_file"}}, false, false)
got = runSearchItems(t, db, "s1", "/", filter)
want = []string{"/a/my_file.txt"}
if !sameSet(got, want) {
t.Fatalf("underscore search got %v, want %v", got, want)
}
// Scope is respected.
filter = iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
got = runSearchItems(t, db, "s1", "/b/", filter)
want = []string{"/b/report.txt"}
if !sameSet(got, want) {
t.Fatalf("scoped search got %v, want %v", got, want)
}
}
func TestSearchItemsCaseExactAndWildcard(t *testing.T) {
db := newSearchTestDB(t, "search_fts_modes")
seedSearchItems(t, db)
// case:exact is case-sensitive.
exact := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{
Terms: []string{"Report"},
Conditions: map[string]bool{"exact": true},
}, false, false)
got := runSearchItems(t, db, "s1", "/", exact)
want := []string{"/a/Report 2024.pdf"}
if !sameSet(got, want) {
t.Fatalf("case:exact got %v, want %v", got, want)
}
// Wildcard stays on the legacy (case-insensitive) path.
wild := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report*"}}, true, false)
if wild == nil || wild.UseFTS {
t.Fatalf("wildcard should use the legacy path, got %#v", wild)
}
got = runSearchItems(t, db, "s1", "/", wild)
want = []string{"/a/Report 2024.pdf", "/b/report.txt"}
if !sameSet(got, want) {
t.Fatalf("wildcard got %v, want %v", got, want)
}
// Too short for trigrams => legacy path, still correct.
short := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"ab"}}, false, false)
if short == nil || short.UseFTS {
t.Fatalf("short term should use the legacy path, got %#v", short)
}
got = runSearchItems(t, db, "s1", "/", short)
want = []string{"/a/abc.txt"}
if !sameSet(got, want) {
t.Fatalf("short search got %v, want %v", got, want)
}
}
func TestSearchItemsMultiSource(t *testing.T) {
db := newSearchTestDB(t, "search_fts_multi")
seedSearchItems(t, db)
filter := iteminfo.NameFilterForSearch(iteminfo.SearchOptions{Terms: []string{"report"}}, false, false)
rows, err := db.SearchItemsMultiSource(context.Background(),
[]string{"s1", "s2"}, map[string]string{"s1": "/", "s2": "/"}, false, filter)
if err != nil {
t.Fatalf("SearchItemsMultiSource: %v", err)
}
defer rows.Close()
var got []string
for rows.Next() {
var source, path, name string
var size, ct, mt int64
var mime string
var isDir, hasPreview bool
if err := rows.Scan(&source, &path, &name, &size, &ct, &mt, &mime, &isDir, &hasPreview); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, source+":"+path)
}
want := []string{"s1:/a/Report 2024.pdf", "s1:/b/report.txt", "s2:/a/report-other.txt"}
if !sameSet(got, want) {
t.Fatalf("multi-source got %v, want %v", got, want)
}
}
// TestSearchItemsQueryPlan guards the planner fix: the FTS table must drive the query.
func TestSearchItemsQueryPlan(t *testing.T) {
db := newSearchTestDB(t, "search_fts_plan")
seedSearchItems(t, db)
plan, err := db.ExplainQueryPlan(
`SELECT i.path FROM index_items_fts f CROSS JOIN index_items i ON i.rowid = f.rowid
WHERE i.source = ? AND i.path GLOB ? AND f.spath LIKE ? AND f.name LIKE ? AND lower(i.name) GLOB ?`,
"s1", "/%", "s1:/%", "%report%", "*report*",
)
if err != nil {
t.Fatalf("ExplainQueryPlan: %v", err)
}
if !strings.Contains(plan, "VIRTUAL TABLE") {
t.Fatalf("expected the FTS table to drive the query, plan:\n%s", plan)
}
if !strings.Contains(plan, "SEARCH i USING INTEGER PRIMARY KEY") {
t.Fatalf("expected a rowid lookup into index_items, plan:\n%s", plan)
}
}
func TestSearchItemsFTSHealthAfterDelete(t *testing.T) {
db := newSearchTestDB(t, "search_fts_health")
seedSearchItems(t, db)
if err := db.checkFTSHealth(); err != nil {
t.Fatalf("health before delete: %v", err)
}
if err := db.DeleteItem("s1", "/a/abc.txt", false); err != nil {
t.Fatalf("DeleteItem: %v", err)
}
if err := db.checkFTSHealth(); err != nil {
t.Fatalf("health after delete: %v", err)
}
// A name-changing upsert must also keep the index in sync.
insertSearchItem(t, db, "s1", "/a/abc.txt", "renamed.txt")
if err := db.checkFTSHealth(); err != nil {
t.Fatalf("health after re-insert: %v", err)
}
}
func sameSet(got, want []string) bool {
if len(got) != len(want) {
return false
}
set := make(map[string]int, len(got))
for _, g := range got {
set[g]++
}
for _, w := range want {
set[w]--
if set[w] < 0 {
return false
}
}
return true
}
+10 -6
View File
@@ -1,6 +1,7 @@
package sql
import (
"context"
"database/sql"
"fmt"
"os"
@@ -23,11 +24,7 @@ const (
)
func init() {
if SqliteDriver == "sqlite3" {
logger.Debugf("CGO SQLite driver initialized")
} else {
logger.Debugf("Default SQLite driver initialized")
}
logger.Debugf("SQLite driver initialized (pure Go)")
}
// TempDB manages a temporary SQLite database for operations that need
@@ -227,7 +224,6 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
}
// Open SQLite database with basic connection string.
// Driver is selected at compile time: "sqlite3" (CGO) or "sqlite" (pure Go).
// busy_timeout is set in the DSN so every pooled connection honors it.
db, err := sql.Open(SqliteDriver, sqlitebusy.WithBusyTimeout(tmpPath))
if err != nil {
@@ -405,6 +401,14 @@ func (t *TempDB) Query(query string, args ...interface{}) (*sql.Rows, error) {
return t.db.Query(query, args...)
}
// QueryContext executes a query that returns rows, honoring context cancellation so
// an abandoned request interrupts the running statement instead of holding the connection.
func (t *TempDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
t.mu.Lock()
defer t.mu.Unlock()
return t.db.QueryContext(ctx, query, args...)
}
// QueryRow executes a query that is expected to return at most one row.
// This is a convenience method that handles locking.
func (t *TempDB) QueryRow(query string, args ...interface{}) *sql.Row {
+57
View File
@@ -3,11 +3,32 @@ package sqldb
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
// settingAccessRulesLowercaseMigrated marks that access rule paths were normalized to lowercase.
const settingAccessRulesLowercaseMigrated = "access_rules_lowercase_migrated"
// AccessRulesLowercaseMigrated reports whether the one-time lowercase migration has completed.
func (s *SQLStore) AccessRulesLowercaseMigrated() (bool, error) {
_, err := s.GetSetting(settingAccessRulesLowercaseMigrated)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
// SetAccessRulesLowercaseMigrated records that the lowercase migration finished (empty DBs too).
func (s *SQLStore) SetAccessRulesLowercaseMigrated() error {
return s.SaveSetting(settingAccessRulesLowercaseMigrated, true)
}
// Access rules SQL operations
// GetAccessRulesBySource retrieves all access rules for a source
@@ -121,6 +142,42 @@ func (s *SQLStore) SaveAccessRule(source, path string, rule *access.AccessRule)
return nil
}
// MigrateAccessRulePathsLowercase rewrites rule paths to lowercase in one transaction.
func (s *SQLStore) MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules access.RuleMap) error {
tx, err := s.BeginTx()
if err != nil {
return fmt.Errorf("failed to begin access rule migration: %w", err)
}
rollback := func(cause error) error {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("%w (rollback also failed: %v)", cause, rbErr)
}
return cause
}
for path, rule := range newRules {
ruleDataJSON, err := json.Marshal(rule)
if err != nil {
return rollback(fmt.Errorf("failed to marshal rule data: %w", err))
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO access_rules (source, path, rule_data) VALUES (?, ?, ?)`, source, path, ruleDataJSON); err != nil {
return rollback(fmt.Errorf("failed to save access rule: %w", err))
}
}
for _, old := range oldPaths {
if _, kept := newRules[old]; kept {
continue
}
if _, err := tx.Exec(`DELETE FROM access_rules WHERE source = ? AND path = ?`, source, old); err != nil {
return rollback(fmt.Errorf("failed to delete access rule: %w", err))
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit access rule migration: %w", err)
}
return nil
}
// DeleteAccessRule deletes an access rule
func (s *SQLStore) DeleteAccessRule(source, path string) error {
query := `DELETE FROM access_rules WHERE source = ? AND path = ?`
@@ -0,0 +1,43 @@
package sqldb
import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
// The lowercase marker is set by StartupMigrateAccessRuleKeys after all
// sources migrate, not inside the per-source migration transaction.
func TestMigrateAccessRulePathsLowercaseRewritesPaths(t *testing.T) {
store, _, err := NewSQLStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
rule := &access.AccessRule{
Allow: access.RuleSet{Users: map[string]struct{}{"bob": {}}},
}
if err = store.SaveAccessRule("src", "/Old/", rule); err != nil {
t.Fatal(err)
}
newRules := access.RuleMap{
"/old/": rule,
}
if err = store.MigrateAccessRulePathsLowercase("src", []string{"/Old/"}, newRules); err != nil {
t.Fatal(err)
}
rules, err := store.GetAccessRulesBySource("src")
if err != nil {
t.Fatal(err)
}
if rules["/old/"] == nil {
t.Fatal("expected lowercased rule row")
}
if rules["/Old/"] != nil {
t.Fatal("expected old rule path to be deleted")
}
}
@@ -1,6 +1,3 @@
//go:build !cgosql
// +build !cgosql
package sqldb
import (
@@ -1,10 +0,0 @@
//go:build cgosql
// +build cgosql
package sqldb
import (
_ "github.com/mattn/go-sqlite3"
)
const SqliteDriver = "sqlite3"
+1 -1
View File
@@ -14,7 +14,7 @@ var ErrBusy = errors.New("sqlite database is busy or locked")
// WithBusyTimeout appends the busy_timeout connection parameter to dsn so that
// every connection in the pool honors the timeout. Both supported drivers
// (mattn/go-sqlite3 and modernc.org/sqlite) apply the parameter at
// (modernc.org/sqlite) applies the parameter at
// connection-open time, unlike a one-off PRAGMA which only configures the
// single connection that database/sql happens to pick.
func WithBusyTimeout(dsn string) string {
@@ -1,23 +0,0 @@
//go:build cgosql
// +build cgosql
package sqlitebusy
import (
"errors"
sqlite3 "github.com/mattn/go-sqlite3"
)
const testDriver = "sqlite3"
// driverErrorCode returns the driver's extended result code for err, so tests
// can assert which busy/locked variant was produced. mattn reports the
// extended code in Error.ExtendedCode.
func driverErrorCode(err error) int {
var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) {
return int(sqliteErr.ExtendedCode)
}
return -1
}
@@ -1,6 +1,3 @@
//go:build !cgosql
// +build !cgosql
package sqlitebusy
import (
@@ -1,6 +1,3 @@
//go:build !cgosql
// +build !cgosql
package sqlitebusy
import (
@@ -1,21 +0,0 @@
//go:build cgosql
// +build cgosql
package sqlitebusy
import (
"errors"
sqlite3 "github.com/mattn/go-sqlite3"
)
// isBusyOrLocked reports whether err is a SQLITE_BUSY or SQLITE_LOCKED error
// from the CGO (mattn/go-sqlite3) driver. Error.Code is already the primary
// result code; extended codes are reported separately in Error.ExtendedCode.
func isBusyOrLocked(err error) bool {
var sqliteErr sqlite3.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code == sqlite3.ErrBusy || sqliteErr.Code == sqlite3.ErrLocked
}
@@ -0,0 +1,73 @@
package users
import (
"encoding/json"
"strings"
)
// GlobalPermissionsFieldMask records which global permission JSON keys were present
// on a create-user request (admin, api, share, realtime). Omitted keys should receive
// userDefaults; present keys keep the caller's value, including explicit false.
type GlobalPermissionsFieldMask struct {
Admin bool
Api bool
Share bool
Realtime bool
}
// Any reports whether at least one global permission key was explicitly present.
func (m GlobalPermissionsFieldMask) Any() bool {
return m.Admin || m.Api || m.Share || m.Realtime
}
// GlobalPermissionsFieldMaskFromJSON builds a mask from a permissions object JSON value.
// A nil or JSON null value yields an empty mask (no keys explicitly set).
func GlobalPermissionsFieldMaskFromJSON(raw json.RawMessage) GlobalPermissionsFieldMask {
if len(raw) == 0 || string(raw) == "null" {
return GlobalPermissionsFieldMask{}
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return GlobalPermissionsFieldMask{}
}
var mask GlobalPermissionsFieldMask
for key := range obj {
switch strings.ToLower(key) {
case "admin":
mask.Admin = true
case "api":
mask.Api = true
case "share":
mask.Share = true
case "realtime":
mask.Realtime = true
}
}
return mask
}
// MergeGlobalPermissionsOnCreate combines defaults with caller-supplied global permissions.
// When mask is empty, any true global flag on requested preserves the full requested struct
// (programmatic create paths). Otherwise only masked fields override defaults.
func MergeGlobalPermissionsOnCreate(defaults, requested Permissions, mask GlobalPermissionsFieldMask) Permissions {
if !mask.Any() {
if requested.Admin || requested.Api || requested.Share || requested.Realtime {
return requested
}
return defaults
}
out := defaults
if mask.Admin {
out.Admin = requested.Admin
}
if mask.Api {
out.Api = requested.Api
}
if mask.Share {
out.Share = requested.Share
}
if mask.Realtime {
out.Realtime = requested.Realtime
}
return out
}
@@ -0,0 +1,68 @@
package users
import (
"encoding/json"
"testing"
)
func TestGlobalPermissionsFieldMaskFromJSON(t *testing.T) {
tests := []struct {
name string
raw string
want GlobalPermissionsFieldMask
}{
{name: "empty object", raw: `{}`, want: GlobalPermissionsFieldMask{}},
{name: "null", raw: `null`, want: GlobalPermissionsFieldMask{}},
{name: "admin only", raw: `{"admin":true}`, want: GlobalPermissionsFieldMask{Admin: true}},
{name: "partial mixed case", raw: `{"Share":false,"api":true}`, want: GlobalPermissionsFieldMask{Api: true, Share: true}},
{name: "explicit false still present", raw: `{"share":false}`, want: GlobalPermissionsFieldMask{Share: true}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := GlobalPermissionsFieldMaskFromJSON(json.RawMessage(tc.raw))
if got != tc.want {
t.Fatalf("got %+v, want %+v", got, tc.want)
}
})
}
}
func TestMergeGlobalPermissionsOnCreate(t *testing.T) {
defaults := Permissions{Admin: false, Api: true, Share: true, Realtime: true}
t.Run("omitted permissions keep defaults", func(t *testing.T) {
requested := Permissions{}
got := MergeGlobalPermissionsOnCreate(defaults, requested, GlobalPermissionsFieldMask{})
if got != defaults {
t.Fatalf("got %+v, want defaults %+v", got, defaults)
}
})
t.Run("partial mask merges only present keys", func(t *testing.T) {
requested := Permissions{Admin: true, Api: false}
mask := GlobalPermissionsFieldMask{Admin: true, Api: true}
got := MergeGlobalPermissionsOnCreate(defaults, requested, mask)
want := Permissions{Admin: true, Api: false, Share: true, Realtime: true}
if got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
})
t.Run("explicit false on masked field", func(t *testing.T) {
requested := Permissions{Share: false}
mask := GlobalPermissionsFieldMask{Share: true}
got := MergeGlobalPermissionsOnCreate(defaults, requested, mask)
want := Permissions{Admin: false, Api: true, Share: false, Realtime: true}
if got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
})
t.Run("programmatic all true without mask", func(t *testing.T) {
requested := Permissions{Admin: true, Api: true, Share: true, Realtime: true}
got := MergeGlobalPermissionsOnCreate(defaults, requested, GlobalPermissionsFieldMask{})
if got != requested {
t.Fatalf("got %+v, want %+v", got, requested)
}
})
}
@@ -1,6 +1,68 @@
package users
import "testing"
import (
"encoding/json"
"testing"
)
func TestPermissionsUnmarshalJSONMarksConfigured(t *testing.T) {
t.Parallel()
var p Permissions
if err := json.Unmarshal([]byte(`{"admin":false,"share":false,"api":false,"realtime":false}`), &p); err != nil {
t.Fatal(err)
}
if !p.Configured {
t.Fatal("expected Configured after unmarshaling an explicit permissions object")
}
if p.Admin || p.Share || p.Api || p.Realtime {
t.Fatalf("expected all-false permissions, got %+v", p)
}
var u struct {
Permissions Permissions `json:"permissions"`
}
if err := json.Unmarshal([]byte(`{}`), &u); err != nil {
t.Fatal(err)
}
if u.Permissions.Configured {
t.Fatal("omitted permissions must not set Configured")
}
var nullWrap struct {
Permissions Permissions `json:"permissions"`
}
if err := json.Unmarshal([]byte(`{"permissions":null}`), &nullWrap); err != nil {
t.Fatal(err)
}
if nullWrap.Permissions.Configured {
t.Fatal("permissions null must not set Configured")
}
if nullWrap.Permissions.GlobalPermissionsFieldMaskFromPermissions().Any() {
t.Fatal("permissions null must not set global key mask")
}
}
func TestPermissionsUnmarshalJSONPartialKeys(t *testing.T) {
t.Parallel()
var u struct {
Permissions Permissions `json:"permissions"`
}
if err := json.Unmarshal([]byte(`{"permissions":{"admin":true}}`), &u); err != nil {
t.Fatal(err)
}
if !u.Permissions.Configured {
t.Fatal("expected Configured for explicit permissions object")
}
mask := u.Permissions.GlobalPermissionsFieldMaskFromPermissions()
if !mask.Admin || mask.Api || mask.Share || mask.Realtime {
t.Fatalf("mask = %+v, want admin only", mask)
}
if !u.Permissions.Admin {
t.Fatal("expected admin true")
}
}
func TestSourceFilePermissionsIsUnset(t *testing.T) {
t.Parallel()
@@ -24,7 +24,11 @@ func HasAnyGlobalPermission(p Permissions) bool {
}
// IsMinimalApiToken reports whether the token inherits the owner's globals at auth time
// rather than embedding its own global permission caps in the JWT.
// rather than applying stored global permission caps.
func IsMinimalApiToken(t AuthToken) bool {
if t.Minimal != nil {
return *t.Minimal
}
// Legacy tokens lack minimal; empty stored global caps implied minimal (WebDAV-style).
return !HasAnyGlobalPermission(t.Permissions)
}
Loaded 100 of 644 files, more files were not shown because too many files have changed in this diff. Show more