Merge branch 'main' into dev-vhost

Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
This commit is contained in:
Pikachu Ren
2026-04-08 14:19:08 +08:00
committed by GitHub
37 changed files with 2081 additions and 298 deletions

View File

@@ -61,20 +61,38 @@ jobs:
strategy:
matrix:
include:
- target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch
- target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch (exclude mips64le)
hash: "md5"
- target: "linux-!(arm*)-musl*" #musl-not-arm
flags: ""
goflags: ""
- target: "linux-(mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-compat-family
hash: "md5-linux-musl-mips"
flags: ""
goflags: ""
- target: "linux-!(arm*|mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-not-arm (exclude compat-family)
hash: "md5-linux-musl"
flags: ""
goflags: ""
- target: "linux-arm*-musl*" #musl-arm
hash: "md5-linux-musl-arm"
flags: ""
goflags: ""
- target: "windows-arm64" #win-arm64
hash: "md5-windows-arm64"
flags: ""
goflags: ""
- target: "windows7-*" #win7
hash: "md5-windows7"
flags: ""
goflags: "-tags=sqlite_cgo_compat"
- target: "android-*" #android
hash: "md5-android"
flags: ""
goflags: ""
- target: "freebsd-*" #freebsd
hash: "md5-freebsd"
flags: ""
goflags: ""
name: Beta Release
runs-on: ubuntu-latest
@@ -99,6 +117,7 @@ jobs:
uses: OpenListTeam/cgo-actions@v1.2.2
with:
targets: ${{ matrix.target }}
flags: ${{ matrix.flags || '-ldflags=' }}
musl-target-format: $os-$musl-$arch
github-token: ${{ secrets.GITHUB_TOKEN }}
out-dir: build
@@ -110,6 +129,8 @@ jobs:
github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit
github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag
github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling
env:
GOFLAGS: ${{ matrix.goflags }}
- name: Compress
run: |

View File

