Compare commits

...
35 changed files with 704 additions and 346 deletions

No files matched your search

-22
View File
@@ -60,25 +60,3 @@ jobs:
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
- name: Modify version names
id: modify-json-media
run: |
TAGS="${{ steps.meta.outputs.tags }}"
# Apply modifications to both docker.io and ghcr.io tags
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev-media/g')
# Add dev tag to both registries
CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev-media,ghcr.io/gtsteffaniak/filebrowser:dev-media"
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and push media image
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
file: ./_docker/Dockerfile.media
push: true
tags: ${{ steps.modify-json-media.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
-40
View File
@@ -96,43 +96,3 @@ jobs:
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
- name: Modify version names
id: modify-json-media
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(
sub(":v"; ":") + "-media"
) |
if any(.tags[]; test("beta-media")) then
.tags += [
"gtstef/filebrowser:beta-media",
"ghcr.io/gtsteffaniak/filebrowser:beta-media",
"gtstef/filebrowser:media",
"ghcr.io/gtsteffaniak/filebrowser:media"
]
else . end |
if any(.tags[]; test("stable-media")) then
.tags += [
"gtstef/filebrowser:stable-media",
"ghcr.io/gtsteffaniak/filebrowser:stable-media",
"gtstef/filebrowser:media",
"ghcr.io/gtsteffaniak/filebrowser:media"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push media image
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
file: ./_docker/Dockerfile.media
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.modify-json-media.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+22
View File
@@ -2,6 +2,28 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.7.1-beta
The `media` tags introduced in 0.7.0 have been removed -- all docker images have media enabled now.
**Notes**:
- changes to support jwks url needed for authelia - still needs testing to ensure it works https://github.com/gtsteffaniak/filebrowser/issues/575, added debug logs to help identify any further issues.
- added apache license file back https://github.com/gtsteffaniak/filebrowser/discussions/599
- updated toggle view icons to better match.
- adjusted popup preview position on mobile.
- updated createUserDir logic, https://github.com/gtsteffaniak/filebrowser/issues/541
- it always creats user dir (even for admins)
- scope path must exist if it doesn't end in username, and if it does, the parent dir must exist
- enforced user login methods types -- can't be changed. a password user cannot login as oidc, etc.
**Bug Fixes**:
- right click context menu issue https://github.com/gtsteffaniak/filebrowser/issues/598
- upload file issue https://github.com/gtsteffaniak/filebrowser/issues/597
- defaultUserScope is not respected https://github.com/gtsteffaniak/filebrowser/issues/589
- defaultEnabled is not respected https://github.com/gtsteffaniak/filebrowser/issues/603
- user has weird navigation barhttps://github.com/gtsteffaniak/filebrowser/issues/593
- fix multibutton state issue for close overlay https://github.com/gtsteffaniak/filebrowser/issues/596
## v0.7.0-beta
**New Features**:
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 File Browser contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+7 -4
View File
@@ -111,8 +111,8 @@ webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
Docker image size | 200 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -123,13 +123,13 @@ Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
@@ -146,3 +146,6 @@ Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
+27 -1
View File
@@ -18,14 +18,40 @@ COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
RUN apk add --no-cache curl xz && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;; \
aarch64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ;; \
armv7l) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
mkdir -p /tmp/ffmpeg && \
curl -L "$URL" | tar -xJ -C /tmp/ffmpeg && \
mv /tmp/ffmpeg/ffmpeg-*/ffmpeg /usr/local/bin/ && \
mv /tmp/ffmpeg/ffmpeg-*/ffprobe /usr/local/bin/ && \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
rm -rf /tmp/ffmpeg && \
apk del curl xz && \
rm -rf /var/cache/apk/*
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=base --chown=filebrowser:1000 /app/config.yaml ./
COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
-35
View File
@@ -1,35 +0,0 @@
FROM mwader/static-ffmpeg AS ffmpeg
FROM golang:1.24-alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
#RUN swag init --output swagger/docs
RUN ln -s swagger /usr/local/go/src/
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
RUN apk --no-cache add ca-certificates mailcap
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
USER root
# exposing default port for auto discovery.
EXPOSE 80
ENTRYPOINT [ "./filebrowser" ]
+1 -1
View File
@@ -17,8 +17,8 @@ import (
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
+30 -14
View File
@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -21,28 +20,45 @@ func MakeUserDir(fullPath string) error {
func MakeUserDirs(u *users.User) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", u.Username))
return fmt.Errorf("create user: invalid user for home dir creation: [%s]", u.Username)
}
for i, scope := range u.Scopes {
source := settings.Config.Server.SourceMap[scope.Name]
if source.Config.CreateUserDir {
if !source.Config.CreateUserDir {
continue
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
return fmt.Errorf("create user: source not found: %s", scope.Name)
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// validate that scope path exists
if !Exists(parentDir) {
return fmt.Errorf("create user: scope path does not exist: %s", scope.Scope)
}
fullPath := ""
if filepath.Base(scope.Scope) != cleanedUserName && !u.Permissions.Admin {
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
fullPath = filepath.Join(source.Path, scope.Scope)
} else {
fullPath = filepath.Join(source.Path, scope.Scope, cleanedUserName)
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
return fmt.Errorf("create user: scope folder does not exist: %s", parentDir)
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
// update scope to reflect new user home dir
u.Scopes[i] = scope
} else {
// just assigning scope to path provided, so just check that it exists
parentDir := filepath.Join(source.Path, scope.Scope)
if !Exists(parentDir) {
return fmt.Errorf("create user: parent folder does not exist: %s", parentDir)
}
}
u.Scopes[i] = scope
}
return nil
}
+20 -28
View File
@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
@@ -58,25 +59,16 @@ func updateUserScopes(user *users.User) bool {
// Step 1: Start by including all existing scopes, updating Name if source.Path matches
for _, existingScope := range user.Scopes {
updated := false
for _, source := range settings.Config.Server.SourceMap {
if existingScope.Name == source.Path || existingScope.Scope == source.Path {
// Update Name to match source.Path
existingScope.Name = source.Path
updated = true
break
}
if existingScope.Scope != "/" {
existingScope.Scope = strings.TrimSuffix(existingScope.Scope, "/")
}
if existingScope.Scope == "" {
existingScope.Scope = "/"
updateUser = true
continue
}
if !seenNames[existingScope.Name] {
finalScopes = append(finalScopes, existingScope)
seenNames[existingScope.Name] = true
if updated {
updateUser = true
}
}
}
@@ -85,24 +77,24 @@ func updateUserScopes(user *users.User) bool {
if seenNames[source.Path] {
continue
}
// Check if scope exists already via path
scopePath, err := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
// If user has no access, skip unless admin or default-enabled
if err != nil && !(user.Permissions.Admin || source.Config.DefaultEnabled) {
continue
}
// Determine scope path
scopePath, _ := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
if scopePath == "" {
scopePath = source.Config.DefaultUserScope
}
if scopePath == "" {
scopePath = "/"
}
if source.Config.CreateUserDir && !user.Permissions.Admin {
scopePath = fmt.Sprintf("%s%s", scopePath, users.CleanUsername(user.Username))
// must be new scope for user
// If user has no access, skip unless default-enabled
if !source.Config.DefaultEnabled {
continue
}
// Determine scope path
if scopePath == "" {
scopePath = source.Config.DefaultUserScope
}
if scopePath == "" {
scopePath = "/"
}
if scopePath != "/" {
scopePath = strings.TrimSuffix(scopePath, "/")
}
}
// Add new scope
@@ -8,8 +8,9 @@ import (
var (
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
RealPath = NewCache(48*time.Hour, 72*time.Hour)
SearchResults = NewCache(15*time.Second, time.Hour)
SearchResults = NewCache(15*time.Second, 1*time.Hour)
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
JwtCache = NewCache(1*time.Hour, 72*time.Hour)
)
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
+30 -14
View File
@@ -87,6 +87,9 @@ func setupSources() {
Config.Server.DefaultSource = source
}
}
if source.Config.DefaultUserScope == "" {
source.Config.DefaultUserScope = "/"
}
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
}
@@ -96,20 +99,20 @@ func setupSources() {
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
first := true
potentialDefaultSource := Config.Server.DefaultSource
for _, sourcePathOnly := range Config.Server.Sources {
realPath := getRealPath(sourcePathOnly.Path)
source, ok := Config.Server.SourceMap[realPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
if first {
source.Config.DefaultEnabled = true
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
Config.Server.DefaultSource = source
potentialDefaultSource = source
}
first = false
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
Config.Server.DefaultSource = source
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = source
}
defaultScopes = append(defaultScopes, users.SourceScope{
Name: source.Path,
Scope: source.Config.DefaultUserScope,
@@ -120,6 +123,9 @@ func setupSources() {
logger.Warning(fmt.Sprintf("source %v is not configured correctly, skipping", sourcePathOnly.Path))
}
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = potentialDefaultSource
}
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
}
@@ -291,18 +297,19 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
}
newScopes := []users.SourceScope{}
for _, scope := range scopes {
if scope.Scope == "" {
scope.Scope = "/"
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if !strings.HasSuffix(scope.Scope, "/") {
scope.Scope = scope.Scope + "/"
}
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
@@ -316,6 +323,15 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
// source might no longer be configured
continue
}
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
+7 -5
View File
@@ -54,11 +54,13 @@ func ApplyUserDefaults(u *users.User) {
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
if len(u.Scopes) == 0 {
u.Scopes = []users.SourceScope{
{
Scope: Config.Server.DefaultSource.Config.DefaultUserScope,
Name: Config.Server.DefaultSource.Path,
},
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: source.Config.DefaultUserScope,
})
}
}
}
}
+4 -12
View File
@@ -83,12 +83,10 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
if !actorIsAdmin {
fmt.Println("User is not admin, checking restricted fields")
err := checkRestrictedFields(existingUser, fields)
if err != nil {
return err
}
fmt.Println("existingUser, fields", existingUser, fields)
}
if len(fields) == 0 {
@@ -102,6 +100,10 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user)
if err != nil {
return err
}
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
@@ -157,7 +159,6 @@ func (st usersBackend) Save(user *users.User, changePass bool) error {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user)
if err != nil {
logger.Error(err.Error())
@@ -186,7 +187,6 @@ func (st usersBackend) DeleteByUsername(username string) error {
func checkRestrictedFields(existingUser *users.User, fields []string) error {
// Get a list of allowed fields from NonAdminEditable
allowed := getNonAdminEditableFieldNames()
for _, field := range fields {
if !slices.Contains(allowed, field) {
return fmt.Errorf("non-admins cannot modify field: %s", field)
@@ -232,14 +232,6 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
if !actorIsAdmin {
continue
}
newScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err == nil {
user.Scopes = newScopes
}
err = files.MakeUserDirs(user)
if err != nil {
logger.Error(err.Error())
}
}
if capitalField == "Password" {
if user.LoginMethod != users.LoginMethodPassword {
-3
View File
@@ -114,9 +114,6 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
if asAdmin {
userInfo.Permissions = settings.AdminPerms()
}
if len(userInfo.Scopes) == 0 {
userInfo.Scopes = settings.Config.UserDefaults.DefaultScopes
}
// create new home directories
err := store.Users.Save(newUser, true)
if err != nil {
-12
View File
@@ -1,17 +1,11 @@
package users
import (
"regexp"
"strings"
jwt "github.com/golang-jwt/jwt/v4"
)
var (
invalidFilenameChars = regexp.MustCompile(`[^0-9A-Za-z@_\-.]`)
dashes = regexp.MustCompile(`[\-]+`)
)
const ()
type LoginMethod string
@@ -106,11 +100,5 @@ func CleanUsername(s string) string {
// Remove any trailing space to avoid ending on -
s = strings.Trim(s, " ")
s = strings.Replace(s, "..", "", -1)
// Replace all characters which not in the list `0-9A-Za-z@_\-.` with a dash
s = invalidFilenameChars.ReplaceAllString(s, "-")
// Remove any multiple dashes caused by replacements above
s = dashes.ReplaceAllString(s, "-")
return s
}
+20 -8
View File
@@ -15,9 +15,9 @@ require (
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.37.0
golang.org/x/image v0.26.0
golang.org/x/sys v0.32.0
golang.org/x/crypto v0.38.0
golang.org/x/image v0.27.0
golang.org/x/sys v0.33.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -61,6 +61,7 @@ require (
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
@@ -91,8 +92,9 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/geo v0.0.0-20250502184219-cc00c7679ab0 // indirect
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
@@ -131,6 +133,14 @@ require (
github.com/ldez/usetesting v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx v1.2.31 // indirect
github.com/lestrrat-go/jwx/v3 v3.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
@@ -152,6 +162,7 @@ require (
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
@@ -175,6 +186,7 @@ require (
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.2 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect
@@ -216,10 +228,10 @@ require (
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.32.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+42 -16
View File
@@ -140,6 +140,8 @@ github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
@@ -253,6 +255,8 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
@@ -263,8 +267,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250502184219-cc00c7679ab0 h1:k2jSh+pAqi/RtvCtyVyTCcybi657OWJXHUytu1nRjB0=
github.com/golang/geo v0.0.0-20250502184219-cc00c7679ab0/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA=
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db h1:juWRoz5Y+/j2ffgMV0yPnZD5sd1EkeJOsrnMMyLIFrM=
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -434,6 +438,25 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54=
github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk=
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
@@ -566,6 +589,8 @@ github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxX
github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8=
github.com/securego/gosec/v2 v2.22.2 h1:IXbuI7cJninj0nRpZSLCUlotsj8jGusohfONMrHoF6g=
github.com/securego/gosec/v2 v2.22.2/go.mod h1:UEBGA+dSKb+VqM6TdehR7lnQtIIMorYJ4/9CW1KVQBE=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -611,6 +636,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -705,8 +731,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -726,8 +752,8 @@ golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -801,8 +827,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -824,8 +850,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -884,8 +910,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -906,8 +932,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -967,8 +993,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+237 -39
View File
@@ -12,8 +12,10 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"github.com/lestrrat-go/jwx/v3/jwk"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -99,6 +101,9 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
return nil, fmt.Errorf("proxy authentication failed - no user found")
}
}
if data.user.LoginMethod != users.LoginMethodProxy {
return nil, fmt.Errorf("user %s is not allowed to login with proxy authentication", proxyUser)
}
return data.user, nil
}
@@ -145,6 +150,10 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
if user.LoginMethod != users.LoginMethodPassword {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
@@ -157,14 +166,6 @@ type signupBody struct {
Password string `json:"password"`
}
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
}
// signupHandler registers a new user account.
// @Summary User signup
// @Description Register a new user account with a username and password.
@@ -308,17 +309,61 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
return 200, nil
}
// userInfo struct to hold user claims from either UserInfo or ID token
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
}
// getKeySet fetches the JWKS from the given URL, using the provided cache.
func getKeySet(jwksUrl string) (jwk.Set, error) {
// Check cache first
value := cache.JwtCache.Get(jwksUrl)
jwks, ok := value.(jwk.Set)
if ok {
return jwks, nil
}
logger.Debug("JWKS not found in cache for, fetching...")
// Fetch the JWKS from the URL
resp, err := http.Get(jwksUrl)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS from %s: %v", jwksUrl, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to fetch JWKS from %s: received status code %d with body: %s", jwksUrl, resp.StatusCode, string(bodyBytes))
}
// Decode the JWKS using jwk.ParseReader
jwks, err = jwk.ParseReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to decode JWKS: %v", err)
}
// Store in cache. Assuming your cache handles expiration (e.g., 1 hour).
cache.JwtCache.Set(jwksUrl, jwks) // Store the jwk.Set
return jwks, nil // Return the jwk.Set after type assertion
}
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
// It exchanges the authorization code for tokens, then attempts to verify the ID token using JWKS
// if configured, falling back to the UserInfo endpoint if necessary.
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
code := r.URL.Query().Get("code")
//state := r.URL.Query().Get("state")
//
//fmt.Println("State:", state)
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
// Step 1: Exchange the code for tokens
data := url.Values{}
data.Set("grant_type", "authorization_code")
@@ -329,43 +374,156 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
req, _ := http.NewRequest("POST", config.Auth.Methods.OidcAuth.TokenUrl, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
client := &http.Client{} // Consider using a client with a timeout
resp, err := client.Do(req)
if err != nil {
logger.Debug(fmt.Sprintf("failed to send token request: %v", err))
return 500, fmt.Errorf("token request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
// print body in response
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
logger.Debug(fmt.Sprintf("failed to fetch token: %v %v", resp.StatusCode, string(body)))
return 500, fmt.Errorf("failed to fetch token: %v %v", resp.StatusCode, string(body))
}
// Decode the token response, expecting access_token and potentially id_token
var tokenResp struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"` // OIDC providers include id_token here
}
if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
logger.Debug(fmt.Sprintf("failed to parse token response: %v", err))
return 500, fmt.Errorf("failed to parse token response: %v", err)
}
// Step 2: Use the access token to fetch user info from the UserInfo URL
userInfoURL := config.Auth.Methods.OidcAuth.UserInfoUrl
req, _ = http.NewRequest("GET", userInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
resp, err = client.Do(req)
if err != nil || resp.StatusCode != 200 {
return 500, fmt.Errorf("failed to fetch user info: %v %v", resp.StatusCode, err)
}
defer resp.Body.Close()
var userdata userInfo // This will hold the user claims, using the original struct
useUserInfoEndpoint := true // Flag to determine if we need to call the UserInfo endpoint
var userdata userInfo
if err = json.NewDecoder(resp.Body).Decode(&userdata); err != nil {
return 500, fmt.Errorf("failed to parse user info: %v", err)
// Step 2: Process ID Token if available and JWKS URL is configured
if tokenResp.IDToken != "" && config.Auth.Methods.OidcAuth.JwksUrl != "" {
logger.Debug(fmt.Sprintf("ID token received `%v` attempting to verify using JWKS URL", tokenResp.IDToken))
// getKeySet now returns jwk.Set (interface)
var jwks jwk.Set
jwks, err = getKeySet(config.Auth.Methods.OidcAuth.JwksUrl)
if err != nil {
// Log the error but don't fail yet, try UserInfo endpoint as a fallback
logger.Warning("Failed to fetch or decode JWKS: %v. Falling back to UserInfo endpoint. Ensure your OidcAuth.JwksUrl is correct.")
} else {
var token *jwt.Token
// Parse the ID token. We need to provide a key function to `jwt.Parse`
// that looks up the key in the JWKS based on the token's `kid`.
token, err = jwt.Parse(tokenResp.IDToken, func(token *jwt.Token) (interface{}, error) {
switch alg := token.Method.Alg(); alg {
case "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA":
// accepted
default:
logger.Debug(fmt.Sprintf("unsupported signing method recieved `%v`", alg))
return nil, fmt.Errorf("unsupported signing method recieved: %v", alg)
}
// Find the key in the JWKS that matches the token's kid (Key ID)
keyID, ok := token.Header["kid"].(string)
if !ok {
logger.Debug("kid header not found in ID token")
return nil, fmt.Errorf("kid header not found in ID token")
}
// Use the jwk.Set interface to find the key
// LookupKeyID returns a slice of jwk.Key and a boolean indicating if any keys were found.
key, found := jwks.LookupKeyID(keyID) // Called on the jwk.Set interface
if !found {
logger.Debug(fmt.Sprintf("key with kid %s not found in JWKS", keyID))
return nil, fmt.Errorf("key with kid %s not found in JWKS", keyID)
}
// Return the public key from the first matching JWK
// In a real scenario, you might need more sophisticated key selection logic.
// The jwk.Key interface has a PublicKey() method to get the public key.
publicKey, publicKeyErr := key.PublicKey()
if publicKeyErr != nil {
logger.Debug(fmt.Sprintf("failed to get public key from JWK: %v", publicKeyErr))
return nil, fmt.Errorf("failed to get public key from JWK: %v", err)
}
return publicKey, nil
})
if err != nil {
// Log the verification error but fall back to UserInfo endpoint
logger.Error(fmt.Sprintf("failed to parse or verify ID token: %v. Falling back to UserInfo endpoint.", err))
} else if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
logger.Debug("ID token verified successfully, claims extracted.")
// --- IMPORTANT OIDC VALIDATION ---
// In a production application, you MUST perform additional validations on the ID token claims:
// - Issuer (iss): Must match the expected issuer URL.
// - Audience (aud): Must contain your client ID.
// - Expiration Time (exp): Token must not be expired (jwt.Parse handles this if clock skew is accounted for).
// - Issued At Time (iat): Token must not be issued in the future.
// - Nonce (nonce): If a nonce was sent in the authorization request, it must match the 'nonce' claim in the ID token.
// - Authorized Party (azp): If 'aud' has multiple values, 'azp' must be present and equal to your client ID.
// - at_hash and c_hash: If the access token or authorization code were issued, validate their hashes if present in the ID token.
// A dedicated OIDC library handles these validations correctly and securely.
// This simplified example only verifies the signature and basic validity.
// --- END IMPORTANT OIDC VALIDATION ---
// Populate the userInfo struct from the ID token claims (map)
if name, ok := claims["name"].(string); ok {
userdata.Name = name
}
if preferredUsername, ok := claims["preferred_username"].(string); ok {
userdata.PreferredUsername = preferredUsername
}
if email, ok := claims["email"].(string); ok {
userdata.Email = email
}
if sub, ok := claims["sub"].(string); ok {
userdata.Sub = sub
}
if phone, ok := claims["phone_number"].(string); ok {
userdata.Phone = phone
}
useUserInfoEndpoint = false // Successfully processed ID token, no need for UserInfo endpoint
} else {
// ID token signature verification failed or token is invalid for other reasons
logger.Error("id token signature verification failed or token is invalid. Falling back to UserInfo endpoint.")
}
}
}
// Step 3: Use the access token to fetch user info from the UserInfo URL if needed
// This step is skipped if the ID token was successfully processed and verified.
if useUserInfoEndpoint {
userInfoURL := config.Auth.Methods.OidcAuth.UserInfoUrl
req, _ = http.NewRequest("GET", userInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
resp, err = client.Do(req)
if err != nil {
return 500, fmt.Errorf("failed to fetch user info: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return 500, fmt.Errorf("failed to fetch user info: received status code %d with body: %s", resp.StatusCode, string(bodyBytes))
}
// Decode the UserInfo response directly into the original userdata struct
if err = json.NewDecoder(resp.Body).Decode(&userdata); err != nil {
return 500, fmt.Errorf("failed to parse user info: %v", err)
}
}
// Now userdata contains claims from either the verified ID token or the UserInfo endpoint.
// Proceed with logging in the user based on the extracted claims.
return loginWithOidcUser(w, r, userdata)
}
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
// This function remains largely the same, but includes the 'fb_redirect' parameter
// to redirect the user back to the original page after successful login.
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Auth.Methods.OidcAuth.Enabled {
origin := r.Header.Get("Origin")
@@ -373,25 +531,35 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
// Generate a nonce for CSRF protection (optional but recommended)
// You should store this nonce and validate it in the callback handler.
nonce := utils.InsecureRandomIdentifier(16)
// For state parameter, you might include the nonce and the original redirect URL
state := fmt.Sprintf("%s:%s", nonce, r.URL.Query().Get("fb_redirect")) // Example state parameter structure
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&scope=%s&redirect_uri=%s&state=%s&fb_redirect=%s",
// Construct the authorization URL
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&scope=%s&redirect_uri=%s&state=%s",
config.Auth.Methods.OidcAuth.AuthorizationUrl,
url.QueryEscape(config.Auth.Methods.OidcAuth.ClientID),
url.QueryEscape(config.Auth.Methods.OidcAuth.Scopes),
url.QueryEscape(redirectURI),
nonce,
r.URL.Query().Get("fb_redirect"),
url.QueryEscape(state), // Use the state parameter
)
// Redirect the user to the OIDC provider
http.Redirect(w, r, authURL, http.StatusFound)
return 0, nil
return 0, nil // Indicate that the request has been handled by the redirect
}
// OIDC authentication is not enabled
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, userInfo userInfo) (int, error) {
username := userInfo.PreferredUsername
switch config.Auth.Methods.OidcAuth.UserIdentifier {
@@ -426,24 +594,54 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, userInfo userInfo
return http.StatusInternalServerError, err
}
}
if user.LoginMethod != users.LoginMethodOidc {
return http.StatusForbidden, fmt.Errorf("user %s is not allowed to login with OIDC", username)
}
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
// Handle potential errors during token generation
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err)
}
return http.StatusInternalServerError, err
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
// set signed.Key in the cookie as auth
// Set the authentication token as an HTTP cookie
cookie := &http.Cookie{
Name: "auth",
Value: signed.Key,
Domain: strings.Split(r.Host, ":")[0],
Path: "/",
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
// Secure: true, // Recommended: Set to true in production with HTTPS
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
}
http.SetCookie(w, cookie)
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
setUserInResponseWriter(w, user)
http.Redirect(w, r, "/", http.StatusFound)
return 200, nil
// Redirect the user to the page they were trying to access before login,
// or to the root ("/") if no specific redirect was requested.
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
state := r.URL.Query().Get("state")
fbRedirect := "/" // Default redirect path
if state != "" {
// Assuming state is in the format "nonce:fb_redirect"
parts := strings.SplitN(state, ":", 2)
if len(parts) == 2 {
// TODO: Validate the nonce part against the stored nonce for CSRF protection
// For this example, we'll just extract the redirect part
extractedRedirect := parts[1]
if extractedRedirect != "" {
fbRedirect = extractedRedirect
}
}
}
http.Redirect(w, r, fbRedirect, http.StatusFound)
// Return 0 to indicate that the response has been handled by the redirect
return 0, nil
}
+1 -1
View File
@@ -11,8 +11,8 @@ import (
"strings"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
+1 -2
View File
@@ -10,8 +10,7 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
-26
View File
@@ -1659,32 +1659,6 @@ const docTemplate = `{
}
}
},
"settings.Auth": {
"type": "object",
"properties": {
"adminPassword": {
"type": "string"
},
"adminUsername": {
"type": "string"
},
"key": {
"type": "array",
"items": {
"type": "integer"
}
},
"methods": {
"$ref": "#/definitions/settings.LoginMethods"
},
"signup": {
"type": "boolean"
},
"tokenExpirationHours": {
"type": "integer"
}
}
},
"settings.ExternalLink": {
"type": "object",
"required": [
-26
View File
@@ -1648,32 +1648,6 @@
}
}
},
"settings.Auth": {
"type": "object",
"properties": {
"adminPassword": {
"type": "string"
},
"adminUsername": {
"type": "string"
},
"key": {
"type": "array",
"items": {
"type": "integer"
}
},
"methods": {
"$ref": "#/definitions/settings.LoginMethods"
},
"signup": {
"type": "boolean"
},
"tokenExpirationHours": {
"type": "integer"
}
}
},
"settings.ExternalLink": {
"type": "object",
"required": [
-17
View File
@@ -104,23 +104,6 @@ definitions:
description: the number of hours until the token expires. Default is 2 hours.
type: integer
type: object
settings.Auth:
properties:
adminPassword:
type: string
adminUsername:
type: string
key:
items:
type: integer
type: array
methods:
$ref: '#/definitions/settings.LoginMethods'
signup:
type: boolean
tokenExpirationHours:
type: integer
type: object
settings.ExternalLink:
properties:
text:
+3 -1
View File
@@ -30,7 +30,9 @@
:title="label"
class="action no-select"
>
<i class="material-icons">{{ icon }}</i>
<i v-if="icon == 'table_rows_narrow'" class="material-symbols-outlined">{{ icon }}</i>
<i v-else class="material-icons">{{ icon }}</i>
<span>{{ label }}</span>
<span v-if="counter > 0" class="counter">{{ counter }}</span>
</button>
@@ -60,9 +60,10 @@ export default {
if (state.isMobile) {
this.popupStyle = {
left: "50%",
"max-width": "90%",
transform: "translate(-50%, 10em)",
"max-height": "75%",
"margin": "1em",
transform: "translate(0, 4em)",
};
return;
}
@@ -110,9 +111,6 @@ export default {
box-shadow: 0 0 0.5em black;
border-color: var(--primaryColor);
overflow: hidden;
max-height: 80vh;
max-width: 80vw;
z-index: 1000;
transition: all 0.3s ease-in-out;
}
+11 -3
View File
@@ -28,7 +28,13 @@
<i class="material-icons">folder</i>
<div class="title">{{ $t("buttons.folder") }}</div>
</div>
<input ref="fileInput" @change="onFilePicked" type="file" multiple style="display: none" />
<input
ref="fileInput"
@change="onFilePicked"
type="file"
multiple
style="display: none"
/>
<input
ref="folderInput"
@change="onFolderPicked"
@@ -54,11 +60,11 @@ export default {
const folderInput = ref(null);
const triggerFilePicker = () => {
fileInput.value.click();
if (fileInput.value) fileInput.value.click();
};
const triggerFolderPicker = () => {
folderInput.value.click();
if (folderInput.value) folderInput.value.click();
};
const onFilePicked = (event) => {
@@ -117,6 +123,8 @@ export default {
triggerFolderPicker,
onFilePicked,
onFolderPicked,
fileInput,
folderInput,
};
},
};
+5 -2
View File
@@ -4,7 +4,7 @@
this.hoverText
}}</span>
</div>
<div class="tooltip-sources">
<div v-if="hasSourceInfo" class="tooltip-sources">
<div
class="tooltiptext-sources"
:style="{ top: mouseY - 500 + 'px' }"
@@ -146,7 +146,7 @@
</svg>
<span>{{ name }}</span>
</div>
<div v-if="info.total != 0" class="usage-info">
<div v-if="hasSourceInfo" class="usage-info">
<progress-bar
:val="info.usedPercentage"
text-position="inside"
@@ -187,6 +187,9 @@ export default {
};
},
computed: {
hasSourceInfo() {
return state.sources.hasSourceInfo;
},
settingsAllowed: () => !state.user.disableSettings,
isSettings: () => getters.isSettings(),
isStickySidebar: () => getters.isStickySidebar(),
+11
View File
@@ -9,6 +9,9 @@ import { serverHasMultipleSources } from "@/utils/constants.js";
export const mutations = {
setMultiButtonState: (value) => {
if (state.multiButtonLastState != value) {
state.multiButtonLastState = state.multiButtonState;
}
state.multiButtonState = value;
emitStateChanged();
},
@@ -47,6 +50,11 @@ export const mutations = {
for (const k of Object.keys(value)) {
const source = value[k];
if (state.sources.info[k]) {
if (source.total == 0) {
state.sources.hasSourceInfo = false
} else {
state.sources.hasSourceInfo = true
}
state.sources.info[k].used = source.used;
state.sources.info[k].total = source.total;
state.sources.info[k].usedPercentage = Math.round((source.used / source.total) * 100);
@@ -151,6 +159,9 @@ export const mutations = {
emitStateChanged();
},
closeHovers: () => {
const previousState = state.multiButtonLastState;
state.multiButtonLastState = mutations.multiButtonState;
state.multiButtonState = previousState;
state.prompts = [];
if (!state.stickySidebar) {
state.showSidebar = false;
+2
View File
@@ -3,6 +3,7 @@ import { detectLocale } from "@/i18n";
export const state = reactive({
multiButtonState: "menu",
multiButtonLastState: "menu",
showOverflowMenu: false,
sessionId: "",
disableOnlyOfficeExt: "",
@@ -20,6 +21,7 @@ export const state = reactive({
sources: {
current: "",
count: 1,
hasSourceInfo: false,
info: {},
},
user: {
+4 -4
View File
@@ -55,10 +55,10 @@ export default {
},
viewIcon() {
const icons = {
list: "view_module",
compact: "view_module",
normal: "grid_view",
gallery: "view_list",
list: "view_list",
compact: "table_rows_narrow",
normal: "view_module",
gallery: "grid_view",
};
return icons[state.user.viewMode] || "grid_view";
},
+5 -1
View File
@@ -250,8 +250,12 @@ export default {
if (!getters.isCardView()) {
return 1;
}
const elem = document.querySelector("#main");
if (!elem) {
return 1;
}
let columns = Math.floor(
document.querySelector("#main").offsetWidth / this.columnWidth
elem.offsetWidth / this.columnWidth
);
if (columns === 0) columns = 1;
return columns;
+5 -1
View File
@@ -106,11 +106,15 @@ export default {
async save(event) {
event.preventDefault();
try {
let fields = ["all"]
if (!state.user.permissions.admin) {
fields = ["password"];
}
if (this.isNew) {
await usersApi.create(this.user); // Use the computed property
this.$router.push({ path: "/settings", hash: "#users-main" });
} else {
await usersApi.update(this.user, ["all"]);
await usersApi.update(this.user, fields);
if (this.updatePassword) {
await usersApi.update(this.user, ["password"]);
}