mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
5
Commits
main
...
v1.3.7-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d47755a4 | ||
|
|
7f93152f3e | ||
|
|
ae5a216c3c | ||
|
|
831590dc3e | ||
|
|
b16234abc1 |
No files matched your search
@@ -2,6 +2,15 @@
|
||||
|
||||
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).
|
||||
|
||||
## v1.3.7
|
||||
|
||||
**Notes**:
|
||||
- creating/deleting password-based user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
|
||||
- No Loginfields shown if password authentification is set to false (#2331)
|
||||
|
||||
## v1.3.6
|
||||
|
||||
**BugFixes**:
|
||||
|
||||
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 3000,
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/settings",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -21,6 +21,24 @@ func SetFsPermissions(unixFileMode, unixDirMode uint32) {
|
||||
PermDir = unixModeToFileMode(unixDirMode)
|
||||
}
|
||||
|
||||
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
|
||||
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
|
||||
// the process has not set globals yet.
|
||||
func EffectiveDirPerm() os.FileMode {
|
||||
if PermDir == 0 {
|
||||
return 0o755
|
||||
}
|
||||
return PermDir
|
||||
}
|
||||
|
||||
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
|
||||
func EffectiveFilePerm() os.FileMode {
|
||||
if PermFile == 0 {
|
||||
return 0o644
|
||||
}
|
||||
return PermFile
|
||||
}
|
||||
|
||||
func unixModeToFileMode(u uint32) os.FileMode {
|
||||
m := os.FileMode(u & 0777)
|
||||
if u&0o4000 != 0 {
|
||||
|
||||
@@ -24,12 +24,12 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
username := r.URL.Query().Get("username")
|
||||
recaptcha := r.URL.Query().Get("recaptcha")
|
||||
password := r.Header.Get("X-Password")
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
// URL-decode password to support special characters in headers
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
|
||||
// If ReCaptcha is enabled, check the code.
|
||||
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
|
||||
|
||||
+3
-3
@@ -32,7 +32,7 @@ require (
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.44.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.49.1
|
||||
modernc.org/sqlite v1.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -50,7 +50,7 @@ require (
|
||||
github.com/Antonboom/errname v1.1.1 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.1 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.4 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
@@ -281,7 +281,7 @@ require (
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/libc v1.72.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
+12
-12
@@ -59,8 +59,8 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
|
||||
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
|
||||
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
|
||||
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
@@ -1290,10 +1290,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
|
||||
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
|
||||
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
|
||||
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=
|
||||
@@ -1302,18 +1302,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/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.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+128
-50
@@ -11,7 +11,9 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -103,34 +105,34 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
|
||||
fullDestFile := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestFile, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination is a file we are about to create; resolve parent dir only (parent must exist or be creatable).
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
fullDestParent := utils.GetParentDirectoryPath(fullDestFile)
|
||||
if store.Access != nil && fullDestParent != fullDestFile && !store.Access.Permitted(idxTo.Path, fullDestParent, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
parentReal, _, err := idxTo.GetRealPath(fullDestParent)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
}
|
||||
destRealPath := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destParentReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
|
||||
fileName := path.Base(destRel)
|
||||
if fileName == "." || fileName == "/" {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
|
||||
}
|
||||
destFileReal := filepath.Join(parentReal, fileName)
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
ext := strings.ToLower(filepath.Ext(destRealPath))
|
||||
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
|
||||
destLower := strings.ToLower(req.Destination)
|
||||
if strings.HasSuffix(destLower, ".tar.gz") {
|
||||
format = "tar.gz"
|
||||
} else if ext == ".zip" {
|
||||
} else if strings.HasSuffix(destLower, ".zip") || filepath.Ext(req.Destination) == ".zip" {
|
||||
format = "zip"
|
||||
} else {
|
||||
format = "zip"
|
||||
@@ -172,7 +174,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
|
||||
var createErr error
|
||||
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
file, err := os.OpenFile(destFileReal, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.EffectiveFilePerm())
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -299,22 +301,19 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestPath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination may not exist yet; resolve parent dir then build destination path.
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
destReal, _, err := idxTo.GetRealPath(fullDestPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
}
|
||||
destReal := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
destInfo, err := os.Stat(destReal)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory does not exist: %s", req.Destination)
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("destination: %v", err)
|
||||
}
|
||||
if !destInfo.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination must be a directory: %s", req.Destination)
|
||||
}
|
||||
|
||||
info, err := os.Stat(archiveReal)
|
||||
@@ -649,13 +648,53 @@ type unarchiveRequest struct {
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
clean := filepath.Clean(name)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
// normalizeArchiveEntryName turns a raw zip/tar name into a safe relative path (slash-separated).
|
||||
// Many Windows zips use backslashes; some start with a leading backslash, which would make
|
||||
// filepath.Join drop destDir and write under the drive root. Backslashes are not path separators
|
||||
// on Unix, so we always normalize '\' -> '/' and trim leading '/' before path.Clean.
|
||||
func normalizeArchiveEntryName(name string) (string, error) {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
abs := filepath.Join(destDir, clean)
|
||||
s = strings.ReplaceAll(s, "\\", "/")
|
||||
if strings.HasPrefix(s, "//") {
|
||||
return "", errors.New("UNC or invalid path")
|
||||
}
|
||||
s = strings.TrimLeft(s, "/")
|
||||
// Reject e.g. "C:/path" in archive
|
||||
if len(s) >= 3 && s[1] == ':' && s[2] == '/' {
|
||||
if 'A' <= s[0] && s[0] <= 'Z' || 'a' <= s[0] && s[0] <= 'z' {
|
||||
return "", errors.New("absolute path in archive")
|
||||
}
|
||||
}
|
||||
if s == ".." {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
if strings.HasPrefix(s, "../") {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
cleaned := path.Clean(s)
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return "", errors.New("empty path after clean")
|
||||
}
|
||||
if cleaned == ".." {
|
||||
return "", errors.New("path traversal after clean")
|
||||
}
|
||||
if strings.HasPrefix(cleaned, "../") {
|
||||
return "", errors.New("path traversal after clean")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or drive-root / UNC tricks).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
relSlash, err := normalizeArchiveEntryName(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
|
||||
}
|
||||
// Convert to platform separators so filepath.Join and os.Mkdir work correctly
|
||||
abs := filepath.Join(destDir, filepath.FromSlash(relSlash))
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -664,8 +703,13 @@ func safeExtractPath(destDir, name string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(entryAbs, destAbs) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
// Case-sensitive/prefix issues on Windows: use Rel instead of HasPrefix on absolute paths
|
||||
rel, err := filepath.Rel(destAbs, entryAbs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %q", name)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -730,10 +774,10 @@ func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
|
||||
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermDir
|
||||
perm = fileutils.EffectiveDirPerm()
|
||||
}
|
||||
if err := os.MkdirAll(destPath, perm); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("mkdir %q: %w", destPath, err)
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, mode, modTime)
|
||||
}
|
||||
@@ -741,12 +785,13 @@ func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) er
|
||||
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
|
||||
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
|
||||
defer func() { _ = src.Close() }()
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
parent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(parent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", parent, err)
|
||||
}
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermFile
|
||||
perm = fileutils.EffectiveFilePerm()
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
@@ -766,8 +811,9 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
|
||||
// extractArchivedSymlink creates a symlink after validating the target stays under destRoot.
|
||||
func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time) error {
|
||||
target = strings.TrimSpace(target)
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
symParent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(symParent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", symParent, err)
|
||||
}
|
||||
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
|
||||
return err
|
||||
@@ -788,7 +834,39 @@ func extractZip(archivePath, destDir string) error {
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Central directory order is arbitrary. Some zips (e.g. Windows driver packs) use names that
|
||||
// are similar length at different tree depths, so byte length is a poor order key. Use the
|
||||
// number of path segments in the *normalized* entry name: deeper trees first, then
|
||||
// lexicographic, so parent dirs exist before shorter entries that might be mis-tagged.
|
||||
type orderedName struct {
|
||||
f *zip.File
|
||||
rel string
|
||||
depth int
|
||||
}
|
||||
ordered := make([]orderedName, 0, len(r.File))
|
||||
for _, f := range r.File {
|
||||
rel, nerr := normalizeArchiveEntryName(f.Name)
|
||||
if nerr != nil {
|
||||
rel = strings.TrimLeft(strings.ReplaceAll(strings.TrimSpace(f.Name), "\\", "/"), "/")
|
||||
}
|
||||
depth := 0
|
||||
if rel != "" {
|
||||
depth = strings.Count(rel, "/") + 1
|
||||
}
|
||||
ordered = append(ordered, orderedName{f: f, rel: rel, depth: depth})
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].depth != ordered[j].depth {
|
||||
return ordered[i].depth > ordered[j].depth
|
||||
}
|
||||
if ordered[i].rel != ordered[j].rel {
|
||||
return ordered[i].rel < ordered[j].rel
|
||||
}
|
||||
return ordered[i].f.Name < ordered[j].f.Name
|
||||
})
|
||||
|
||||
for _, o := range ordered {
|
||||
f := o.f
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, f.Name)
|
||||
if err != nil {
|
||||
@@ -797,8 +875,8 @@ func extractZip(archivePath, destDir string) error {
|
||||
|
||||
fi := f.FileInfo()
|
||||
mode := fi.Mode()
|
||||
|
||||
if mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") {
|
||||
isDirEntry := mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, "\\")
|
||||
if isDirEntry {
|
||||
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeArchiveEntryName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "simple forward slashes",
|
||||
input: "DOCS/guide.pdf",
|
||||
want: "DOCS/guide.pdf",
|
||||
},
|
||||
{
|
||||
name: "Windows backslashes become logical path",
|
||||
input: `DOCS\QUICK\chs.gif`,
|
||||
want: "DOCS/QUICK/chs.gif",
|
||||
},
|
||||
{
|
||||
name: "Intel-style leading backslash stripped so path stays relative",
|
||||
input: `\PRO1000\Winx64\NDIS65\e1c65x64.cat`,
|
||||
want: "PRO1000/Winx64/NDIS65/e1c65x64.cat",
|
||||
},
|
||||
{
|
||||
name: "leading forward slashes stripped",
|
||||
input: "/PRO1000/Winx64/readme.txt",
|
||||
want: "PRO1000/Winx64/readme.txt",
|
||||
},
|
||||
{
|
||||
name: "directory entry with trailing slash",
|
||||
input: `PRO1000\Winx64\`,
|
||||
want: "PRO1000/Winx64",
|
||||
},
|
||||
{
|
||||
name: "mixed redundant segments cleaned",
|
||||
input: "a/./b/../c/file",
|
||||
want: "a/c/file",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: "empty path",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " ",
|
||||
wantErr: "empty path",
|
||||
},
|
||||
{
|
||||
name: "UNC rejected",
|
||||
input: `//server/share/file`,
|
||||
wantErr: "UNC or invalid path",
|
||||
},
|
||||
{
|
||||
name: "double slash prefix after backslash replace (UNC-style)",
|
||||
input: `\\?\C:\temp\file`,
|
||||
wantErr: "UNC or invalid path",
|
||||
},
|
||||
{
|
||||
name: "Windows drive absolute rejected",
|
||||
input: `C:/Windows/system32`,
|
||||
wantErr: "absolute path in archive",
|
||||
},
|
||||
{
|
||||
name: "lowercase drive rejected",
|
||||
input: `c:/data/file.txt`,
|
||||
wantErr: "absolute path in archive",
|
||||
},
|
||||
{
|
||||
name: "bare dot-dot",
|
||||
input: "..",
|
||||
wantErr: "path traversal",
|
||||
},
|
||||
{
|
||||
name: "prefixed dot-dot",
|
||||
input: "../etc/passwd",
|
||||
wantErr: "path traversal",
|
||||
},
|
||||
{
|
||||
name: "traversal after path clean",
|
||||
input: "safe/../../outside",
|
||||
wantErr: "path traversal after clean",
|
||||
},
|
||||
{
|
||||
name: "only dots and slashes",
|
||||
input: "./",
|
||||
wantErr: "empty path after clean",
|
||||
},
|
||||
{
|
||||
name: "path cleans to nothing",
|
||||
input: "x/../.",
|
||||
wantErr: "empty path after clean",
|
||||
},
|
||||
{
|
||||
name: "spaces preserved in file name",
|
||||
input: "My Folder/read me.txt",
|
||||
want: "My Folder/read me.txt",
|
||||
},
|
||||
{
|
||||
name: "deep relative tree",
|
||||
input: "a/b/c/d/e/f/g.bin",
|
||||
want: "a/b/c/d/e/f/g.bin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeArchiveEntryName(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("normalizeArchiveEntryName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeArchiveEntryName_backslashInFileNameOnUnix documents that after normalization
|
||||
// a single backslash in the raw name is never a path separator: it becomes a slash in the
|
||||
// logical path (portable), so segments split correctly on every GOOS in safeExtractPath.
|
||||
func TestNormalizeArchiveEntryName_unifiesBackslashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeArchiveEntryName(`folder\file.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "folder/file.txt"; got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeExtractPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
dest, err := filepath.Abs(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("file under dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := safeExtractPath(dest, `DOCS\Adapter_User_Guide.pdf`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dest, "DOCS", "Adapter_User_Guide.pdf")
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leading backslash in zip name stays under dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := safeExtractPath(dest, `\sub\file.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(got, dest+string(filepath.Separator)) {
|
||||
t.Fatalf("result %q should be under dest %q", got, dest)
|
||||
}
|
||||
rel, err := filepath.Rel(dest, got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel != filepath.Join("sub", "file.txt") {
|
||||
t.Fatalf("Rel = %q, want sub%cfile.txt", rel, filepath.Separator)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects path that escapes dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := safeExtractPath(dest, "../../../x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for parent segments in archive name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid entry path") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects when normalize fails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := safeExtractPath(dest, "..")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid entry path") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSafeExtractPath_rejectsParentTraversal ensures ".." in the archive name is rejected
|
||||
// by normalizeArchiveEntryName, not written under dest.
|
||||
func TestSafeExtractPath_rejectsParentTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
_, err := safeExtractPath(dest, `pkg/../../../secret`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path traversal in archive name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeExtractPath_destJoinDoesNotDropBase verifies that a previously problematic pattern
|
||||
// (root-relative second segment) does not throw away dest after normalization.
|
||||
func TestSafeExtractPath_destJoinDoesNotDropBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
out, err := safeExtractPath(dest, `a\b\c.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(out, dest+string(filepath.Separator)) {
|
||||
t.Fatalf("expected path under %q, got %q", dest, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractZip_nestedBackslashes extracts a Windows-style path in a zip (backslashes) into
|
||||
// nested directories under dest. This matches many vendor driver zips.
|
||||
func TestExtractZip_nestedBackslashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "test.zip")
|
||||
out := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create(`DOCS\QUICK\readme.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = w.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = extractZip(zipPath, out); err != nil {
|
||||
t.Fatalf("extractZip: %v", err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(out, "DOCS", "QUICK", "readme.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "hello" {
|
||||
t.Fatalf("content = %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks (optional, keep small)
|
||||
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
|
||||
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = normalizeArchiveEntryName(name)
|
||||
}
|
||||
}
|
||||
@@ -92,11 +92,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
// Auth Routes - /api/auth/
|
||||
// ========================================
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(false, loginHandler))
|
||||
api.HandleFunc("POST /auth/login", loginHelper(loginHandler))
|
||||
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
|
||||
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(true, generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(false, verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
|
||||
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
|
||||
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
|
||||
|
||||
@@ -314,7 +314,7 @@ func withoutUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
|
||||
// allow user without OTP to pass
|
||||
func userWithoutOTPhelper(disableOtp bool, fn handleFunc) handleFunc {
|
||||
func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
|
||||
if config.Auth.Methods.ProxyAuth.Enabled {
|
||||
@@ -659,8 +659,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(withoutUserHelper(fn))
|
||||
}
|
||||
|
||||
func userWithoutOTP(disableOtp bool, fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(userWithoutOTPhelper(disableOtp, fn))
|
||||
func loginHelper(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(LoginHelper(false, fn))
|
||||
}
|
||||
|
||||
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
|
||||
|
||||
+46
-25
@@ -3,9 +3,10 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
|
||||
@@ -21,18 +22,15 @@ import (
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/otp/generate [post]
|
||||
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
username = d.user.Username
|
||||
}
|
||||
if username != d.user.Username && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
|
||||
}
|
||||
targetUser, err := store.Users.Get(username)
|
||||
err := checkPassword(r, d)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
|
||||
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
|
||||
if getErr != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
|
||||
}
|
||||
url, err := auth.GenerateOtpForUser(user, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
|
||||
}
|
||||
@@ -61,21 +59,15 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if code == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("code is required")
|
||||
}
|
||||
|
||||
targetUser := d.user
|
||||
username := r.URL.Query().Get("username")
|
||||
|
||||
// If admin is verifying for another user
|
||||
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
|
||||
var err error
|
||||
targetUser, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
|
||||
err := checkPassword(r, d)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
|
||||
err := auth.VerifyTotpCode(targetUser, code, store.Users)
|
||||
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
|
||||
if getErr != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
|
||||
}
|
||||
err = auth.VerifyTotpCode(user, code, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
@@ -86,3 +78,32 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// On success, return a simple confirmation.
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
func checkPassword(r *http.Request, d *requestContext) error {
|
||||
providedPassword := r.Header.Get("X-Password")
|
||||
username := r.URL.Query().Get("username")
|
||||
if d.user.Permissions.Admin {
|
||||
username = d.user.Username
|
||||
}
|
||||
// url decode
|
||||
providedPassword, err := url.QueryUnescape(providedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
if providedPassword == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
user, getErr := store.Users.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
// always run checkPwd to prevent timing attacks
|
||||
err = utils.CheckPwd(providedPassword, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid password or user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+19
-5
@@ -180,12 +180,14 @@ func normalizeLocale(locale string) string {
|
||||
|
||||
// userDeleteHandler deletes a user by ID.
|
||||
// @Summary Delete a user by ID
|
||||
// @Description Deletes a user identified by their ID.
|
||||
// @Description Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id query string true "User ID"
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
|
||||
// @Success 200 "User deleted successfully"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [delete]
|
||||
@@ -202,8 +204,13 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
|
||||
}
|
||||
|
||||
status, err := verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
err := store.Users.Delete(givenUserId)
|
||||
err = store.Users.Delete(givenUserId)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -212,13 +219,15 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
|
||||
// usersPostHandler creates a new user.
|
||||
// @Summary Create a new user
|
||||
// @Description Adds a new user to the system.
|
||||
// @Description Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
|
||||
// @Param data body users.User true "User data to create a new user"
|
||||
// @Success 201 {object} users.User "Created user"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [post]
|
||||
func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -249,6 +258,11 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusBadRequest, errors.ErrEmptyPassword
|
||||
}
|
||||
|
||||
status, err := verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
err = storage.CreateUser(req.User, req.User.Permissions)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
@@ -292,7 +306,7 @@ func userPutOnlyNonAdminEditableFields(which []string) bool {
|
||||
|
||||
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
|
||||
// password login. Callers should invoke this only when the update requires re-authentication.
|
||||
func verifyActorPasswordForUserPut(r *http.Request, d *requestContext) (int, error) {
|
||||
func verifyActorPasswordForUserActions(r *http.Request, d *requestContext) (int, error) {
|
||||
if d.user.LoginMethod != users.LoginMethodPassword {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -382,7 +396,7 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
|
||||
if d.user.LoginMethod == users.LoginMethodPassword && !userPutOnlyNonAdminEditableFields(req.Which) {
|
||||
var status int
|
||||
status, err = verifyActorPasswordForUserPut(r, d)
|
||||
status, err = verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
@@ -3156,7 +3156,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Adds a new user to the system.",
|
||||
"description": "Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3168,6 +3168,12 @@ const docTemplate = `{
|
||||
],
|
||||
"summary": "Create a new user",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to create a new user",
|
||||
"name": "data",
|
||||
@@ -3194,6 +3200,15 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -3206,7 +3221,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes a user identified by their ID.",
|
||||
"description": "Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3224,12 +3239,27 @@ const docTemplate = `{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User deleted successfully"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
|
||||
@@ -3145,7 +3145,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Adds a new user to the system.",
|
||||
"description": "Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3157,6 +3157,12 @@
|
||||
],
|
||||
"summary": "Create a new user",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to create a new user",
|
||||
"name": "data",
|
||||
@@ -3183,6 +3189,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -3195,7 +3210,7 @@
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes a user identified by their ID.",
|
||||
"description": "Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3213,12 +3228,27 @@
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User deleted successfully"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
|
||||
@@ -4136,18 +4136,31 @@ paths:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes a user identified by their ID.
|
||||
description: Deletes a user identified by their ID. When the authenticated actor
|
||||
uses password login, they must send their current password in the X-Password
|
||||
header.
|
||||
parameters:
|
||||
- description: User ID
|
||||
in: query
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Actor's current password (URL-encoded); required for password-login
|
||||
actors
|
||||
in: header
|
||||
name: X-Password
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: User deleted successfully
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing actor password when required
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
@@ -4204,8 +4217,14 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Adds a new user to the system.
|
||||
description: Adds a new user to the system. When the authenticated actor uses
|
||||
password login, they must send their current password in the X-Password header.
|
||||
parameters:
|
||||
- description: Actor's current password (URL-encoded); required for password-login
|
||||
actors
|
||||
in: header
|
||||
name: X-Password
|
||||
type: string
|
||||
- description: User data to create a new user
|
||||
in: body
|
||||
name: data
|
||||
@@ -4225,6 +4244,12 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing actor password when required
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
|
||||
Generated
+69
-91
@@ -35,7 +35,7 @@
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"deepl-node": "^1.18.0",
|
||||
"deepl-node": "^1.16.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vue": "^9.24.0",
|
||||
@@ -1959,53 +1959,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
|
||||
"integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
|
||||
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/shared": "3.5.32",
|
||||
"@vue/shared": "3.5.33",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
|
||||
"integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
|
||||
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.32",
|
||||
"@vue/shared": "3.5.32"
|
||||
"@vue/compiler-core": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
|
||||
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
|
||||
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/compiler-core": "3.5.32",
|
||||
"@vue/compiler-dom": "3.5.32",
|
||||
"@vue/compiler-ssr": "3.5.32",
|
||||
"@vue/shared": "3.5.32",
|
||||
"@vue/compiler-core": "3.5.33",
|
||||
"@vue/compiler-dom": "3.5.33",
|
||||
"@vue/compiler-ssr": "3.5.33",
|
||||
"@vue/shared": "3.5.33",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "^8.5.10",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
|
||||
"integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
|
||||
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.32",
|
||||
"@vue/shared": "3.5.32"
|
||||
"@vue/compiler-dom": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
@@ -2076,53 +2076,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
|
||||
"integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
|
||||
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.32"
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
|
||||
"integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
|
||||
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.32",
|
||||
"@vue/shared": "3.5.32"
|
||||
"@vue/reactivity": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
|
||||
"integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
|
||||
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.32",
|
||||
"@vue/runtime-core": "3.5.32",
|
||||
"@vue/shared": "3.5.32",
|
||||
"@vue/reactivity": "3.5.33",
|
||||
"@vue/runtime-core": "3.5.33",
|
||||
"@vue/shared": "3.5.33",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
|
||||
"integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
|
||||
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.32",
|
||||
"@vue/shared": "3.5.32"
|
||||
"@vue/compiler-ssr": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.32"
|
||||
"vue": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
|
||||
"integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
|
||||
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
@@ -2163,16 +2163,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -2184,9 +2174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2267,9 +2257,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
|
||||
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
@@ -2647,18 +2637,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepl-node": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.26.0.tgz",
|
||||
"integrity": "sha512-KaMZ248O/N2HrevQnsJoCoHxDUNBbsQ7+OZJa2YAOhym4qtVCNTzRst+4rLX9B9T84SVN4xVQSuJyFIdlI/x1w==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.16.0.tgz",
|
||||
"integrity": "sha512-sYrh8UngVHKBrgffLPl+ng065mlC+Ep3LBdSN3xqDBd4KdBZzgvdxHiHd9Pm9xQCI5pwyuCHyq0cDUW1T3vQUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=12.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.7.4",
|
||||
"form-data": "^3.0.4",
|
||||
"loglevel": ">=1.6.2",
|
||||
"uuid": "^8.3.2"
|
||||
"form-data": "^3.0.0",
|
||||
"loglevel": ">=1.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
@@ -2723,9 +2711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
|
||||
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -5746,16 +5734,6 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
@@ -6069,16 +6047,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
||||
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
||||
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.32",
|
||||
"@vue/compiler-sfc": "3.5.32",
|
||||
"@vue/runtime-dom": "3.5.32",
|
||||
"@vue/server-renderer": "3.5.32",
|
||||
"@vue/shared": "3.5.32"
|
||||
"@vue/compiler-dom": "3.5.33",
|
||||
"@vue/compiler-sfc": "3.5.33",
|
||||
"@vue/runtime-dom": "3.5.33",
|
||||
"@vue/server-renderer": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"deepl-node": "^1.18.0",
|
||||
"deepl-node": "^1.16.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vue": "^9.24.0",
|
||||
|
||||
+101
-18
@@ -28,25 +28,66 @@ export async function get(id) {
|
||||
}
|
||||
|
||||
// POST /api/users (create user)
|
||||
export async function create(user) {
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function create(user, options = {}) {
|
||||
const mergedHeaders = { ...(options.headers || {}) }
|
||||
const apiPath = getApiPath('users')
|
||||
const body = JSON.stringify({
|
||||
which: [],
|
||||
data: user
|
||||
})
|
||||
|
||||
const needsActorPasswordRetry = (err) =>
|
||||
state.user?.loginMethod === 'password' &&
|
||||
options.skipActorPasswordConfirm !== true &&
|
||||
mergedHeaders['X-Password'] === undefined &&
|
||||
err &&
|
||||
err.status === 401 &&
|
||||
typeof err.message === 'string' &&
|
||||
err.message.includes('X-Password')
|
||||
|
||||
try {
|
||||
const apiPath = getApiPath('users')
|
||||
const res = await fetchURL(apiPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
which: [],
|
||||
data: user
|
||||
})
|
||||
body,
|
||||
headers: mergedHeaders,
|
||||
})
|
||||
|
||||
if (res.status === 201) {
|
||||
return res.headers.get('Location')
|
||||
} else {
|
||||
throw new Error('Failed to create user')
|
||||
}
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error creating user')
|
||||
throw err
|
||||
throw new Error('Failed to create user')
|
||||
} catch (e) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
const location = await create(user, {
|
||||
...options,
|
||||
headers: {
|
||||
...mergedHeaders,
|
||||
'X-Password': encodeURIComponent(actorPassword),
|
||||
},
|
||||
skipActorPasswordConfirm: true,
|
||||
})
|
||||
resolve(location)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,15 +169,57 @@ export async function update(user, which = ['all'], options = {}) {
|
||||
}
|
||||
|
||||
// DELETE /api/users (remove user)
|
||||
export async function remove(id) {
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function remove(id, options = {}) {
|
||||
const mergedHeaders = { ...(options.headers || {}) }
|
||||
const apiPath = getApiPath('users', { id: id })
|
||||
|
||||
const needsActorPasswordRetry = (err) =>
|
||||
state.user?.loginMethod === 'password' &&
|
||||
options.skipActorPasswordConfirm !== true &&
|
||||
mergedHeaders['X-Password'] === undefined &&
|
||||
err &&
|
||||
err.status === 401 &&
|
||||
typeof err.message === 'string' &&
|
||||
err.message.includes('X-Password')
|
||||
|
||||
try {
|
||||
const apiPath = getApiPath('users', { id: id })
|
||||
await fetchURL(apiPath, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: mergedHeaders,
|
||||
})
|
||||
} catch (e) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
await remove(id, {
|
||||
...options,
|
||||
headers: {
|
||||
...mergedHeaders,
|
||||
'X-Password': encodeURIComponent(actorPassword),
|
||||
},
|
||||
skipActorPasswordConfirm: true,
|
||||
})
|
||||
resolve(undefined)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
notify.showError(err.message || `Failed to delete user with ID: ${id}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,6 @@ export default {
|
||||
deletePrompt() {
|
||||
mutations.showPrompt({
|
||||
name: "generic",
|
||||
pinned: true,
|
||||
props: {
|
||||
title: this.$t("general.delete"),
|
||||
body: this.$t("prompts.deleteUserMessage", { username: this.user.username }),
|
||||
@@ -383,7 +382,9 @@ export default {
|
||||
label: this.$t("general.delete"),
|
||||
action: async () => {
|
||||
try {
|
||||
await usersApi.remove(this.user.id);
|
||||
await usersApi.remove(this.user.id, {
|
||||
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
|
||||
});
|
||||
notify.showSuccessToast(this.$t("settings.userDeleted"));
|
||||
eventBus.emit('usersChanged');
|
||||
mutations.closeTopPrompt(); // close delete user prompt confirmation
|
||||
@@ -414,7 +415,12 @@ export default {
|
||||
notify.showError(this.$t("settings.userNotAdmin"));
|
||||
return;
|
||||
}
|
||||
await usersApi.create({ ...this.user, scopes: scopesToSend });
|
||||
await usersApi.create(
|
||||
{ ...this.user, scopes: scopesToSend },
|
||||
{
|
||||
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
|
||||
}
|
||||
);
|
||||
// Emit event to refresh user list
|
||||
eventBus.emit('usersChanged');
|
||||
// Close the prompt
|
||||
|
||||
@@ -92,8 +92,7 @@ export function getApiPath(path, params = {}, skipEncode = false, isPublic = fal
|
||||
const encodedParams = [];
|
||||
for (const key of paramKeys) {
|
||||
const value = params[key];
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
// Handle array values for repeated parameters
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
@@ -110,8 +109,7 @@ export function getApiPath(path, params = {}, skipEncode = false, isPublic = fal
|
||||
const queryParams = [];
|
||||
for (const key in params) {
|
||||
const value = params[key];
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
// Handle array values for repeated parameters
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
<div v-else class="loginOptions no-padding" key="loginForm">
|
||||
<div v-if="passwordAvailable" class="password-entry">
|
||||
<div v-if="passwordAvailable || ldapAvailable" class="password-entry">
|
||||
<div v-if="error !== ''" class="wrong-login card">
|
||||
<span>{{ $t("login.failedLogin") }}</span>
|
||||
<i class="no-select material-symbols-outlined tooltip-info-icon" @mouseenter="showTooltip($event, error)"
|
||||
@@ -216,6 +216,7 @@ export default {
|
||||
signup: () => globalVars.signup,
|
||||
oidcAvailable: () => globalVars.oidcAvailable,
|
||||
passwordAvailable: () => globalVars.passwordAvailable,
|
||||
ldapAvailable: () => globalVars.ldapAvailable,
|
||||
name: () => globalVars.name || "FileBrowser Quantum",
|
||||
loginIconUrl: () => globalVars.loginIcon,
|
||||
isDarkMode() {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { test, expect } from "../test-setup";
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* User PUT for admin-only fields returns 401 until X-Password is supplied; the UI opens password-prompt on top.
|
||||
* User POST/PUT/DELETE for sensitive actions return 401 until X-Password is supplied; the UI opens password-prompt on top.
|
||||
* Password must match tests/playwright/global-setup.ts (same as two factor auth check).
|
||||
*/
|
||||
async function confirmActorPasswordPrompt(page: Page) {
|
||||
const passwordModal = page.locator('div[aria-label="password-prompt"]');
|
||||
await expect(passwordModal).toBeVisible({ timeout: 3000 });
|
||||
const passwordModal = page.locator(
|
||||
'div[aria-label="password-prompt"]:not(.prompt-behind)'
|
||||
);
|
||||
await expect(passwordModal).toBeVisible();
|
||||
await passwordModal.locator("input").fill("admin");
|
||||
await passwordModal.locator('button[aria-label="Confirm"]').click();
|
||||
await expect(passwordModal).not.toBeVisible();
|
||||
@@ -32,7 +34,7 @@ test("create, check settings, and delete user (retry-safe name)", async ({
|
||||
)
|
||||
await page.locator('input[aria-label="Password2"]').fill('testpassword')
|
||||
|
||||
// Just create the user first (listen before click to avoid missing a fast 201).
|
||||
// usersApi.create tries POST without X-Password first; server returns 401, then password-prompt + retry (201).
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes('/api/users') &&
|
||||
@@ -40,6 +42,7 @@ test("create, check settings, and delete user (retry-safe name)", async ({
|
||||
resp.status() === 201
|
||||
);
|
||||
await page.locator('button[aria-label="Save"]').click();
|
||||
await confirmActorPasswordPrompt(page);
|
||||
await createResponse;
|
||||
|
||||
// We should be back on the settings page
|
||||
@@ -86,11 +89,12 @@ test("create, check settings, and delete user (retry-safe name)", async ({
|
||||
const genericModal = page.locator('div[aria-label="generic-prompt"]');
|
||||
await expect(genericModal).toBeVisible();
|
||||
await genericModal.locator('button[aria-label="Delete"]').click();
|
||||
await confirmActorPasswordPrompt(page);
|
||||
|
||||
// After deletion, we should be back on the settings page.
|
||||
await expect(page.locator('tr.item', { hasText: username })).not.toBeVisible();
|
||||
// usersApi.update tries PUT without X-Password first; server returns 401, then the UI retries with password (204).
|
||||
checkForErrors(0, 1);
|
||||
// usersApi.create/update/remove try without X-Password first; server returns 401, then the UI retries (201/204/200).
|
||||
checkForErrors(0, 3);
|
||||
})
|
||||
|
||||
test("two factor auth check", async ({ page, checkForErrors }) => {
|
||||
@@ -116,7 +120,9 @@ test("two factor auth check", async ({ page, checkForErrors }) => {
|
||||
await expect(twoFactorCheckbox).toBeChecked();
|
||||
await modal.locator('button[aria-label="Generate Code"]').click();
|
||||
|
||||
const passwordModal = page.locator('div[aria-label="password-prompt"]');
|
||||
const passwordModal = page.locator(
|
||||
'div[aria-label="password-prompt"]:not(.prompt-behind)'
|
||||
);
|
||||
// Must match the admin password used in tests/playwright/global-setup.ts (not testuser passwords).
|
||||
await passwordModal.locator('input').fill('admin');
|
||||
await passwordModal.locator('button[aria-label="Confirm"]').click();
|
||||
|
||||
Reference in new issue
Block a user