@@ -48,6 +48,19 @@ ldflags="\
-X 'github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=$webVersion' \
"
# Keep sqlite driver tag selection centralized to avoid target drift.
GetBuildTagsForTarget() {
local target="$1"
case "$target" in
linux-loong64|linux-mips|linux-mips64|linux-mips64le|linux-mipsle|linux-musl-loong64|linux-musl-mips|linux-musl-mips64|linux-musl-mips64le|linux-musl-mipsle|windows-386|windows7-386|windows7-amd64)
echo "jsoniter,sqlite_cgo_compat"
;;
*)
echo "jsoniter"
;;
esac
}
FetchWebRolling() {
pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/rolling\"")
pre_release_assets=$(echo "$pre_release_json" | jq -r '.assets[].browser_download_url')
@@ -110,6 +123,7 @@ BuildWin7() {
# Build for both 386 and amd64 architectures
for arch in "386" "amd64"; do
echo "building for windows7-${arch}"
build_tags=$(GetBuildTagsForTarget "windows7-${arch}")
export GOOS=windows
export GOARCH=${arch}
export CGO_ENABLED=1
@@ -124,7 +138,7 @@ BuildWin7() {
fi
# Use the patched Go compiler for Win7 compatibility
$(pwd)/go-win7/bin/go build -o "${1}-${arch}.exe" -ldflags="$ldflags" -tags=jsoniter .
$(pwd)/go-win7/bin/go build -o "${1}-${arch}.exe" -ldflags="$ldflags" -tags="$build_tags" .
done
}
@@ -193,11 +207,12 @@ BuildDockerMultiplatform() {
cgo_cc=${CGO_ARGS[$i]}
os=${os_arch%%-*}
arch=${os_arch##*-}
build_tags=$(GetBuildTagsForTarget "$os_arch")
export GOOS=$os
export GOARCH=$arch
export CC=${cgo_cc}
echo "building for $os_arch"
go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags=jsoniter .
go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags="$build_tags" .
done
DOCKER_ARM_ARCHES=(linux-arm/v6 linux-arm/v7)
@@ -237,6 +252,8 @@ BuildLoongGLIBC() {
local target_abi="$2"
local output_file="$1"
local oldWorldGoVersion="1.25.0"
local loong_tags
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
if [ "$target_abi" = "abi1.0" ]; then
echo building for linux-loong64-abi1.0
@@ -311,7 +328,7 @@ BuildLoongGLIBC() {
CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \
CGO_ENABLED=1 \
GOCACHE="$abi1_cache_dir" \
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
echo "Error: Build failed with patched Go compiler"
echo "Attempting retry with cache cleanup..."
env GOCACHE="$abi1_cache_dir" $(pwd)/go-loong64-abi1.0/bin/go clean -cache
@@ -320,7 +337,7 @@ BuildLoongGLIBC() {
CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \
CGO_ENABLED=1 \
GOCACHE="$abi1_cache_dir" \
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
$(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
echo "Error: Build failed again after cache cleanup"
echo "Build environment details:"
echo "GOOS=linux"
@@ -366,11 +383,11 @@ BuildLoongGLIBC() {
# Use standard Go compiler for new-world build
echo "Building with standard Go compiler for new-world ABI2.0..."
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
echo "Error: Build failed with standard Go compiler"
echo "Attempting retry with cache cleanup..."
go clean -cache
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then
if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then
echo "Error: Build failed again after cache cleanup"
echo "Build environment details:"
echo "GOOS=$GOOS"
@@ -391,6 +408,7 @@ BuildReleaseLinuxMusl() {
mkdir -p "build"
muslflags="--extldflags '-static -fpic' $ldflags"
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
# Keep mips-family targets enabled; sqlite driver selection is handled by Go build tags.
FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross mips-linux-musl-cross mips64-linux-musl-cross mips64el-linux-musl-cross mipsel-linux-musl-cross powerpc64le-linux-musl-cross s390x-linux-musl-cross loongarch64-linux-musl-cross)
for i in "${FILES[@]}"; do
url="${BASE}${i}.tgz"
@@ -403,12 +421,13 @@ BuildReleaseLinuxMusl() {
for i in "${!OS_ARCHES[@]}"; do
os_arch=${OS_ARCHES[$i]}
cgo_cc=${CGO_ARGS[$i]}
build_tags=$(GetBuildTagsForTarget "$os_arch")
echo building for ${os_arch}
export GOOS=${os_arch%%-*}
export GOARCH=${os_arch##*-}
export CC=${cgo_cc}
export CGO_ENABLED=1
go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter .
go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags="$build_tags" .
done
}

View File

@@ -34,8 +34,8 @@ func (d *Open123) Init(ctx context.Context) error {
d.UploadThread = 3
}
if d.RefreshToken != "" {
// refresh token 直接主动刷新
if (d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0) || (d.ClientID != "" && d.ClientSecret != "") {
// proactive refresh by renewapi or client credentials
d.AccessToken = ""
d.tm = &tokenManager{}
} else {

View File

@@ -6,9 +6,6 @@ import (
)
type Addition struct {
// refresh_token方式的AccessToken 【对个人开发者暂未开放】
RefreshToken string `json:"RefreshToken" required:"false"`
// 通过 https://www.123pan.com/developer 申请
ClientID string `json:"ClientID" required:"false"`
ClientSecret string `json:"ClientSecret" required:"false"`
@@ -16,6 +13,13 @@ type Addition struct {
// 直接写入AccessToken, AccessToken有过期时间不建议直接填写
AccessToken string `json:"AccessToken" required:"false"`
// refresh_token方式的AccessToken 【对个人开发者暂未开放】
RefreshToken string `json:"RefreshToken" required:"false"`
// 使用在线API
UseOnlineAPI bool `json:"use_online_api" default:"true"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/123cloud/renewapi"`
// 用户名+密码方式登录的AccessToken可以兼容
//Username string `json:"username" required:"false"`
//Password string `json:"password" required:"false"`

View File

@@ -1,7 +1,6 @@
package _123_open
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -13,10 +12,16 @@ import (
)
var (
AccessToken = "https://open-api.123pan.com/api/v1/access_token"
RefreshToken = "https://open-api.123pan.com/api/v1/oauth2/access_token"
AccessToken = "https://open-api.123pan.com/api/v1/access_token"
)
func expiresInToExpiredAt(expiresIn int64) (time.Time, error) {
if expiresIn <= 0 {
return time.Time{}, errors.New("invalid expires_in from official API")
}
return time.Now().UTC().Add(time.Duration(expiresIn) * time.Second), nil
}
type tokenManager struct {
// accessToken string
expiredAt time.Time
@@ -43,73 +48,82 @@ func (d *Open123) getAccessToken(forceRefresh bool) (string, error) {
}
func (d *Open123) flushAccessToken() error {
// directly send request to avoid deadlock
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"authorization": "Bearer " + d.AccessToken,
"platform": "open_platform",
"Content-Type": "application/json",
})
if d.ClientID != "" {
if d.RefreshToken != "" {
var resp RefreshTokenResp
req.SetQueryParam("client_id", d.ClientID)
if d.ClientSecret != "" {
req.SetQueryParam("client_secret", d.ClientSecret)
}
req.SetQueryParam("grant_type", "refresh_token")
req.SetQueryParam("refresh_token", d.RefreshToken)
req.SetResult(&resp)
res, err := req.Execute(http.MethodPost, RefreshToken)
if err != nil {
return err
}
body := res.Body()
var baseResp BaseResp
if err = json.Unmarshal(body, &baseResp); err != nil {
return err
}
if baseResp.Code != 0 {
return fmt.Errorf("get access token failed: %s", baseResp.Message)
}
d.AccessToken = resp.AccessToken
// add token expire time
d.tm.expiredAt = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
d.RefreshToken = resp.RefreshToken
op.MustSaveDriverStorage(d)
d.tm.blockRefresh = false
return nil
} else if d.ClientSecret != "" {
var resp AccessTokenResp
req.SetBody(base.Json{
"clientID": d.ClientID,
"clientSecret": d.ClientSecret,
})
req.SetResult(&resp)
res, err := req.Execute(http.MethodPost, AccessToken)
if err != nil {
return err
}
body := res.Body()
var baseResp BaseResp
if err = json.Unmarshal(body, &baseResp); err != nil {
return err
}
if baseResp.Code != 0 {
return fmt.Errorf("get access token failed: %s", baseResp.Message)
}
d.AccessToken = resp.Data.AccessToken
// parse token expire time
d.tm.expiredAt, err = time.Parse(time.RFC3339, resp.Data.ExpiredAt)
if err != nil {
return fmt.Errorf("parse expire time failed: %w", err)
}
op.MustSaveDriverStorage(d)
d.tm.blockRefresh = false
return nil
// Official app renewapi response contains access_token, refresh_token and expires_in.
if d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0 {
var resp RefreshTokenResp
_, err := base.RestyClient.R().
SetResult(&resp).
SetQueryParams(map[string]string{
"refresh_ui": d.RefreshToken,
"server_use": "true",
"driver_txt": "123cloud_oa",
}).
Get(d.APIAddress)
if err != nil {
return err
}
if resp.AccessToken == "" || resp.RefreshToken == "" {
errMessage := resp.ErrorDescription
if errMessage == "" {
errMessage = resp.Text
}
if errMessage == "" {
errMessage = resp.Message
}
if errMessage == "" {
errMessage = resp.Error
}
if errMessage != "" {
return fmt.Errorf("failed to refresh token: %s", errMessage)
}
return fmt.Errorf("empty access_token or refresh_token returned from official API")
}
expiredAt, err := expiresInToExpiredAt(resp.ExpiresIn)
if err != nil {
return err
}
d.AccessToken = resp.AccessToken
d.RefreshToken = resp.RefreshToken
d.tm.expiredAt = expiredAt
op.MustSaveDriverStorage(d)
d.tm.blockRefresh = false
return nil
}
// Developer API response contains code/message/data(accessToken, expiredAt).
if d.ClientID != "" && d.ClientSecret != "" {
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"platform": "open_platform",
"Content-Type": "application/json",
})
var resp AccessTokenResp
req.SetBody(base.Json{
"clientID": d.ClientID,
"clientSecret": d.ClientSecret,
})
req.SetResult(&resp)
_, err := req.Execute(http.MethodPost, AccessToken)
if err != nil {
return err
}
if resp.Code != 0 {
return fmt.Errorf("get access token failed: %s", resp.Message)
}
if resp.Data.AccessToken == "" || resp.Data.ExpiredAt == "" {
return errors.New("invalid token payload from developer API")
}
expiredAt, err := time.Parse(time.RFC3339, resp.Data.ExpiredAt)
if err != nil {
return fmt.Errorf("parse expire time failed: %w", err)
}
d.AccessToken = resp.Data.AccessToken
d.tm.expiredAt = expiredAt.UTC()
op.MustSaveDriverStorage(d)
d.tm.blockRefresh = false
return nil
}
return errors.New("no valid authentication method available")
}

View File

@@ -125,11 +125,14 @@ type AccessTokenResp struct {
}
type RefreshTokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
Code int `json:"code"`
Message string `json:"message"`
ErrorDescription string `json:"error_description"`
Error string `json:"error"`
Text string `json:"text"`
}
type UserInfoResp struct {

View File

@@ -46,6 +46,9 @@ func (d *CloudreveV4) Init(ctx context.Context) error {
if d.ref != nil {
return nil
}
if d.isShare() {
return nil
}
if d.canLogin() {
return d.login()
}

View File

@@ -33,6 +33,7 @@ const (
CodeLoginRequired = http.StatusUnauthorized
CodePathNotExist = 40016 // Path not exist
CodeCredentialInvalid = 40020 // Failed to issue token
// IncorrectSharePassword = 40069 // Incorrect share password
)
var (
@@ -277,9 +278,16 @@ func (d *CloudreveV4) parseJWT(token string, jwt any) error {
return nil
}
func (d *CloudreveV4) isShare() bool {
return strings.HasSuffix(d.GetRootPath(), "@share")
}
// check if token is expired
// https://github.com/cloudreve/frontend/blob/ddfacc1c31c49be03beb71de4cc114c8811038d6/src/session/index.ts#L177-L200
func (d *CloudreveV4) isTokenExpired() bool {
if d.isShare() {
return false
}
if d.RefreshToken == "" {
// login again if username and password is set
if d.canLogin() {

View File

@@ -84,7 +84,7 @@ func (d *OpenList) List(ctx context.Context, dir model.Obj, args model.ListArgs)
},
Path: dir.GetPath(),
Password: d.MetaPassword,
Refresh: false,
Refresh: d.PassRefreshFlagToUpsteam && args.Refresh,
})
})
if err != nil {

View File

@@ -7,14 +7,15 @@ import (
type Addition struct {
driver.RootPath
Address string `json:"url" required:"true"`
MetaPassword string `json:"meta_password"`
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
PassIPToUpsteam bool `json:"pass_ip_to_upsteam" default:"true"`
PassUAToUpsteam bool `json:"pass_ua_to_upsteam" default:"true"`
ForwardArchiveReq bool `json:"forward_archive_requests" default:"true"`
Address string `json:"url" required:"true"`
MetaPassword string `json:"meta_password"`
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
PassIPToUpsteam bool `json:"pass_ip_to_upsteam" default:"true"`
PassUAToUpsteam bool `json:"pass_ua_to_upsteam" default:"true"`
ForwardArchiveReq bool `json:"forward_archive_requests" default:"true"`
PassRefreshFlagToUpsteam bool `json:"pass_refresh_flag_to_upsteam" default:"false"`
}
var config = driver.Config{

8
go.mod
View File

@@ -41,6 +41,7 @@ require (
github.com/foxxorcat/weiyun-sdk-go v0.1.4
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/go-resty/resty/v2 v2.16.5
github.com/go-webauthn/webauthn v0.13.4
github.com/golang-jwt/jwt/v4 v4.5.2
@@ -104,10 +105,12 @@ require (
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
@@ -123,11 +126,16 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/relvacode/iso8601 v1.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/mod v0.30.0 // indirect
gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
require (

19
go.sum
View File

@@ -246,6 +246,8 @@ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564 h1:I6KUy4CI6hHjqnyJLNCEi7YHVMkwwtfSr2k9splgdSM=
github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564/go.mod h1:yekO+3ZShy19S+bsmnERmznGy9Rfg6dWWWpiGJjNAz8=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
@@ -279,6 +281,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
@@ -335,6 +341,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20230405160723-4a4c7d95572b h1:Qcx5LM0fSiks9uCyFZwDBUasd3lxd1RM0GYpL+Li5o4=
github.com/google/pprof v0.0.0-20230405160723-4a4c7d95572b/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -574,6 +582,9 @@ github.com/rclone/rclone v1.70.3 h1:rg/WNh4DmSVZyKP2tHZ4lAaWEyMi7h/F0r7smOMA3IE=
github.com/rclone/rclone v1.70.3/go.mod h1:nLyN+hpxAsQn9Rgt5kM774lcRDad82x/KqQeBZ83cMo=
github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU=
github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -849,6 +860,14 @@ gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0=
lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
resty.dev/v3 v3.0.0-beta.2 h1:xu4mGAdbCLuc3kbk7eddWfWm4JfhwDtdapwss5nCjnQ=
resty.dev/v3 v3.0.0-beta.2/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=

View File

@@ -12,7 +12,6 @@ import (
log "github.com/sirupsen/logrus"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
@@ -41,7 +40,7 @@ func InitDB() {
var dB *gorm.DB
var err error
if flags.Dev {
dB, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), gormConfig)
dB, err = gorm.Open(openSQLite("file::memory:?cache=shared"), gormConfig)
conf.Conf.Database.Type = "sqlite3"
} else {
database := conf.Conf.Database
@@ -51,7 +50,7 @@ func InitDB() {
if !(strings.HasSuffix(database.DBFile, ".db") && len(database.DBFile) > 3) {
log.Fatalf("db name error.")
}
dB, err = gorm.Open(sqlite.Open(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental",
dB, err = gorm.Open(openSQLite(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental",
database.DBFile)), gormConfig)
}
case "mysql":

View File

@@ -0,0 +1,12 @@
//go:build !sqlite_cgo_compat && !(linux && (mips || mips64 || mips64le || mipsle || loong64)) && !(windows && 386)
package bootstrap
import (
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func openSQLite(dsn string) gorm.Dialector {
return sqlite.Open(dsn)
}

View File

@@ -0,0 +1,12 @@
//go:build sqlite_cgo_compat || (linux && (mips || mips64 || mips64le || mipsle || loong64)) || (windows && 386)
package bootstrap
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openSQLite(dsn string) gorm.Dialector {
return sqlite.Open(dsn)
}

View File

@@ -2,13 +2,14 @@ package fs
import (
"context"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"path"
)
// List files
@@ -43,7 +44,29 @@ func list(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error)
om.InitHideReg(meta.Hide)
}
objs := om.Merge(_objs, virtualFiles...)
return objs, nil
objs, err = filterReadableObjs(objs, user, path, meta)
return objs, err
}
func filterReadableObjs(objs []model.Obj, user *model.User, reqPath string, parentMeta *model.Meta) ([]model.Obj, error) {
var result []model.Obj
for _, obj := range objs {
var meta *model.Meta
objPath := path.Join(reqPath, obj.GetName())
if obj.IsDir() {
var err error
meta, err = op.GetNearestMeta(objPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return result, err
}
} else {
meta = parentMeta
}
if common.CanRead(user, meta, objPath) {
result = append(result, obj)
}
}
return result, nil
}
func whetherHide(user *model.User, meta *model.Meta, path string) bool {
@@ -60,7 +83,7 @@ func whetherHide(user *model.User, meta *model.Meta, path string) bool {
return false
}
// if meta doesn't apply to sub_folder, don't hide
if !utils.PathEqual(meta.Path, path) && !meta.HSub {
if !common.MetaCoversPath(meta.Path, path, meta.HSub) {
return false
}
// if is guest, hide

151
internal/fs/list_test.go Normal file
View File

@@ -0,0 +1,151 @@
package fs
import (
"testing"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
func TestWhetherHide(t *testing.T) {
tests := []struct {
name string
user *model.User
meta *model.Meta
path string
want bool
reason string
}{
{
name: "nil user",
user: nil,
meta: &model.Meta{
Path: "/folder",
Hide: "secret",
HSub: true,
},
path: "/folder",
want: false,
reason: "nil user (treated as admin) should not hide",
},
{
name: "user with can_see_hides permission",
user: &model.User{
Role: model.GENERAL,
Permission: 1, // bit 0 set = can see hides
},
meta: &model.Meta{
Path: "/folder",
Hide: "secret",
HSub: true,
},
path: "/folder",
want: false,
reason: "user with can_see_hides permission should not hide",
},
{
name: "nil meta",
user: &model.User{
Role: model.GUEST,
},
meta: nil,
path: "/folder",
want: false,
reason: "nil meta should not hide",
},
{
name: "empty hide string",
user: &model.User{
Role: model.GUEST,
},
meta: &model.Meta{
Path: "/folder",
Hide: "",
HSub: true,
},
path: "/folder",
want: false,
reason: "empty hide string should not hide",
},
{
name: "exact path match with HSub=false",
user: &model.User{
Role: model.GUEST,
},
meta: &model.Meta{
Path: "/folder",
Hide: "secret",
HSub: false,
},
path: "/folder",
want: true,
reason: "exact path match should hide for guest",
},
{
name: "sub path with HSub=true",
user: &model.User{
Role: model.GUEST,
},
meta: &model.Meta{
Path: "/folder",
Hide: "secret",
HSub: true,
},
path: "/folder/subfolder",
want: true,
reason: "sub path with HSub=true should hide for guest",
},
{
name: "sub path with HSub=false",
user: &model.User{
Role: model.GUEST,
},
meta: &model.Meta{
Path: "/folder",
Hide: "secret",
HSub: false,
},
path: "/folder/subfolder",
want: false,
reason: "sub path with HSub=false should not hide",
},
{
name: "non-sub path with HSub=true",
user: &model.User{
Role: model.GUEST,
},
meta: &model.Meta{
Path: "/folder",
Hide: "secret",
HSub: true,
},
path: "/other",
want: false,
reason: "non-sub path should not hide even with HSub=true",
},
{
name: "user without can_see_hides permission",
user: &model.User{
Role: model.GENERAL,
Permission: 0, // bit 0 not set = cannot see hides
},
meta: &model.Meta{
Path: "/folder",
Hide: "secret",
HSub: true,
},
path: "/folder",
want: true,
reason: "user without can_see_hides permission should hide",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := whetherHide(tt.user, tt.meta, tt.path)
if got != tt.want {
t.Errorf("whetherHide() = %v, want %v\nReason: %s",
got, tt.want, tt.reason)
}
})
}
}

View File

@@ -1,16 +1,20 @@
package model
type Meta struct {
ID uint `json:"id" gorm:"primaryKey"`
Path string `json:"path" gorm:"unique" binding:"required"`
Password string `json:"password"`
PSub bool `json:"p_sub"`
Write bool `json:"write"`
WSub bool `json:"w_sub"`
Hide string `json:"hide"`
HSub bool `json:"h_sub"`
Readme string `json:"readme"`
RSub bool `json:"r_sub"`
Header string `json:"header"`
HeaderSub bool `json:"header_sub"`
ID uint `json:"id" gorm:"primaryKey"`
Path string `json:"path" gorm:"unique" binding:"required"`
ReadUsers []uint `json:"read_users" gorm:"serializer:json"`
ReadUsersSub bool `json:"read_users_sub"`
WriteUsers []uint `json:"write_users" gorm:"serializer:json"`
WriteUsersSub bool `json:"write_users_sub"`
Password string `json:"password"`
PSub bool `json:"p_sub"`
Write bool `json:"write"`
WSub bool `json:"w_sub"`
Hide string `json:"hide"`
HSub bool `json:"h_sub"`
Readme string `json:"readme"`
RSub bool `json:"r_sub"`
Header string `json:"header"`
HeaderSub bool `json:"header_sub"`
}

View File

@@ -123,12 +123,12 @@ func (u *User) CanAddOfflineDownloadTasks() bool {
return CanAddOfflineDownloadTasks(u.Permission)
}
func CanWrite(permission int32) bool {
func CanWriteContent(permission int32) bool {
return (permission>>3)&1 == 1
}
func (u *User) CanWrite() bool {
return CanWrite(u.Permission)
func (u *User) CanWriteContent() bool {
return CanWriteContent(u.Permission)
}
func CanRename(permission int32) bool {

View File

@@ -147,11 +147,11 @@ func (t *DownloadTask) Update() (bool, error) {
if err != nil {
t.callStatusRetried++
log.Errorf("failed to get status of %s, retried %d times", t.ID, t.callStatusRetried)
if t.callStatusRetried > 5 {
return true, errors.Errorf("failed to get status of %s, retried %d times", t.ID, t.callStatusRetried)
}
return false, nil
}
if t.callStatusRetried > 5 {
return true, errors.Errorf("failed to get status of %s, retried %d times", t.ID, t.callStatusRetried)
}
t.callStatusRetried = 0
t.SetProgress(info.Progress)
t.SetTotalBytes(info.TotalBytes)

View File

@@ -10,7 +10,7 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
mapset "github.com/deckarep/golang-set/v2"
"gorm.io/driver/sqlite"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)

View File

@@ -2,6 +2,7 @@ package common
import (
"path"
"slices"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
@@ -17,24 +18,39 @@ func IsStorageSignEnabled(rawPath string) bool {
return storage != nil && storage.GetStorage().EnableSign
}
func CanWrite(meta *model.Meta, path string) bool {
func CanRead(user *model.User, meta *model.Meta, path string) bool {
// nil user is treated as internal/system context and bypasses per-user read restrictions
if user == nil {
return true
}
if meta != nil && len(meta.ReadUsers) > 0 && !slices.Contains(meta.ReadUsers, user.ID) && MetaCoversPath(meta.Path, path, meta.ReadUsersSub) {
return false
}
return true
}
func CanWrite(user *model.User, meta *model.Meta, path string) bool {
// nil user is treated as internal/system context and bypasses per-user write restrictions
if user == nil {
return true
}
if meta != nil && len(meta.WriteUsers) > 0 && !slices.Contains(meta.WriteUsers, user.ID) && MetaCoversPath(meta.Path, path, meta.WriteUsersSub) {
return false
}
return true
}
func CanWriteContentBypassUserPerms(meta *model.Meta, path string) bool {
if meta == nil || !meta.Write {
return false
}
return meta.WSub || meta.Path == path
}
func IsApply(metaPath, reqPath string, applySub bool) bool {
if utils.PathEqual(metaPath, reqPath) {
return true
}
return utils.IsSubPath(metaPath, reqPath) && applySub
return MetaCoversPath(meta.Path, path, meta.WSub)
}
func CanAccess(user *model.User, meta *model.Meta, reqPath string, password string) bool {
// if the reqPath is in hide (only can check the nearest meta) and user can't see hides, can't access
if meta != nil && !user.CanSeeHides() && meta.Hide != "" &&
IsApply(meta.Path, path.Dir(reqPath), meta.HSub) { // the meta should apply to the parent of current path
MetaCoversPath(meta.Path, path.Dir(reqPath), meta.HSub) { // the meta should apply to the parent of current path
for _, hide := range strings.Split(meta.Hide, "\n") {
re := regexp2.MustCompile(hide, regexp2.None)
if isMatch, _ := re.MatchString(path.Base(reqPath)); isMatch {
@@ -42,6 +58,9 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri
}
}
}
if !CanRead(user, meta, reqPath) {
return false
}
// if is not guest and can access without password
if user.CanAccessWithoutPassword() {
return true
@@ -51,13 +70,20 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri
return true
}
// if meta doesn't apply to sub_folder, can access
if !utils.PathEqual(meta.Path, reqPath) && !meta.PSub {
if !MetaCoversPath(meta.Path, reqPath, meta.PSub) {
return true
}
// validate password
return meta.Password == password
}
func MetaCoversPath(metaPath, reqPath string, applyToSubFolder bool) bool {
if utils.PathEqual(metaPath, reqPath) {
return true
}
return utils.IsSubPath(metaPath, reqPath) && applyToSubFolder
}
// ShouldProxy TODO need optimize
// when should be proxy?
// 1. config.MustProxy()

View File

@@ -1,24 +1,986 @@
package common
import "testing"
import (
"testing"
func TestIsApply(t *testing.T) {
datas := []struct {
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
func TestCoversPath(t *testing.T) {
tests := []struct {
name string
metaPath string
reqPath string
applySub bool
result bool
want bool
}{
{
metaPath: "/",
reqPath: "/test",
name: "exact path match with applySub=false",
metaPath: "/folder",
reqPath: "/folder",
applySub: false,
want: true,
},
{
name: "exact path match with applySub=true",
metaPath: "/folder",
reqPath: "/folder",
applySub: true,
result: true,
want: true,
},
{
name: "sub path with applySub=true",
metaPath: "/folder",
reqPath: "/folder/subfolder",
applySub: true,
want: true,
},
{
name: "sub path with applySub=false",
metaPath: "/folder",
reqPath: "/folder/subfolder",
applySub: false,
want: false,
},
{
name: "non-sub path with applySub=true",
metaPath: "/folder",
reqPath: "/other",
applySub: true,
want: false,
},
{
name: "non-sub path with applySub=false",
metaPath: "/folder",
reqPath: "/other",
applySub: false,
want: false,
},
{
name: "root path covers all with applySub=true",
metaPath: "/",
reqPath: "/any/deep/path",
applySub: true,
want: true,
},
{
name: "root path exact match",
metaPath: "/",
reqPath: "/",
applySub: false,
want: true,
},
{
name: "deep sub path with applySub=true",
metaPath: "/folder",
reqPath: "/folder/sub1/sub2/file.txt",
applySub: true,
want: true,
},
{
name: "sibling paths with applySub=true",
metaPath: "/folder1",
reqPath: "/folder2",
applySub: true,
want: false,
},
}
for i, data := range datas {
if IsApply(data.metaPath, data.reqPath, data.applySub) != data.result {
t.Errorf("TestIsApply %d failed", i)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MetaCoversPath(tt.metaPath, tt.reqPath, tt.applySub)
if got != tt.want {
t.Errorf("MetaCoversPath(%q, %q, %v) = %v, want %v",
tt.metaPath, tt.reqPath, tt.applySub, got, tt.want)
}
})
}
}
func TestCanWriteContentIgnoringUserPerms(t *testing.T) {
tests := []struct {
name string
meta *model.Meta
path string
want bool
reason string
}{
{
name: "nil meta",
meta: nil,
path: "/any",
want: false,
reason: "nil meta should deny write",
},
{
name: "meta.Write=false",
meta: &model.Meta{
Path: "/folder",
Write: false,
},
path: "/folder",
want: false,
reason: "Write=false should deny write",
},
{
name: "exact path match with WSub=false",
meta: &model.Meta{
Path: "/folder",
Write: true,
WSub: false,
},
path: "/folder",
want: true,
reason: "exact path match should allow write",
},
{
name: "sub path with WSub=true",
meta: &model.Meta{
Path: "/folder",
Write: true,
WSub: true,
},
path: "/folder/subfolder",
want: true,
reason: "sub path with WSub=true should allow write",
},
{
name: "sub path with WSub=false (BEHAVIOR CHANGE)",
meta: &model.Meta{
Path: "/folder",
Write: true,
WSub: false,
},
path: "/folder/subfolder",
want: false,
reason: "sub path with WSub=false should deny write (fixed bug)",
},
{
name: "non-sub path with WSub=true",
meta: &model.Meta{
Path: "/folder",
Write: true,
WSub: true,
},
path: "/other",
want: false,
reason: "non-sub path should deny write even with WSub=true",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CanWriteContentBypassUserPerms(tt.meta, tt.path)
if got != tt.want {
t.Errorf("CanWriteContentBypassUserPerms() = %v, want %v\nReason: %s",
got, tt.want, tt.reason)
}
})
}
}
func TestCanRead(t *testing.T) {
tests := []struct {
name string
user *model.User
meta *model.Meta
path string
want bool
reason string
}{
{
name: "nil user should allow access",
user: nil,
meta: nil,
path: "/any",
want: true,
reason: "nil user represents internal/system context and bypasses per-user read restrictions",
},
{
name: "nil meta should allow access",
user: &model.User{
ID: 1,
},
meta: nil,
path: "/any",
want: true,
reason: "nil meta means no restrictions",
},
{
name: "empty ReadUsers list should allow access",
user: &model.User{
ID: 1,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{},
},
path: "/folder",
want: true,
reason: "empty ReadUsers means no user-level restrictions",
},
{
name: "user in ReadUsers list with exact path match",
user: &model.User{
ID: 1,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2, 3},
ReadUsersSub: false,
},
path: "/folder",
want: true,
reason: "user ID 1 is in ReadUsers list",
},
{
name: "user not in ReadUsers list with exact path match",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2, 3},
ReadUsersSub: false,
},
path: "/folder",
want: false,
reason: "user ID 5 is not in ReadUsers list and path matches",
},
{
name: "user not in ReadUsers list with ReadUsersSub=true for sub path",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2, 3},
ReadUsersSub: true,
},
path: "/folder/subfolder",
want: false,
reason: "user ID 5 is not in ReadUsers list and ReadUsersSub applies to sub paths",
},
{
name: "user not in ReadUsers list with ReadUsersSub=false for sub path",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2, 3},
ReadUsersSub: false,
},
path: "/folder/subfolder",
want: true,
reason: "ReadUsersSub=false means restriction doesn't apply to sub paths",
},
{
name: "user in ReadUsers list with ReadUsersSub=true for sub path",
user: &model.User{
ID: 2,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2, 3},
ReadUsersSub: true,
},
path: "/folder/subfolder/deep",
want: true,
reason: "user ID 2 is in ReadUsers list so can access sub paths",
},
{
name: "user not in ReadUsers list for different path",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2, 3},
ReadUsersSub: false,
},
path: "/other",
want: true,
reason: "meta path doesn't match request path, so restriction doesn't apply",
},
{
name: "root level restriction with ReadUsersSub=true",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/",
ReadUsers: []uint{1, 2, 3},
ReadUsersSub: true,
},
path: "/any/deep/path",
want: false,
reason: "root level restriction with ReadUsersSub affects all paths",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CanRead(tt.user, tt.meta, tt.path)
if got != tt.want {
t.Errorf("CanRead() = %v, want %v\nReason: %s\nUser ID: %v, Meta: %+v, Path: %s",
got, tt.want, tt.reason, getUserID(tt.user), tt.meta, tt.path)
}
})
}
}
func TestCanWrite(t *testing.T) {
tests := []struct {
name string
user *model.User
meta *model.Meta
path string
want bool
reason string
}{
{
name: "nil user should allow access",
user: nil,
meta: nil,
path: "/any",
want: true,
reason: "nil user represents internal/system context and bypasses per-user write restrictions",
},
{
name: "nil meta should allow access",
user: &model.User{
ID: 1,
},
meta: nil,
path: "/any",
want: true,
reason: "nil meta means no restrictions",
},
{
name: "empty WriteUsers list should allow access",
user: &model.User{
ID: 1,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{},
},
path: "/folder",
want: true,
reason: "empty WriteUsers means no user-level restrictions",
},
{
name: "user in WriteUsers list with exact path match",
user: &model.User{
ID: 1,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{1, 2, 3},
WriteUsersSub: false,
},
path: "/folder",
want: true,
reason: "user ID 1 is in WriteUsers list",
},
{
name: "user not in WriteUsers list with exact path match",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{1, 2, 3},
WriteUsersSub: false,
},
path: "/folder",
want: false,
reason: "user ID 5 is not in WriteUsers list and path matches",
},
{
name: "user not in WriteUsers list with WriteUsersSub=true for sub path",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{1, 2, 3},
WriteUsersSub: true,
},
path: "/folder/subfolder",
want: false,
reason: "user ID 5 is not in WriteUsers list and WriteUsersSub applies to sub paths",
},
{
name: "user not in WriteUsers list with WriteUsersSub=false for sub path",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{1, 2, 3},
WriteUsersSub: false,
},
path: "/folder/subfolder",
want: true,
reason: "WriteUsersSub=false means restriction doesn't apply to sub paths",
},
{
name: "user in WriteUsers list with WriteUsersSub=true for sub path",
user: &model.User{
ID: 2,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{1, 2, 3},
WriteUsersSub: true,
},
path: "/folder/subfolder/deep",
want: true,
reason: "user ID 2 is in WriteUsers list so can write to sub paths",
},
{
name: "user not in WriteUsers list for different path",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{1, 2, 3},
WriteUsersSub: false,
},
path: "/other",
want: true,
reason: "meta path doesn't match request path, so restriction doesn't apply",
},
{
name: "multiple users with mixed permissions",
user: &model.User{
ID: 10,
},
meta: &model.Meta{
Path: "/folder",
WriteUsers: []uint{1, 5, 10, 15},
WriteUsersSub: true,
},
path: "/folder/file.txt",
want: true,
reason: "user ID 10 is in WriteUsers list",
},
{
name: "write restriction at root level",
user: &model.User{
ID: 5,
},
meta: &model.Meta{
Path: "/",
WriteUsers: []uint{1},
WriteUsersSub: true,
},
path: "/any/path",
want: false,
reason: "only user ID 1 can write when root has WriteUsers restriction",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CanWrite(tt.user, tt.meta, tt.path)
if got != tt.want {
t.Errorf("CanWrite() = %v, want %v\nReason: %s\nUser ID: %v, Meta: %+v, Path: %s",
got, tt.want, tt.reason, getUserID(tt.user), tt.meta, tt.path)
}
})
}
}
func TestCanAccessWithReadPermissions(t *testing.T) {
tests := []struct {
name string
user *model.User
meta *model.Meta
reqPath string
password string
want bool
reason string
}{
{
name: "user with read permission and correct password",
user: &model.User{
ID: 1,
Role: model.GENERAL,
Permission: 0,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2},
ReadUsersSub: true,
Password: "secret",
PSub: true,
},
reqPath: "/folder/file.txt",
password: "secret",
want: true,
reason: "user in ReadUsers list with correct password",
},
{
name: "user without read permission even with correct password",
user: &model.User{
ID: 5,
Role: model.GENERAL,
Permission: 0,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2},
ReadUsersSub: true,
Password: "secret",
PSub: true,
},
reqPath: "/folder/file.txt",
password: "secret",
want: false,
reason: "user not in ReadUsers list, should be denied before password check",
},
{
name: "user with read permission but wrong password",
user: &model.User{
ID: 1,
Role: model.GENERAL,
Permission: 0,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2},
ReadUsersSub: true,
Password: "secret",
PSub: true,
},
reqPath: "/folder/file.txt",
password: "wrong",
want: false,
reason: "user in ReadUsers list but wrong password",
},
{
name: "user without read permission and no password",
user: &model.User{
ID: 5,
Role: model.GENERAL,
Permission: 0,
},
meta: &model.Meta{
Path: "/folder",
ReadUsers: []uint{1, 2},
ReadUsersSub: true,
},
reqPath: "/folder/file.txt",
password: "",
want: false,
reason: "user not in ReadUsers list should be denied",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CanAccess(tt.user, tt.meta, tt.reqPath, tt.password)
if got != tt.want {
t.Errorf("CanAccess() = %v, want %v\nReason: %s",
got, tt.want, tt.reason)
}
})
}
}
// Helper function to safely get user ID
func getUserID(user *model.User) uint {
if user == nil {
return 0
}
return user.ID
}
// TestWritePermissionCombinations tests the combined permission check logic
// that is actually used in the codebase:
//
// if !user.CanWriteContent() && !CanWriteContentBypassUserPerms(meta, path) {
// deny
// }
// if !CanWrite(user, meta, path) {
// deny
// }
//
// This ensures the three-layer permission system works correctly:
// 1. User-level global write permission (CanWriteContent)
// 2. Meta-level global write permission (CanWriteContentBypassUserPerms)
// 3. Meta-level user whitelist (CanWrite)
func TestWritePermissionCombinations(t *testing.T) {
tests := []struct {
name string
user *model.User
meta *model.Meta
path string
want bool
reason string
checkFirstLayer bool // whether first layer should pass
checkSecondLayer bool // whether second layer should pass
expectedDenyReason string
}{
// === Scenario 1: User has global write permission ===
{
name: "user has CanWriteContent + in WriteUsers whitelist",
user: &model.User{
ID: 1,
Permission: 1 << 3, // CanWriteContent = true
},
meta: &model.Meta{
Path: "/folder",
Write: false,
WriteUsers: []uint{1},
WriteUsersSub: false,
},
path: "/folder",
want: true,
reason: "user has global write permission AND is in whitelist",
checkFirstLayer: true,
checkSecondLayer: true,
expectedDenyReason: "",
},
{
name: "user has CanWriteContent but NOT in WriteUsers whitelist",
user: &model.User{
ID: 1,
Permission: 1 << 3, // CanWriteContent = true
},
meta: &model.Meta{
Path: "/folder",
Write: false,
WriteUsers: []uint{2, 3}, // user 1 not in list
WriteUsersSub: false,
},
path: "/folder",
want: false,
reason: "even with global write permission, must pass whitelist check",
checkFirstLayer: true,
checkSecondLayer: false,
expectedDenyReason: "whitelist check failed",
},
// === Scenario 2: User lacks global permission but meta.Write=true ===
{
name: "no CanWriteContent + meta.Write=true + in WriteUsers",
user: &model.User{
ID: 1,
Permission: 0, // CanWriteContent = false
},
meta: &model.Meta{
Path: "/folder",
Write: true, // bypass enabled
WSub: false,
WriteUsers: []uint{1},
WriteUsersSub: false,
},
path: "/folder",
want: true,
reason: "meta.Write bypasses user permission check, and user is in whitelist",
checkFirstLayer: true,
checkSecondLayer: true,
expectedDenyReason: "",
},
{
name: "no CanWriteContent + meta.Write=true + NOT in WriteUsers (KEY TEST)",
user: &model.User{
ID: 5,
Permission: 0, // CanWriteContent = false
},
meta: &model.Meta{
Path: "/folder",
Write: true, // bypass enabled
WSub: false,
WriteUsers: []uint{1, 2, 3}, // user 5 not in list
WriteUsersSub: false,
},
path: "/folder",
want: false,
reason: "CRITICAL: meta.Write cannot bypass whitelist check (new behavior)",
checkFirstLayer: true,
checkSecondLayer: false,
expectedDenyReason: "whitelist check failed even with meta.Write=true",
},
// === Scenario 3: Both checks fail ===
{
name: "no CanWriteContent + meta.Write=false",
user: &model.User{
ID: 1,
Permission: 0, // CanWriteContent = false
},
meta: &model.Meta{
Path: "/folder",
Write: false, // no bypass
WriteUsers: []uint{1},
WriteUsersSub: false,
},
path: "/folder",
want: false,
reason: "denied at first layer: no global permission and no bypass",
checkFirstLayer: false,
checkSecondLayer: false,
expectedDenyReason: "first layer check failed",
},
// === Scenario 4: Empty WriteUsers (no whitelist restriction) ===
{
name: "user has CanWriteContent + empty WriteUsers",
user: &model.User{
ID: 1,
Permission: 1 << 3, // CanWriteContent = true
},
meta: &model.Meta{
Path: "/folder",
Write: false,
WriteUsers: []uint{}, // empty = no restriction
WriteUsersSub: false,
},
path: "/folder",
want: true,
reason: "empty WriteUsers means no whitelist restriction",
checkFirstLayer: true,
checkSecondLayer: true,
expectedDenyReason: "",
},
{
name: "no CanWriteContent + meta.Write=true + empty WriteUsers",
user: &model.User{
ID: 1,
Permission: 0,
},
meta: &model.Meta{
Path: "/folder",
Write: true,
WSub: false,
WriteUsers: []uint{}, // empty = no restriction
WriteUsersSub: false,
},
path: "/folder",
want: true,
reason: "meta.Write bypasses first check, empty whitelist passes second",
checkFirstLayer: true,
checkSecondLayer: true,
expectedDenyReason: "",
},
// === Scenario 5: Nil meta (no restrictions) ===
{
name: "user has CanWriteContent + nil meta",
user: &model.User{
ID: 1,
Permission: 1 << 3,
},
meta: nil,
path: "/folder",
want: true,
reason: "nil meta means no restrictions",
checkFirstLayer: true,
checkSecondLayer: true,
expectedDenyReason: "",
},
{
name: "no CanWriteContent + nil meta",
user: &model.User{
ID: 1,
Permission: 0,
},
meta: nil,
path: "/folder",
want: false,
reason: "nil meta cannot bypass lack of user permission",
checkFirstLayer: false,
checkSecondLayer: true, // would pass if first layer passed
expectedDenyReason: "first layer check failed",
},
// === Scenario 6: Sub-directory inheritance ===
{
name: "meta.Write with WSub=true for subdirectory",
user: &model.User{
ID: 1,
Permission: 0,
},
meta: &model.Meta{
Path: "/folder",
Write: true,
WSub: true, // applies to subdirectories
WriteUsers: []uint{1},
WriteUsersSub: true,
},
path: "/folder/subfolder",
want: true,
reason: "WSub=true applies meta.Write to subdirectories",
checkFirstLayer: true,
checkSecondLayer: true,
expectedDenyReason: "",
},
{
name: "meta.Write with WSub=false for subdirectory",
user: &model.User{
ID: 1,
Permission: 0,
},
meta: &model.Meta{
Path: "/folder",
Write: true,
WSub: false, // does NOT apply to subdirectories
WriteUsers: []uint{1},
WriteUsersSub: false,
},
path: "/folder/subfolder",
want: false,
reason: "WSub=false means meta.Write doesn't apply to subdirectories",
checkFirstLayer: false,
checkSecondLayer: true,
expectedDenyReason: "first layer check failed (WSub=false)",
},
{
name: "WriteUsersSub=false for subdirectory bypasses whitelist",
user: &model.User{
ID: 5, // not in WriteUsers
Permission: 1 << 3,
},
meta: &model.Meta{
Path: "/folder",
Write: false,
WriteUsers: []uint{1, 2},
WriteUsersSub: false, // whitelist does NOT apply to subdirectories
},
path: "/folder/subfolder",
want: true,
reason: "WriteUsersSub=false means whitelist doesn't apply to subdirectories",
checkFirstLayer: true,
checkSecondLayer: true, // passes because restriction doesn't apply
expectedDenyReason: "",
},
// === Scenario 7: Root level restriction ===
{
name: "root level meta.Write with user in whitelist",
user: &model.User{
ID: 1,
Permission: 0,
},
meta: &model.Meta{
Path: "/",
Write: true,
WSub: true,
WriteUsers: []uint{1},
WriteUsersSub: true,
},
path: "/any/deep/path",
want: true,
reason: "root level permissions apply to all paths",
checkFirstLayer: true,
checkSecondLayer: true,
expectedDenyReason: "",
},
{
name: "root level restriction denies non-whitelisted user",
user: &model.User{
ID: 5,
Permission: 1 << 3, // has global permission
},
meta: &model.Meta{
Path: "/",
Write: false,
WriteUsers: []uint{1, 2},
WriteUsersSub: true,
},
path: "/any/path",
want: false,
reason: "root level whitelist restricts all paths",
checkFirstLayer: true,
checkSecondLayer: false,
expectedDenyReason: "not in root level whitelist",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the actual permission check logic
firstLayerPass := tt.user.CanWriteContent() || CanWriteContentBypassUserPerms(tt.meta, tt.path)
secondLayerPass := CanWrite(tt.user, tt.meta, tt.path)
// Verify our understanding of each layer
if firstLayerPass != tt.checkFirstLayer {
t.Errorf("First layer check mismatch: got %v, expected %v\n"+
"CanWriteContent()=%v, CanWriteContentBypassUserPerms()=%v",
firstLayerPass, tt.checkFirstLayer,
tt.user.CanWriteContent(), CanWriteContentBypassUserPerms(tt.meta, tt.path))
}
if firstLayerPass && secondLayerPass != tt.checkSecondLayer {
t.Errorf("Second layer check mismatch: got %v, expected %v\n"+
"CanWrite()=%v",
secondLayerPass, tt.checkSecondLayer,
CanWrite(tt.user, tt.meta, tt.path))
}
// Final result
got := firstLayerPass && secondLayerPass
if got != tt.want {
t.Errorf("Permission check failed:\n"+
" Result: %v, want %v\n"+
" Reason: %s\n"+
" First layer (CanWriteContent || CanWriteContentBypassUserPerms): %v\n"+
" Second layer (CanWrite): %v\n"+
" User: ID=%d, Permission=%d, CanWriteContent=%v\n"+
" Meta: Path=%s, Write=%v, WSub=%v, WriteUsers=%v, WriteUsersSub=%v\n"+
" Check Path: %s",
got, tt.want,
tt.reason,
firstLayerPass,
secondLayerPass,
tt.user.ID, tt.user.Permission, tt.user.CanWriteContent(),
getMetaPath(tt.meta), getMetaWrite(tt.meta), getMetaWSub(tt.meta),
getMetaWriteUsers(tt.meta), getMetaWriteUsersSub(tt.meta),
tt.path)
}
})
}
}
// Helper functions to safely extract meta fields
func getMetaPath(meta *model.Meta) string {
if meta == nil {
return "nil"
}
return meta.Path
}
func getMetaWrite(meta *model.Meta) bool {
if meta == nil {
return false
}
return meta.Write
}
func getMetaWSub(meta *model.Meta) bool {
if meta == nil {
return false
}
return meta.WSub
}
func getMetaWriteUsers(meta *model.Meta) []uint {
if meta == nil {
return nil
}
return meta.WriteUsers
}
func getMetaWriteUsersSub(meta *model.Meta) bool {
if meta == nil {
return false
}
return meta.WriteUsersSub
}

View File

@@ -15,20 +15,23 @@ import (
func Mkdir(ctx context.Context, path string) error {
user := ctx.Value(conf.UserKey).(*model.User)
if !user.CanFTPManage() {
return errs.PermissionDenied
}
reqPath, err := user.JoinPath(path)
if err != nil {
return err
}
if !user.CanWrite() || !user.CanFTPManage() {
meta, err := op.GetNearestMeta(stdpath.Dir(reqPath))
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return err
}
}
if !common.CanWrite(meta, reqPath) {
return errs.PermissionDenied
}
parentPath := stdpath.Dir(reqPath)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return err
}
if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) {
return errs.PermissionDenied
}
if !common.CanWrite(user, parentMeta, parentPath) {
return errs.PermissionDenied
}
return fs.MakeDir(ctx, reqPath)
}
@@ -42,6 +45,13 @@ func Remove(ctx context.Context, path string) error {
if err != nil {
return err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return err
}
if !common.CanWrite(user, meta, reqPath) {
return errs.PermissionDenied
}
if err = RemoveStage(reqPath); !errors.Is(err, errs.ObjectNotFound) {
return err
}
@@ -60,8 +70,12 @@ func Rename(ctx context.Context, oldPath, newPath string) error {
}
srcDir, srcBase := stdpath.Split(srcPath)
dstDir, dstBase := stdpath.Split(dstPath)
dstMeta, err := op.GetNearestMeta(dstDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return err
}
if srcDir == dstDir {
if !user.CanRename() || !user.CanFTPManage() {
if !user.CanRename() || !user.CanFTPManage() || !common.CanWrite(user, dstMeta, dstDir) {
return errs.PermissionDenied
}
if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) {
@@ -69,7 +83,11 @@ func Rename(ctx context.Context, oldPath, newPath string) error {
}
return fs.Rename(ctx, srcPath, dstBase)
} else {
if !user.CanFTPManage() || !user.CanMove() || (srcBase != dstBase && !user.CanRename()) {
srcMeta, err := op.GetNearestMeta(srcDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return err
}
if !user.CanMove() || !user.CanFTPManage() || (srcBase != dstBase && !user.CanRename()) || !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) {
return errs.PermissionDenied
}
if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) {

View File

@@ -27,10 +27,8 @@ type FileDownloadProxy struct {
func OpenDownload(ctx context.Context, reqPath string, offset int64) (*FileDownloadProxy, error) {
user := ctx.Value(conf.UserKey).(*model.User)
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return nil, err
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return nil, err
}
ctx = context.WithValue(ctx, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) {
@@ -121,10 +119,8 @@ func Stat(ctx context.Context, path string) (os.FileInfo, error) {
return nil, err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return nil, err
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return nil, err
}
ctx = context.WithValue(ctx, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) {
@@ -147,10 +143,8 @@ func List(ctx context.Context, path string) ([]os.FileInfo, error) {
return nil, err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return nil, err
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return nil, err
}
ctx = context.WithValue(ctx, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) {

View File

@@ -33,14 +33,18 @@ type FileUploadProxy struct {
func uploadAuth(ctx context.Context, path string) error {
user := ctx.Value(conf.UserKey).(*model.User)
meta, err := op.GetNearestMeta(stdpath.Dir(path))
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return err
}
if !user.CanFTPManage() {
return errs.PermissionDenied
}
if !(common.CanAccess(user, meta, path, ctx.Value(conf.MetaPassKey).(string)) &&
((user.CanFTPManage() && user.CanWrite()) || common.CanWrite(meta, stdpath.Dir(path)))) {
parentPath := stdpath.Dir(path)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return err
}
if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) {
return errs.PermissionDenied
}
if !common.CanWrite(user, parentMeta, parentPath) {
return errs.PermissionDenied
}
return nil

View File

@@ -101,11 +101,9 @@ func FsArchiveMeta(c *gin.Context, req *ArchiveMetaReq, user *model.User) {
return
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, req.Password) {
@@ -186,11 +184,9 @@ func FsArchiveList(c *gin.Context, req *ArchiveListReq, user *model.User) {
return
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, req.Password) {
@@ -264,6 +260,15 @@ func FsArchiveDecompress(c *gin.Context) {
common.ErrorResp(c, err, 403)
return
}
dstMeta, err := op.GetNearestMeta(dstDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, dstMeta, dstDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
tasks := make([]task.TaskExtensionInfo, 0, len(srcPaths))
for _, srcPath := range srcPaths {
t, e := fs.ArchiveDecompress(c.Request.Context(), srcPath, dstDir, model.ArchiveDecompressArgs{

View File

@@ -22,6 +22,7 @@ type RecursiveMoveReq struct {
ConflictPolicy string `json:"conflict_policy"`
}
// FsRecursiveMove recursively moves files (individual item permission checks skipped for performance).
func FsRecursiveMove(c *gin.Context) {
var req RecursiveMoveReq
if err := c.ShouldBind(&req); err != nil {
@@ -39,20 +40,31 @@ func FsRecursiveMove(c *gin.Context) {
common.ErrorResp(c, err, 403)
return
}
srcMeta, err := op.GetNearestMeta(srcDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, srcMeta, srcDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
common.GinWithValue(c, conf.MetaKey, srcMeta)
dstDir, err := user.JoinPath(req.DstDir)
if err != nil {
common.ErrorResp(c, err, 403)
return
}
meta, err := op.GetNearestMeta(srcDir)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
dstMeta, err := op.GetNearestMeta(dstDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, dstMeta, dstDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
rootFiles, err := fs.List(c.Request.Context(), srcDir, &fs.ListArgs{})
if err != nil {
@@ -143,6 +155,7 @@ type BatchRenameReq struct {
} `json:"rename_objects"`
}
// FsBatchRename performs batch rename (individual item permission checks skipped for performance).
func FsBatchRename(c *gin.Context) {
var req BatchRenameReq
if err := c.ShouldBind(&req); err != nil {
@@ -162,11 +175,13 @@ func FsBatchRename(c *gin.Context) {
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, meta, reqPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
for _, renameObject := range req.RenameObjects {
@@ -193,6 +208,7 @@ type RegexRenameReq struct {
NewNameRegex string `json:"new_name_regex"`
}
// FsRegexRename renames files by regex (individual item permission checks skipped for performance).
func FsRegexRename(c *gin.Context) {
var req RegexRenameReq
if err := c.ShouldBind(&req); err != nil {
@@ -212,11 +228,13 @@ func FsRegexRename(c *gin.Context) {
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, meta, reqPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
common.GinWithValue(c, conf.MetaKey, meta)

View File

@@ -36,18 +36,19 @@ func FsMkdir(c *gin.Context) {
common.ErrorResp(c, err, 403)
return
}
if !user.CanWrite() {
meta, err := op.GetNearestMeta(stdpath.Dir(reqPath))
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
}
if !common.CanWrite(meta, reqPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
parentPath := stdpath.Dir(reqPath)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
if !common.CanWrite(user, parentMeta, parentPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
if err := fs.MakeDir(c.Request.Context(), reqPath); err != nil {
common.ErrorResp(c, err, 500)
@@ -65,6 +66,7 @@ type MoveCopyReq struct {
Merge bool `json:"merge"`
}
// FsMove performs batch move (individual item permission checks skipped for performance).
func FsMove(c *gin.Context) {
var req MoveCopyReq
if err := c.ShouldBind(&req); err != nil {
@@ -80,11 +82,34 @@ func FsMove(c *gin.Context) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
srcDir, err := user.JoinPath(req.SrcDir)
if err != nil {
common.ErrorResp(c, err, 403)
return
}
srcMeta, err := op.GetNearestMeta(srcDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, srcMeta, srcDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
dstDir, err := user.JoinPath(req.DstDir)
if err != nil {
common.ErrorResp(c, err, 403)
return
}
dstMeta, err := op.GetNearestMeta(dstDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, dstMeta, dstDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
validPaths := make([]string, 0, len(req.Names))
for _, name := range req.Names {
@@ -140,6 +165,7 @@ func FsMove(c *gin.Context) {
}
}
// FsCopy performs batch copy (individual item permission checks skipped for performance).
func FsCopy(c *gin.Context) {
var req MoveCopyReq
if err := c.ShouldBind(&req); err != nil {
@@ -155,11 +181,34 @@ func FsCopy(c *gin.Context) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
srcDir, err := user.JoinPath(req.SrcDir)
if err != nil {
common.ErrorResp(c, err, 403)
return
}
srcMeta, err := op.GetNearestMeta(srcDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanRead(user, srcMeta, srcDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
dstDir, err := user.JoinPath(req.DstDir)
if err != nil {
common.ErrorResp(c, err, 403)
return
}
dstMeta, err := op.GetNearestMeta(dstDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, dstMeta, dstDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
validPaths := make([]string, 0, len(req.Names))
for _, name := range req.Names {
@@ -245,6 +294,16 @@ func FsRename(c *gin.Context) {
common.ErrorResp(c, err, 403)
return
}
parentPath := stdpath.Dir(reqPath)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, parentMeta, parentPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
if !req.Overwrite {
dstPath := stdpath.Join(stdpath.Dir(reqPath), req.Name)
if dstPath != reqPath {
@@ -273,6 +332,7 @@ type RemoveReq struct {
Names []string `json:"names"`
}
// FsRemove performs batch remove (individual item permission checks skipped for performance).
func FsRemove(c *gin.Context) {
var req RemoveReq
if err := c.ShouldBind(&req); err != nil {
@@ -288,19 +348,28 @@ func FsRemove(c *gin.Context) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
reqPath, err := user.JoinPath(req.Dir)
if err != nil {
common.ErrorResp(c, err, 403)
return
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, meta, reqPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
for i, name := range req.Names {
if strings.TrimSpace(utils.FixAndCleanPath(name)) == "/" {
log.Warnf("FsRemove: invalid item skipped: %s (parent directory: %s)\n", name, req.Dir)
fullPath := stdpath.Join(reqPath, name)
if !strings.HasPrefix(fullPath+"/", reqPath+"/") {
log.Warnf("FsRemove: path traversal attempt skipped: %s (dir: %s)\n", name, req.Dir)
req.Names[i] = ""
continue
}
// ensure req.Names is not a relative path
var err error
req.Names[i], err = user.JoinPath(stdpath.Join(req.Dir, name))
if err != nil {
common.ErrorResp(c, err, 403)
return
}
req.Names[i] = fullPath
}
for _, path := range req.Names {
if path == "" {
@@ -320,6 +389,7 @@ type RemoveEmptyDirectoryReq struct {
SrcDir string `json:"src_dir"`
}
// FsRemoveEmptyDirectory recursively removes empty directories (individual item permission checks skipped for performance).
func FsRemoveEmptyDirectory(c *gin.Context) {
var req RemoveEmptyDirectoryReq
if err := c.ShouldBind(&req); err != nil {
@@ -339,11 +409,13 @@ func FsRemoveEmptyDirectory(c *gin.Context) {
}
meta, err := op.GetNearestMeta(srcDir)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, meta, srcDir) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
common.GinWithValue(c, conf.MetaKey, meta)

View File

@@ -48,13 +48,14 @@ type ObjResp struct {
}
type FsListResp struct {
Content []ObjResp `json:"content"`
Total int64 `json:"total"`
Readme string `json:"readme"`
Header string `json:"header"`
Write bool `json:"write"`
Provider string `json:"provider"`
DirectUploadTools []string `json:"direct_upload_tools,omitempty"`
Content []ObjResp `json:"content"`
Total int64 `json:"total"`
Readme string `json:"readme"`
Header string `json:"header"`
Write bool `json:"write"`
WriteContentBypass bool `json:"write_content_bypass"`
Provider string `json:"provider"`
DirectUploadTools []string `json:"direct_upload_tools,omitempty"`
}
func FsListSplit(c *gin.Context) {
@@ -86,18 +87,17 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) {
return
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, req.Password) {
common.ErrorStrResp(c, "password is incorrect or you have no permission", 403)
return
}
if !user.CanWrite() && !common.CanWrite(meta, reqPath) && req.Refresh {
canWriteContentAtPath := common.CanWrite(user, meta, reqPath) && (user.CanWriteContent() || common.CanWriteContentBypassUserPerms(meta, reqPath))
if req.Refresh && !canWriteContentAtPath {
common.ErrorStrResp(c, "Refresh without permission", 403)
return
}
@@ -112,19 +112,20 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) {
total, objs := pagination(objs, &req.PageReq)
provider := "unknown"
var directUploadTools []string
if user.CanWrite() {
if canWriteContentAtPath {
if storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}); err == nil {
directUploadTools = op.GetDirectUploadTools(storage)
}
}
common.SuccessResp(c, FsListResp{
Content: toObjsResp(objs, reqPath, isEncrypt(meta, reqPath)),
Total: int64(total),
Readme: getReadme(meta, reqPath),
Header: getHeader(meta, reqPath),
Write: user.CanWrite() || common.CanWrite(meta, reqPath),
Provider: provider,
DirectUploadTools: directUploadTools,
Content: toObjsResp(objs, reqPath, isEncrypt(meta, reqPath)),
Total: int64(total),
Readme: getReadme(meta, reqPath),
Header: getHeader(meta, reqPath),
Write: common.CanWrite(user, meta, reqPath),
WriteContentBypass: common.CanWriteContentBypassUserPerms(meta, reqPath),
Provider: provider,
DirectUploadTools: directUploadTools,
})
}
@@ -150,11 +151,9 @@ func FsDirs(c *gin.Context) {
reqPath = tmp
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, req.Password) {
@@ -189,14 +188,14 @@ func filterDirs(objs []model.Obj) []DirResp {
}
func getReadme(meta *model.Meta, path string) string {
if meta != nil && (utils.PathEqual(meta.Path, path) || meta.RSub) {
if meta != nil && common.MetaCoversPath(meta.Path, path, meta.RSub) {
return meta.Readme
}
return ""
}
func getHeader(meta *model.Meta, path string) string {
if meta != nil && (utils.PathEqual(meta.Path, path) || meta.HeaderSub) {
if meta != nil && common.MetaCoversPath(meta.Path, path, meta.HeaderSub) {
return meta.Header
}
return ""
@@ -209,7 +208,7 @@ func isEncrypt(meta *model.Meta, path string) bool {
if meta == nil || meta.Password == "" {
return false
}
if !utils.PathEqual(meta.Path, path) && !meta.PSub {
if !common.MetaCoversPath(meta.Path, path, meta.PSub) {
return false
}
return true
@@ -296,11 +295,9 @@ func FsGet(c *gin.Context, req *FsGetReq, user *model.User) {
return
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
if !common.CanAccess(user, meta, reqPath, req.Password) {
@@ -424,11 +421,9 @@ func FsOther(c *gin.Context) {
return
}
meta, err := op.GetNearestMeta(req.Path)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
if !common.CanAccess(user, meta, req.Path, req.Password) {

View File

@@ -0,0 +1,255 @@
package handles
import (
"testing"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
func TestGetReadme(t *testing.T) {
tests := []struct {
name string
meta *model.Meta
path string
want string
reason string
}{
{
name: "nil meta",
meta: nil,
path: "/any",
want: "",
reason: "nil meta should return empty",
},
{
name: "exact path match with RSub=false",
meta: &model.Meta{
Path: "/folder",
Readme: "Welcome",
RSub: false,
},
path: "/folder",
want: "Welcome",
reason: "exact path should show readme",
},
{
name: "sub path with RSub=true",
meta: &model.Meta{
Path: "/folder",
Readme: "Welcome",
RSub: true,
},
path: "/folder/subfolder",
want: "Welcome",
reason: "sub path with RSub=true should show readme",
},
{
name: "sub path with RSub=false",
meta: &model.Meta{
Path: "/folder",
Readme: "Welcome",
RSub: false,
},
path: "/folder/subfolder",
want: "",
reason: "sub path with RSub=false should not show readme",
},
{
name: "non-sub path with RSub=true (BEHAVIOR CHANGE - BUG FIX)",
meta: &model.Meta{
Path: "/folder",
Readme: "Welcome",
RSub: true,
},
path: "/other",
want: "",
reason: "non-sub path should not show readme even with RSub=true (fixed bug)",
},
{
name: "root readme applies to all with RSub=true",
meta: &model.Meta{
Path: "/",
Readme: "Global Info",
RSub: true,
},
path: "/any/path",
want: "Global Info",
reason: "root readme with RSub=true should apply to all paths",
},
{
name: "empty readme",
meta: &model.Meta{
Path: "/folder",
Readme: "",
RSub: true,
},
path: "/folder",
want: "",
reason: "empty readme should return empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getReadme(tt.meta, tt.path)
if got != tt.want {
t.Errorf("getReadme() = %q, want %q\nReason: %s",
got, tt.want, tt.reason)
}
})
}
}
func TestGetHeader(t *testing.T) {
tests := []struct {
name string
meta *model.Meta
path string
want string
reason string
}{
{
name: "nil meta",
meta: nil,
path: "/any",
want: "",
reason: "nil meta should return empty",
},
{
name: "exact path match with HeaderSub=false",
meta: &model.Meta{
Path: "/folder",
Header: "Custom Header",
HeaderSub: false,
},
path: "/folder",
want: "Custom Header",
reason: "exact path should show header",
},
{
name: "sub path with HeaderSub=true",
meta: &model.Meta{
Path: "/folder",
Header: "Custom Header",
HeaderSub: true,
},
path: "/folder/subfolder",
want: "Custom Header",
reason: "sub path with HeaderSub=true should show header",
},
{
name: "sub path with HeaderSub=false",
meta: &model.Meta{
Path: "/folder",
Header: "Custom Header",
HeaderSub: false,
},
path: "/folder/subfolder",
want: "",
reason: "sub path with HeaderSub=false should not show header",
},
{
name: "non-sub path with HeaderSub=true (BEHAVIOR CHANGE - BUG FIX)",
meta: &model.Meta{
Path: "/folder",
Header: "Custom Header",
HeaderSub: true,
},
path: "/other",
want: "",
reason: "non-sub path should not show header even with HeaderSub=true (fixed bug)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getHeader(tt.meta, tt.path)
if got != tt.want {
t.Errorf("getHeader() = %q, want %q\nReason: %s",
got, tt.want, tt.reason)
}
})
}
}
func TestIsEncrypt(t *testing.T) {
tests := []struct {
name string
meta *model.Meta
path string
want bool
reason string
}{
{
name: "nil meta",
meta: nil,
path: "/any",
want: false,
reason: "nil meta should not be encrypted",
},
{
name: "empty password",
meta: &model.Meta{
Path: "/folder",
Password: "",
},
path: "/folder",
want: false,
reason: "empty password should not be encrypted",
},
{
name: "exact path match with PSub=false",
meta: &model.Meta{
Path: "/folder",
Password: "secret",
PSub: false,
},
path: "/folder",
want: true,
reason: "exact path with password should be encrypted",
},
{
name: "sub path with PSub=true",
meta: &model.Meta{
Path: "/folder",
Password: "secret",
PSub: true,
},
path: "/folder/subfolder",
want: true,
reason: "sub path with PSub=true should be encrypted",
},
{
name: "sub path with PSub=false",
meta: &model.Meta{
Path: "/folder",
Password: "secret",
PSub: false,
},
path: "/folder/subfolder",
want: false,
reason: "sub path with PSub=false should not be encrypted",
},
{
name: "non-sub path with PSub=true",
meta: &model.Meta{
Path: "/folder",
Password: "secret",
PSub: true,
},
path: "/other",
want: false,
reason: "non-sub path should not be encrypted even with PSub=true",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isEncrypt(tt.meta, tt.path)
if got != tt.want {
t.Errorf("isEncrypt() = %v, want %v\nReason: %s",
got, tt.want, tt.reason)
}
})
}
}

View File

@@ -12,12 +12,14 @@ import (
"github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser"
"github.com/OpenListTeam/OpenList/v4/drivers/thunderx"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/task"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
type SetAria2Req struct {
@@ -499,6 +501,15 @@ func AddOfflineDownload(c *gin.Context) {
common.ErrorResp(c, err, 403)
return
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
return
}
if !common.CanWrite(user, meta, reqPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
return
}
var tasks []task.TaskExtensionInfo
for _, url := range req.Urls {
// Filter out empty lines and whitespace-only strings

View File

@@ -63,11 +63,9 @@ func Down(verifyFunc func(string, string) error) func(c *gin.Context) {
return func(c *gin.Context) {
rawPath := c.Request.Context().Value(conf.PathKey).(string)
meta, err := op.GetNearestMeta(rawPath)
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorPage(c, err, 500, true)
return
}
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorPage(c, err, 500, true)
return
}
common.GinWithValue(c, conf.MetaKey, meta)
// verify sign

View File

@@ -15,7 +15,6 @@ import (
func FsUp(c *gin.Context) {
path := c.GetHeader("File-Path")
password := c.GetHeader("Password")
path, err := url.PathUnescape(path)
if err != nil {
common.ErrorResp(c, err, 400)
@@ -28,15 +27,19 @@ func FsUp(c *gin.Context) {
common.ErrorResp(c, err, 403)
return
}
meta, err := op.GetNearestMeta(stdpath.Dir(path))
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
c.Abort()
return
}
parentPath := stdpath.Dir(path)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
common.ErrorResp(c, err, 500, true)
c.Abort()
return
}
if !(common.CanAccess(user, meta, path, password) && (user.CanWrite() || common.CanWrite(meta, stdpath.Dir(path)))) {
if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
c.Abort()
return
}
if !common.CanWrite(user, parentMeta, parentPath) {
common.ErrorResp(c, errs.PermissionDenied, 403)
c.Abort()
return

View File

@@ -117,22 +117,22 @@ func WebDAVAuth(c *gin.Context) {
c.Abort()
return
}
if (c.Request.Method == "PUT" || c.Request.Method == "MKCOL") && (!user.CanWebdavManage() || !user.CanWrite()) {
if (c.Request.Method == "PUT" || c.Request.Method == "MKCOL") && !user.CanWebdavManage() {
c.Status(http.StatusForbidden)
c.Abort()
return
}
if c.Request.Method == "MOVE" && (!user.CanWebdavManage() || (!user.CanMove() && !user.CanRename())) {
if c.Request.Method == "MOVE" && !user.CanWebdavManage() {
c.Status(http.StatusForbidden)
c.Abort()
return
}
if c.Request.Method == "COPY" && (!user.CanWebdavManage() || !user.CanCopy()) {
if c.Request.Method == "COPY" && !user.CanWebdavManage() {
c.Status(http.StatusForbidden)
c.Abort()
return
}
if c.Request.Method == "DELETE" && (!user.CanWebdavManage() || !user.CanRemove()) {
if c.Request.Method == "DELETE" && !user.CanWebdavManage() {
c.Status(http.StatusForbidden)
c.Abort()
return
@@ -143,6 +143,11 @@ func WebDAVAuth(c *gin.Context) {
return
}
common.GinWithValue(c, conf.UserKey, user)
if user.IsGuest() {
common.GinWithValue(c, conf.MetaPassKey, password)
} else {
common.GinWithValue(c, conf.MetaPassKey, "")
}
c.Next()
}

View File

@@ -11,9 +11,12 @@ import (
"path/filepath"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/fs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/pkg/errors"
)
// slashClean is equivalent to but slightly more efficient than
@@ -26,6 +29,7 @@ func slashClean(name string) string {
}
// moveFiles moves files and/or directories from src to dst.
// Individual item permission checks are skipped for performance reasons.
//
// See section 9.9.4 for when various HTTP status codes apply.
func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) {
@@ -40,6 +44,17 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int
if srcName != dstName && !user.CanRename() {
return http.StatusForbidden, nil
}
srcMeta, err := op.GetNearestMeta(srcDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
dstMeta, err := op.GetNearestMeta(dstDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) {
return http.StatusForbidden, nil
}
if srcDir == dstDir {
err = fs.Rename(ctx, src, dstName)
} else {
@@ -59,10 +74,30 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int
}
// copyFiles copies files and/or directories from src to dst.
// Individual item permission checks are skipped for performance reasons.
//
// See section 9.8.5 for when various HTTP status codes apply.
func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) {
srcDir := path.Dir(src)
dstDir := path.Dir(dst)
user := ctx.Value(conf.UserKey).(*model.User)
if !user.CanCopy() {
return http.StatusForbidden, nil
}
srcMeta, err := op.GetNearestMeta(srcDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanRead(user, srcMeta, srcDir) {
return http.StatusForbidden, nil
}
dstMeta, err := op.GetNearestMeta(dstDir)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanWrite(user, dstMeta, dstDir) {
return http.StatusForbidden, nil
}
_, err = fs.Copy(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir)
if err != nil {
return http.StatusInternalServerError, err

View File

@@ -7,7 +7,6 @@ package webdav // import "golang.org/x/net/webdav"
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -20,8 +19,10 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/net"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/pkg/errors"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/fs"
@@ -200,7 +201,7 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) (status
user := ctx.Value(conf.UserKey).(*model.User)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
allow := "OPTIONS, LOCK, PUT, MKCOL"
if fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err == nil {
@@ -226,10 +227,18 @@ func (h *Handler) handleGetHeadPost(w http.ResponseWriter, r *http.Request) (sta
// TODO: check locks for read-only access??
ctx := r.Context()
user := ctx.Value(conf.UserKey).(*model.User)
password, _ := ctx.Value(conf.MetaPassKey).(string)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return http.StatusForbidden, err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanAccess(user, meta, reqPath, password) {
return http.StatusForbidden, errs.PermissionDenied
}
fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{})
if err != nil {
return http.StatusNotFound, err
@@ -294,9 +303,12 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i
ctx := r.Context()
user := ctx.Value(conf.UserKey).(*model.User)
if !user.CanRemove() {
return http.StatusForbidden, nil
}
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
// TODO: return MultiStatus where appropriate.
@@ -309,6 +321,14 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i
}
return http.StatusMethodNotAllowed, err
}
parentPath := path.Dir(reqPath)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanWrite(user, parentMeta, parentPath) {
return http.StatusForbidden, errs.PermissionDenied
}
if err := fs.Remove(ctx, reqPath); err != nil {
return http.StatusMethodNotAllowed, err
}
@@ -363,6 +383,17 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int,
if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) {
return http.StatusForbidden, errs.IgnoredSystemFile
}
parentPath := path.Dir(reqPath)
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) {
return http.StatusForbidden, errs.PermissionDenied
}
if !common.CanWrite(user, parentMeta, parentPath) {
return http.StatusForbidden, errs.PermissionDenied
}
fsStream := &stream.FileStream{
Obj: &obj,
Reader: r.Body,
@@ -407,7 +438,7 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in
user := ctx.Value(conf.UserKey).(*model.User)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
if r.ContentLength > 0 {
@@ -421,13 +452,23 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in
}
// RFC 4918 9.3.1
// 409 (Conflict) The server MUST NOT create those intermediate collections automatically.
reqDir := path.Dir(reqPath)
if _, err := fs.Get(ctx, reqDir, &fs.GetArgs{}); err != nil {
parentPath := path.Dir(reqPath)
if _, err := fs.Get(ctx, parentPath, &fs.GetArgs{}); err != nil {
if errs.IsObjectNotFound(err) {
return http.StatusConflict, err
}
return http.StatusMethodNotAllowed, err
}
parentMeta, err := op.GetNearestMeta(parentPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) {
return http.StatusForbidden, errs.PermissionDenied
}
if !common.CanWrite(user, parentMeta, parentPath) {
return http.StatusForbidden, errs.PermissionDenied
}
if err := fs.MakeDir(ctx, reqPath); err != nil {
if os.IsNotExist(err) {
return http.StatusConflict, err
@@ -471,11 +512,11 @@ func (h *Handler) handleCopyMove(w http.ResponseWriter, r *http.Request) (status
user := ctx.Value(conf.UserKey).(*model.User)
src, err = user.JoinPath(src)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
dst, err = user.JoinPath(dst)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
if r.Method == "COPY" {
@@ -572,7 +613,14 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus
}
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanWrite(user, meta, reqPath) {
return http.StatusForbidden, errs.PermissionDenied
}
ld = LockDetails{
Root: reqPath,
@@ -630,6 +678,24 @@ func (h *Handler) handleUnlock(w http.ResponseWriter, r *http.Request) (status i
}
t = t[1 : len(t)-1]
reqPath, status, err := h.stripPrefix(r.URL.Path)
if err != nil {
return status, err
}
ctx := r.Context()
user := ctx.Value(conf.UserKey).(*model.User)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return http.StatusForbidden, err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanWrite(user, meta, reqPath) {
return http.StatusForbidden, errs.PermissionDenied
}
switch err = h.LockSystem.Unlock(time.Now(), t); err {
case nil:
return http.StatusNoContent, err
@@ -653,9 +719,17 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status
userAgent := r.Header.Get("User-Agent")
ctx = context.WithValue(ctx, conf.UserAgentKey, userAgent)
user := ctx.Value(conf.UserKey).(*model.User)
password, _ := ctx.Value(conf.MetaPassKey).(string)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanAccess(user, meta, reqPath, password) {
return http.StatusForbidden, errs.PermissionDenied
}
fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{})
if err != nil {
@@ -734,7 +808,14 @@ func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request) (statu
user := ctx.Value(conf.UserKey).(*model.User)
reqPath, err = user.JoinPath(reqPath)
if err != nil {
return 403, err
return http.StatusForbidden, err
}
meta, err := op.GetNearestMeta(reqPath)
if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return http.StatusInternalServerError, err
}
if !common.CanWrite(user, meta, reqPath) {
return http.StatusForbidden, errs.PermissionDenied
}
if _, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err != nil {
if errs.IsObjectNotFound(err) {