mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 08:42:06 -05:00
Compare commits
47
Commits
dev/v2.1.4
...
dev/v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
609163735f | ||
|
|
7fb372623a | ||
|
|
10b3c985b6 | ||
|
|
a6833724da | ||
|
|
b383221a20 | ||
|
|
e98aa6fc25 | ||
|
|
e0730e8c7a | ||
|
|
435ded56c6 | ||
|
|
6f54f77d64 | ||
|
|
77a70cde6b | ||
|
|
77f5e2fe36 | ||
|
|
9766eee0d0 | ||
|
|
fdb107e2bb | ||
|
|
f9dddb117c | ||
|
|
e17f544920 | ||
|
|
c6cc8d56b4 | ||
|
|
802bcd8911 | ||
|
|
db13271bf3 | ||
|
|
bb8c5f9aa8 | ||
|
|
968ce8d066 | ||
|
|
356013a137 | ||
|
|
7c68c38479 | ||
|
|
dcca322f48 | ||
|
|
4a6e8edc28 | ||
|
|
d9c91e1125 | ||
|
|
4cdf1e71e4 | ||
|
|
9e6c2cd0a3 | ||
|
|
c102fe7556 | ||
|
|
d0591b9de4 | ||
|
|
ebe47ce643 | ||
|
|
fbbf40f877 | ||
|
|
1508d9ff21 | ||
|
|
0e318d6eaa | ||
|
|
fd64a470d3 | ||
|
+5 |
751cc56809 | ||
|
|
c5112cd51c | ||
|
|
90354e468d | ||
|
|
c35e4f33c8 | ||
|
|
474cd90e74 | ||
|
|
a483504426 | ||
|
|
36dba16fd6 | ||
|
|
5f3049ebc6 | ||
|
|
74923fbe1b | ||
|
|
59bfdbcb54 | ||
|
|
b079dd19e7 | ||
|
|
b3cbd0925e | ||
|
|
9753cfbf8b |
No files matched your search
@@ -9,7 +9,6 @@ backend/filebrowser.sqlite*
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
backend/internal/web/dist/**
|
||||
backend/vendor/**
|
||||
|
||||
frontend/dist/**
|
||||
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+70
-90
@@ -15,16 +15,20 @@ 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
|
||||
- uses: goto-bus-stop/setup-zig@v2
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -68,50 +72,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 +87,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 +180,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 +195,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
@@ -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
|
||||
|
||||
|
||||
@@ -2,6 +2,102 @@
|
||||
|
||||
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.
|
||||
- JXL image preview support (#3334) (#2551)
|
||||
- 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
|
||||
- Document enhancements
|
||||
- Official Collabora support using WOPI config.
|
||||
- Document previews use a pure-go in-process package (all versions get it now).
|
||||
- Added **Always use office for document previews** option to force OnlyOffice/Collabora.
|
||||
- Office previews can use embedded thumbnail if possible.
|
||||
- Text file previews are more efficient direct creation.
|
||||
- New text preview API (`/api/resources/preview/text`) returns a 16KB text preview for text, Markdown, HTML, CSS, code and RTF files. Markdown and HTML render in the listing as sanitized previews. Previews are not cached on disk. SVG files are served directly as images.
|
||||
- Embedded previews for Krita (`.kra`, [#2431](https://github.com/gtsteffaniak/filebrowser/issues/2431)), OpenRaster (`.ora`), Sketch (`.sketch`), MuseScore (`.mscz`), CBZ (first page), XPS (embedded thumbnail, when present), ODF (`.odt`, `.ods`, `.odp`, `.odg`), and Office Open XML (`.docx`, `.xlsx`, `.pptx`) thumbnails.
|
||||
|
||||
**Notes**:
|
||||
- **Document preview formats:** New in-process support includes **`.azw3`**, **`.chm`**, **`.htm`**, and **`.html`**. However, `.doc` continues to require the office integration for thumbnails. **`.xps`** and **`.cbz`** previews come from embedded images. With **Always use office for document previews** enabled, office files still use the office converter
|
||||
- UPX compression is removed from release builds.
|
||||
- A `filebrowser-media` image is published alongside the main and slim images.
|
||||
- Previews (thumbnails, popup, and text previews) are no longer requested for sources the user does not have view access to.
|
||||
|
||||
**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**:
|
||||
|
||||
+1
-4
@@ -16,10 +16,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
- macOS: `brew install ffmpeg`
|
||||
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
|
||||
- **mupdf-tools**: For PDF preview generation
|
||||
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
|
||||
- macOS: `brew install mupdf-tools`
|
||||
- Windows: Download from [mupdf.com](https://mupdf.com/downloads/)
|
||||
Document thumbnails (PDF, EPUB, Office Open XML, and similar) are generated in-process via [folio](https://github.com/gen2brain/folio). An optional office integration is used as a fallback for formats folio does not handle and for in-browser editing.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
+8
-7
@@ -1,24 +1,25 @@
|
||||
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
|
||||
ENV CGO_ENABLED=1
|
||||
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
ENV CGO_ENABLED=0
|
||||
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 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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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 ./
|
||||
ENV CGO_ENABLED=0
|
||||
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" ]
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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 ./
|
||||
ENV CGO_ENABLED=0
|
||||
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 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" ]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ]
|
||||
@@ -20,3 +20,7 @@ userDefaults:
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -51,3 +51,7 @@ userDefaults:
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -64,4 +64,7 @@ userDefaults:
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
highQuality: true
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -40,3 +40,7 @@ userDefaults:
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -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
|
||||
@@ -48,4 +48,7 @@ auth:
|
||||
|
||||
userDefaults:
|
||||
listing:
|
||||
showHidden: true
|
||||
showHidden: true
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
@@ -27,4 +27,7 @@ frontend:
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
disableEventThemes: true
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
+1
-1
@@ -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"]
|
||||
poll = true
|
||||
poll_interval = 1000
|
||||
log = "air.log"
|
||||
|
||||
@@ -17,7 +17,7 @@ linters:
|
||||
exhaustive:
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
- mapliteral
|
||||
exhaustruct:
|
||||
exclude:
|
||||
- ^net/http.Client$
|
||||
|
||||
+39
-115
@@ -5,137 +5,61 @@ version: 2
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- go run ./scripts/setup-gofitz-cgo
|
||||
|
||||
builds:
|
||||
# Build configuration for linux amd64
|
||||
- id: linux-amd64
|
||||
- id: filebrowser
|
||||
main: main.go
|
||||
# Flat dist layout without GOAMD64/GOARM64/GORISCV64 suffix folders (e.g. _v1, _v8.0).
|
||||
no_unique_dist_dir: true
|
||||
binary: >-
|
||||
{{ .Os }}-{{ .Arch }}{{ if eq .Arch "arm" }}-{{ .Arm }}{{ end }}/filebrowser
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target x86_64-linux-musl
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
tags:
|
||||
- analytics
|
||||
- mupdf
|
||||
- musl
|
||||
ldflags: &ldflags
|
||||
ldflags:
|
||||
- -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
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux arm64
|
||||
- id: linux-arm64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target aarch64-linux-musl
|
||||
tags:
|
||||
- analytics
|
||||
- mupdf
|
||||
- musl
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux riscv64
|
||||
- id: linux-riscv64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
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
|
||||
tags:
|
||||
- analytics
|
||||
- musl
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- riscv64
|
||||
|
||||
# Build configuration for linux
|
||||
- id: linux-arm32
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for windows without arm
|
||||
- id: windows
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
|
||||
- CC=zig cc -target x86_64-windows-gnu -lc
|
||||
tags:
|
||||
- analytics
|
||||
- mupdf
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for freebsd without arm & upx
|
||||
- id: freeBSD and freebsd-arm
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
|
||||
- id: macos
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
- amd64
|
||||
- arm
|
||||
- riscv64
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: riscv64
|
||||
|
||||
archives:
|
||||
- name_template: >
|
||||
{{- if eq .Os "windows" -}}
|
||||
{{.ProjectName}}
|
||||
{{.ProjectName}}{{ if ne .Arch "amd64" }}-{{ .Arch }}{{ end }}
|
||||
{{- else if eq .Arch "arm" -}}
|
||||
{{.Os}}-arm-{{ .Arm }}-{{.ProjectName}}
|
||||
{{- else -}}
|
||||
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
|
||||
{{.Os}}-{{.Arch}}-{{.ProjectName}}
|
||||
{{- end -}}
|
||||
formats: [ 'binary' ]
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
formats: ["tar.gz"]
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: ["zip"]
|
||||
+23
-23
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -210,8 +217,6 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
|
||||
@@ -62,3 +62,7 @@ userDefaults:
|
||||
#office:
|
||||
# url: "http://localhost:9052"
|
||||
# secret: "6H1H7GzUk2alRkNxAPav0W02g8JS5oUd"
|
||||
|
||||
integrations:
|
||||
media:
|
||||
skipHwCheck: true
|
||||
Binary file not shown.
@@ -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"
|
||||
]
|
||||
}
|
||||
+29
-28
@@ -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,8 @@ 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/gen2brain/folio v0.2.8
|
||||
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 +19,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-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.2.0
|
||||
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/fxamacker/cbor/v2 v2.9.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||
github.com/gen2brain/gav1d v0.2.6 // indirect
|
||||
github.com/gen2brain/h265 v0.2.4 // indirect
|
||||
github.com/gen2brain/jxl v0.2.0 // 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 +80,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 v1.0.0 // 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
|
||||
)
|
||||
+77
-54
@@ -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,22 @@ 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/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/folio v0.2.8 h1:9iDI7h+zt4mjwEnzm1x8Pl9BrWqLwUwB74X/fnc1PZs=
|
||||
github.com/gen2brain/folio v0.2.8/go.mod h1:4/PcXfaXIYmW1nwQ4bJeE+aG5ybG58Aw9xrwODXaXII=
|
||||
github.com/gen2brain/gav1d v0.2.6 h1:7TSkrWTdE22k5Ul7vYZML7Nu80gNW6OovV7FSCITSE0=
|
||||
github.com/gen2brain/gav1d v0.2.6/go.mod h1:ReFKjHq7pvRhEk+pgRPAo9gOlcrf51sdzVcAbbX7e0E=
|
||||
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/jxl v0.2.0 h1:cXSQnfbcC0JGFzaI344VzPMbe7mh6rYc0tjPcBUsE4U=
|
||||
github.com/gen2brain/jxl v0.2.0/go.mod h1:VjJHRai/8I8Es01mULqyiOihRBOjJr1Zo+NoN/MyIwo=
|
||||
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 +78,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 +117,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 +154,21 @@ 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 v1.0.0 h1:CILp67bcOtiDXvnrQubnH9ctub0OSKnUNA2JJuoIP+c=
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0/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-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.2.0 h1:lbNL/G86uzzH8NaT45DBHKm1r0P7yndIo9pNC7gdqQs=
|
||||
github.com/gtsteffaniak/go-media v1.2.0/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 h1:9lSI8yoz6omDChaeV0AJrhdfe2YTrWvJrDENI/bnkAM=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1/go.mod h1:tRCdDZ15oZZTsiwmQAwyH56PTt1jn1C1O0P03oOkoJ8=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
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 +193,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 +208,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 +232,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 +254,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 +271,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 +290,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 +320,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=
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
-3
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¬Null, &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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -22,14 +23,6 @@ const (
|
||||
defaultCacheSizePages = -4096 // ~16MB
|
||||
)
|
||||
|
||||
func init() {
|
||||
if SqliteDriver == "sqlite3" {
|
||||
logger.Debugf("CGO SQLite driver initialized")
|
||||
} else {
|
||||
logger.Debugf("Default SQLite driver initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// TempDB manages a temporary SQLite database for operations that need
|
||||
// to stream large datasets without loading everything into memory.
|
||||
// This can be used by any part of the codebase that needs temporary SQLite storage.
|
||||
@@ -227,7 +220,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 +397,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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
-3
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
-3
@@ -1,6 +1,3 @@
|
||||
//go:build !cgosql
|
||||
// +build !cgosql
|
||||
|
||||
package sqlitebusy
|
||||
|
||||
import (
|
||||
-3
@@ -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()
|
||||
|
||||
Loaded 100 of 627 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user