Merge pull request #138 from Dvorinka/merge/upstream-prs

Merge 7 upstream PRs + repair stale test suite + fork roadmap
pull/3579/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit b347f036e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

2
.gitignore vendored

@ -38,3 +38,5 @@ tmp/
.devcontainer/ .devcontainer/
cloudreve cloudreve
.DS_Store .DS_Store
# Playwright MCP snapshots
.playwright-mcp/

@ -4,7 +4,7 @@ before:
hooks: hooks:
- go mod tidy - go mod tidy
- chmod +x ./.build/build-assets.sh - chmod +x ./.build/build-assets.sh
- ./.build/build-assets.sh {{.Version}} - ./.build/build-assets.sh {{.Tag}}
builds: builds:
- env: - env:

@ -1,3 +1,19 @@
# Build stage
FROM ghcr.io/goreleaser/goreleaser:v2.10.2 AS builder
WORKDIR /src
# Install goreleaser
RUN apk add --no-cache bash curl git npm nodejs tar zip
RUN npm install -g yarn
# Perform the build
COPY . .
RUN goreleaser build --single-target --snapshot
# Runtime stage
FROM alpine:latest FROM alpine:latest
WORKDIR /cloudreve WORKDIR /cloudreve
@ -17,7 +33,7 @@ ENV CR_ENABLE_ARIA2=1 \
CR_SETTING_DEFAULT_thumb_libraw_enabled=1 CR_SETTING_DEFAULT_thumb_libraw_enabled=1
COPY .build/aria2.supervisor.conf .build/entrypoint.sh ./ COPY .build/aria2.supervisor.conf .build/entrypoint.sh ./
COPY cloudreve ./cloudreve COPY --from=builder /src/dist/*/cloudreve ./cloudreve
RUN chmod +x ./cloudreve \ RUN chmod +x ./cloudreve \
&& chmod +x ./entrypoint.sh && chmod +x ./entrypoint.sh

@ -0,0 +1,160 @@
# Cloudreve Fork — Analysis & Roadmap
Fork: `Dvorinka/cloudreve` · Upstream: `cloudreve/cloudreve` · Baseline: `4.19.1` (exact upstream HEAD, zero divergence)
Original work by the Cloudreve authors (cloudreve.org). This fork continues it as a fully open-source project — every "Pro" feature reimplemented and free, desktop client on all platforms, native Android app.
---
## 1. Analysis
### 1.1 Repo state
| Fact | Value |
|---|---|
| Fork vs upstream | `0 ahead / 0 behind` — clean mirror of `master` @ 4.19.1 |
| Backend | Go 1.26, Gin, ent ORM, `go build ./...` compiles (only `assets.zip` embed fails until frontend is built — expected) |
| Frontend | `assets/` submodule → `cloudreve/frontend` (React + Vite + TS, 69 deps), not yet initialized locally |
| Storage drivers present | local, S3, OSS, COS, Qiniu, Upyun, OneDrive, remote node (`service/explorer/slave.go`) |
| Auth present | password+2FA, passkey (`ent/schema/passkey.go`), generic OAuth client/grant, WebDAV accounts (`davaccount.go`) |
### 1.2 Security posture
16 published GHSAs on upstream — **all fixed at our baseline** (vuln ranges ≤ 4.17.0, we run 4.19.1). Spot-verified in tree, not just by version:
- GHSA-f8xp (account takeover, insecure PRNG): `pkg/util/common.go` uses `crypto/rand` primary, `math/rand` only on crypto failure — fix present
- GHSA-vgj4 (OAuth scope bypass, missing client_id): `service/oauth/oauth.go:159` validates `authCode.ClientID != s.ClientID` — fix present
- Remaining highs (WebDAV path traversal, quota TOCTOU, OneDrive cred update via Admin.Read) — all ≤ 4.16.x ranges, patched
Ongoing security work is in the roadmap (§5), not the backlog.
### 1.3 Pro feature map (cloudreve.org/pricing — all 5 slides captured)
The community repo contains **zero Pro code** — Pro ships as a separate licensed binary (`--license-key`, `proupgrade` DB migration). BUT the community **frontend already contains the full Pro UI skeleton** — every surface below renders a `ProChip` badge that opens `ProDialog.tsx`. Implementation = backend endpoints + remove the chips.
| Pro slide | Features | Frontend hooks found |
|---|---|---|
| Sharing & collaboration | write/upload/delete via share link, paid share links, granular file permissions (users/groups/anonymous), anonymous upload via share, default shares for new users | `ShareSection.tsx` (group editor) |
| Storage policy mgmt | multiple policies per group, per-directory policies, load-balancer policy, file migration between policies | `SelectProvider.tsx`, `storage_policy_id` exists (single) on group |
| User & auth | multi-account switching, Logto SSO, OIDC SSO, QQ Connect, sign-up email filtering | `SSOSettings.tsx`, `SSO/` |
| Monetization (VAS) | storage plans, membership plans, redemption codes, credits | `VAS/` dir: `GroupProducts`, `StorageProducts`, `PaymentProviders`, `GiftCodes` — **full UI exists** |
| System extensions | activity/audit logs, site announcements, node selection, report abuse | `Events.tsx` (admin), `Home.tsx` |
Backend gaps are concrete: `ShareProps` = `{share_view, show_read_me}` only; `group.storage_policy_id` is single; no order/product/credit entities at all. `NavigatorCapability_CommunityPlaceholder1–9` in `pkg/filemanager/fs/dbfs/navigator.go` are the reserved capability slots Pro fills.
### 1.4 Org repo decisions
| Repo | Verdict | Reason |
|---|---|---|
| `cloudreve` (this fork) | **Keep — base** | The core |
| `frontend` | **Fork — required** | UI is source-available and already holds Pro skeleton; we need our own fork to strip gates |
| `desktop` | **Fork — port** | Tauri+React; portable core (`cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync`) vs Windows-only glue (`cfapi`, `shellext`, `win32_notif`) |
| `docs` | **Fork later** | Needed when we ship; low priority |
| `docker-compose` | **Fork — small** | One file we extend (add pro-less compose + dev compose) |
| `taskqueue` | **Skip** | Dead since 2024; OneDrive offload queue superseded by in-app queue |
| `remote-server` | **Skip** | Dead PHP-era remote; v4 has native remote nodes |
| `ios-feedback` | **Skip** | Tracker for closed-source iOS app; we do Android instead |
| `theme-editor`, `frontend_v2`, `v2` | **Skip** | Archived/ancient |
### 1.5 Upstream issues — 137 open, grouped
| Group | Count | Examples |
|---|---|---|
| Bug — upload/download/sync | ~25 | #3574 trash_bin_collect OOM, #3454 PG FK on upload, #3118 WebDAV 500 on large files, #3005 WebDAV fragments, #2938 upload stuck |
| Bug — WebDAV | ~8 | #2878 mount path 404 after move, #3118, #3409 read-only groups |
| Bug — DB/migration | ~8 | #3452 MySQL HeatWave, #2880 unix socket, #2934 v3→v4 sqlite, #2981 PG18 |
| Enhancement — sharing/permissions | ~15 | #3555 preview-only shares, #3390 default share visibility, #3340 upload-only folders, #3033/#3032 multi-share ops |
| Enhancement — storage policy | ~8 | #3518 encrypt on relocation, #2961 enable/disable policy, #2262 site-wide migration |
| Enhancement — auth/SSO | ~7 | #3464 OIDC, #3056 auto-OIDC, #2179 TOTP manual key, #3479 IP whitelist |
| Enhancement — download/tasks | ~10 | #3491 advanced remote download, #3259 yt-dlp, #2427 download quota, #2270 cancel tasks |
| UX/polish | ~20 | #3288 breadcrumb restore, #3223 deselect on empty click, #3508 loop video, #3507 gallery names |
| Pro-related (becomes free here) | ~10 | #3572 ADFS OIDC, #3515 recurring billing, #3180 group-expiry downgrade, #3171 subaddress ban |
| Mobile/iOS requests | ~5 | #3003 captcha incompat, #2826 login fail, #2863 capacity 0KB — Android solves |
| Chinese-titled (mixed) | ~40 | folded into groups above after translation |
| wontfix by upstream (revisit) | ~10 | #2494 multi-group, #2883 slide captcha, #2842 file audit — **candidates for us** |
### 1.6 Upstream PRs — verdicts
| PR | Change | Verdict |
|---|---|---|
| #3524 | revalidate share/direct links after permission change (+185/-1, CLEAN) — fixes #3544, same class as GHSA-vx2m | **Merge** — security |
| #3549 | gorilla/websocket 1.5.0→1.5.3 | **Merge** — dep CVE hygiene |
| #2964 | nwaples/rardecode 2.1.0→2.2.0 | **Merge** — dep hygiene |
| #2851 | ulikunitz/xz 0.5.12→0.5.14 | **Merge** — dep hygiene |
| #3490 | IP range/CIDR filter in event logs (+668, 2 files) — fixes #3480 | **Merge** — small, self-contained |
| #3472 | OIDC provider support (+292/-6, 10 files) — fixes #3464, overlaps Pro SSO | **Merge after review** — strategic (free OIDC) |
| #2481 | multi-stage Dockerfile (+18/-2) | **Review** — conflicts w/ our own docker work likely; take if clean |
| #2507 | p2p QUIC (draft) | **Skip** — draft, scope creep |
| #2499 | multi-group users (draft, WIP) | **Watch** — big feature, matches #2494 wontfix; revisit when upstream matures it |
| #1802 | checksum on WOPI/text-edit update (draft, 2024) | **Skip** — stale draft |
---
## 2. Immediate actions (this change set)
- [x] Analysis + this roadmap
- [ ] Enable Issues on fork; label taxonomy (`upstream-####`, `group:*`, `pro-free`, `desktop`, `android`, `security`)
- [ ] Migrate upstream issues → fork (translate Chinese titles, tag `upstream-NNNN` + group labels, link originals)
- [ ] Cherry-pick merge: #3524, #3549, #2964, #2851, #3490 (and #3472 after compile review)
- [ ] `go build ./...` + `go test ./...` green
## 3. Phase A — foundation hardening (first weeks)
- Sync-fork automation: weekly `upstream → fork` merge workflow (GitHub Action) so security fixes keep landing
- Dependabot/renovate on the fork
- CI: build + test + vet + frontend build on PR (upstream azure-pipelines is theirs; ours = GitHub Actions)
- `docker-compose` dev stack (postgres + app + frontend hot reload)
- Remove `ProDialog`/`ProChip` gates in frontend fork; point `assets` submodule at our frontend fork
## 4. Phase B — Pro features, free (the big one)
Order = user-visible value first; each ships with backend + UI + tests.
1. **Share collaboration** — write/upload/delete via share link, anonymous upload, share ACL (users/groups), preview-only mode (fixes #3555, #3390, #3340, #3517, #3578; uses `NavigatorCapability` placeholder slots + `ShareProps` extension + `share` entity fields)
2. **Storage policy advanced** — multiple policies per group (group→policies join table), per-directory binding, load-balancer policy, file migration between policies (fixes #3518, #2961, #2262)
3. **SSO** — generic OIDC provider (PR #3472 base), Logto connector, multi-account switching, sign-up email filtering (fixes #3464, #3056, #3505)
4. **VAS/monetization-free** — credits + redemption codes as *free* features (gift codes for admin use), storage/membership plan definitions; skip payment processor integration initially — YAGNI until a real user asks (fixes #3231)
5. **System extensions** — activity/audit log surfaced in admin, site announcements, report-abuse queue (fixes #3480, #3479 IP whitelist)
## 5. Phase C — security + quality
- Own security review on top of upstream fixes: session/token entropy audit, SSRF guard re-test (NAT64 class), rate limiting on auth endpoints
- Fix upstream bug backlog by impact: #3574 OOM (trash_bin_collect streaming), #3118/#3005 WebDAV large-file, #3454 PG FK, #3375 SMTP auth discovery
- `desloppify` + `security-reviewer` passes; scorecard appended to README
## 6. Phase D — desktop, all platforms
Goal: Windows + macOS + Linux from one Tauri codebase (`cloudreve/desktop` fork).
| Layer | Windows (exists) | macOS | Linux |
|---|---|---|---|
| Placeholders/hydration | cfapi (keep) | File Provider ext (Swift bridge) | FUSE (`fuser`) or plain sync folder |
| Shell integration | shellext (keep) | Finder sync extension | Nautilus/Dolphin plugin (later) |
| Notifications | win32_notif → replace | `tauri-plugin-notification` (all platforms) | same |
| Sync core | shared: `cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync` | same | same |
- Port order: (1) strip `win32_notif`→tauri notifications (all platforms benefit), (2) abstract `drive/` behind a `HydrationProvider` trait (cfapi impl on Windows, stub→FUSE on Linux, FileProvider on macOS), (3) CI matrix build all 3, (4) MSIX→also ship .dmg/.AppImage/.deb.
- Feature fallback on Linux/macOS until providers land: full sync without placeholders (download-on-access still works via sync engine).
## 7. Phase E — Android app (native, no iOS)
New repo `Dvorinka/cloudreve-android`. Kotlin + Jetpack Compose, Material 3.
- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec
- **Core features**: browse/download/upload files, share links, camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager
- **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events
- **Auth**: webview OAuth flow → token; later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures
- Non-goals: iOS, tablet-first layouts (works, not optimized)
## 8. Governance
- License/credit: keep `LICENSE` (GPL-3.0), add `AUTHORS`/credit line to original Cloudreve authors in README — attribution without endorsement
- Release cadence: tag `fork-4.19.x` line first (cherry-picks only), then `5.0.0-fork` once Phase B lands
- Every merge: build + test + lint green (pre-push gate, non-negotiable)
---
## Issue migration format
Each fork issue: title translated to English when needed, body = `Upstream: cloudreve/cloudreve#NNNN` + short restatement + group labels. Epics get `epic` label and link children. Upstream `wontfix` items we want get `revisit` label.

@ -9,7 +9,7 @@ require (
github.com/Masterminds/semver/v3 v3.3.1 github.com/Masterminds/semver/v3 v3.3.1
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0
github.com/aws/aws-sdk-go v1.34.0 github.com/aws/aws-sdk-go v1.34.0
github.com/bodgit/sevenzip v1.6.0 github.com/bodgit/sevenzip v1.6.1
github.com/cloudflare/cfssl v1.6.1 github.com/cloudflare/cfssl v1.6.1
github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25 github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25
github.com/dsoprea/go-exif/v3 v3.0.1 github.com/dsoprea/go-exif/v3 v3.0.1
@ -35,7 +35,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.2 github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.2.2 github.com/gorilla/sessions v1.2.2
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.3
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible
github.com/jinzhu/gorm v1.9.11 github.com/jinzhu/gorm v1.9.11
github.com/jpillora/backoff v1.0.0 github.com/jpillora/backoff v1.0.0
@ -43,7 +43,7 @@ require (
github.com/ks3sdklib/aws-sdk-go v1.6.2 github.com/ks3sdklib/aws-sdk-go v1.6.2
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/meilisearch/meilisearch-go v0.36.0 github.com/meilisearch/meilisearch-go v0.36.0
github.com/mholt/archives v0.1.3 github.com/mholt/archives v0.1.5
github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2 github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2
github.com/pquerna/otp v1.2.0 github.com/pquerna/otp v1.2.0
github.com/qiniu/go-sdk/v7 v7.19.0 github.com/qiniu/go-sdk/v7 v7.19.0
@ -69,9 +69,9 @@ require (
require ( require (
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect
cloud.google.com/go v0.81.0 // indirect cloud.google.com/go v0.81.0 // indirect
github.com/STARRY-S/zip v0.2.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect
github.com/agext/levenshtein v1.2.1 // indirect github.com/agext/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect github.com/bodgit/windows v1.0.1 // indirect
@ -115,32 +115,33 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.3.0 // indirect github.com/jmespath/go-jmespath v0.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.0 // indirect github.com/minio/minlz v1.0.1 // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-httpheader v0.4.0 // indirect github.com/mozillazg/go-httpheader v0.4.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nwaples/rardecode/v2 v2.1.0 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sorairolake/lzip-go v0.3.5 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.15 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/zclconf/go-cty v1.8.0 // indirect github.com/zclconf/go-cty v1.8.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect

@ -84,6 +84,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg=
github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
@ -102,6 +104,8 @@ github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Sn
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ=
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
@ -142,6 +146,8 @@ github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A= github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A=
github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc= github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
@ -505,8 +511,8 @@ github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8L
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@ -621,6 +627,8 @@ github.com/kisom/goutils v1.4.3/go.mod h1:Lp5qrquG7yhYnWzZCI/68Pa/GpFynw//od6EkG
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@ -694,6 +702,8 @@ github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458= github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458=
github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU= github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
@ -701,6 +711,8 @@ github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZz
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ= github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ=
github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
@ -746,8 +758,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nishanths/predeclared v0.0.0-20200524104333-86fad755b4d3/go.mod h1:nt3d53pc1VYcphSCIaYAJtnPYnr3Zyn8fMq2wvPGPso= github.com/nishanths/predeclared v0.0.0-20200524104333-86fad755b4d3/go.mod h1:nt3d53pc1VYcphSCIaYAJtnPYnr3Zyn8fMq2wvPGPso=
github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc=
github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
@ -786,6 +798,8 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -893,12 +907,16 @@ github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg= github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg=
github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk= github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw= github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw=
github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc= github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
@ -965,8 +983,10 @@ github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W
github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/upyun/go-sdk v2.1.0+incompatible h1:OdjXghQ/TVetWV16Pz3C1/SUpjhGBVPr+cLiqZLLyq0= github.com/upyun/go-sdk v2.1.0+incompatible h1:OdjXghQ/TVetWV16Pz3C1/SUpjhGBVPr+cLiqZLLyq0=
github.com/upyun/go-sdk v2.1.0+incompatible/go.mod h1:eu3F5Uz4b9ZE5bE5QsCL6mgSNWRwfj0zpJ9J626HEqs= github.com/upyun/go-sdk v2.1.0+incompatible/go.mod h1:eu3F5Uz4b9ZE5bE5QsCL6mgSNWRwfj0zpJ9J626HEqs=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=

@ -27,6 +27,14 @@ import (
// needMigration exams if required schema version is satisfied. // needMigration exams if required schema version is satisfied.
func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion string) bool { func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion string) bool {
c, _ := client.Setting.Query().Where(setting.NameEQ(DBVersionPrefix + requiredDbVersion)).Count(ctx) c, _ := client.Setting.Query().Where(setting.NameEQ(DBVersionPrefix + requiredDbVersion)).Count(ctx)
if c == 0 {
return true
}
c, _ = client.Setting.Query().Where(
setting.NameEQ(OIDCSigningPrivateKeySetting),
setting.ValueNEQ(""),
).Count(ctx)
return c == 0 return c == 0
} }
@ -66,19 +74,22 @@ func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Co
} }
// List existing settings into a map // List existing settings into a map
existingSettings := make(map[string]struct{}) existingSettings := make(map[string]*ent.Setting)
settings, err := client.Setting.Query().All(ctx) settings, err := client.Setting.Query().All(ctx)
if err != nil { if err != nil {
l.Warning("Failed to query existing settings: %s", err) l.Warning("Failed to query existing settings: %s", err)
} }
for _, s := range settings { for _, s := range settings {
existingSettings[s.Name] = struct{}{} existingSettings[s.Name] = s
} }
l.Info("Insert default settings...") l.Info("Insert default settings...")
for k, v := range DefaultSettings { for k, v := range DefaultSettings {
if _, ok := existingSettings[k]; ok { if existing, ok := existingSettings[k]; ok {
if k == OIDCSigningPrivateKeySetting && existing.Value == "" {
client.Setting.UpdateOne(existing).SetValue(v).SaveX(ctx)
}
l.Debug("Skip inserting setting %s, already exists.", k) l.Debug("Skip inserting setting %s, already exists.", k)
continue continue
} }

@ -3,8 +3,11 @@ package inventory
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem"
"fmt" "fmt"
"io" "io"
@ -483,6 +486,20 @@ var mailTemplateContents = []MailTemplateContent{
}, },
} }
const OIDCSigningPrivateKeySetting = "oidc_signing_private_key"
func mustGenerateOIDCSigningPrivateKey() string {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(fmt.Errorf("failed to generate OIDC signing key: %w", err))
}
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}))
}
var DefaultSettings = map[string]string{ var DefaultSettings = map[string]string{
"siteURL": `http://localhost:5212`, "siteURL": `http://localhost:5212`,
"siteName": `Cloudreve`, "siteName": `Cloudreve`,
@ -524,6 +541,7 @@ var DefaultSettings = map[string]string{
"theme_options": `{"#1976d2":{"light":{"palette":{"primary":{"main":"#1976d2","light":"#42a5f5","dark":"#1565c0"},"secondary":{"main":"#9c27b0","light":"#ba68c8","dark":"#7b1fa2"}}},"dark":{"palette":{"primary":{"main":"#90caf9","light":"#e3f2fd","dark":"#42a5f5"},"secondary":{"main":"#ce93d8","light":"#f3e5f5","dark":"#ab47bc"}}}},"#3f51b5":{"light":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"dark":{"palette":{"primary":{"main":"#9fa8da"},"secondary":{"main":"#ff4081"}}}}}`, "theme_options": `{"#1976d2":{"light":{"palette":{"primary":{"main":"#1976d2","light":"#42a5f5","dark":"#1565c0"},"secondary":{"main":"#9c27b0","light":"#ba68c8","dark":"#7b1fa2"}}},"dark":{"palette":{"primary":{"main":"#90caf9","light":"#e3f2fd","dark":"#42a5f5"},"secondary":{"main":"#ce93d8","light":"#f3e5f5","dark":"#ab47bc"}}}},"#3f51b5":{"light":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"dark":{"palette":{"primary":{"main":"#9fa8da"},"secondary":{"main":"#ff4081"}}}}}`,
"max_parallel_transfer": `4`, "max_parallel_transfer": `4`,
"secret_key": util.RandStringRunesCrypto(256), "secret_key": util.RandStringRunesCrypto(256),
OIDCSigningPrivateKeySetting: mustGenerateOIDCSigningPrivateKey(),
"temp_path": "temp", "temp_path": "temp",
"avatar_path": "avatar", "avatar_path": "avatar",
"avatar_size": "4194304", "avatar_size": "4194304",
@ -689,8 +707,9 @@ var DefaultSettings = map[string]string{
} }
var RedactedSettings = map[string]struct{}{ var RedactedSettings = map[string]struct{}{
"encrypt_master_key": {}, "encrypt_master_key": {},
"secret_key": {}, "secret_key": {},
"oidc_signing_private_key": {},
} }
func init() { func init() {

@ -237,6 +237,13 @@ func IsValidShare(share *ent.Share) error {
return ErrOwnerInactive return ErrOwnerInactive
} }
// Check the owner's current share permission.
ownerGroup, err := owner.Edges.GroupOrErr()
if err != nil || ownerGroup.Permissions == nil ||
!ownerGroup.Permissions.Enabled(int(types.GroupPermissionShare)) {
return ErrSourceFileInvalid
}
// Check source file status // Check source file status
file, err := share.Edges.FileOrErr() file, err := share.Edges.FileOrErr()
if err != nil || file.FileChildren == 0 || file.OwnerID != owner.ID { if err != nil || file.FileChildren == 0 || file.OwnerID != owner.ID {
@ -426,7 +433,8 @@ func withShareEagerLoading(ctx context.Context, q *ent.ShareQuery) *ent.ShareQue
} }
if v, ok := ctx.Value(LoadShareUser{}).(bool); ok && v { if v, ok := ctx.Value(LoadShareUser{}).(bool); ok && v {
q.WithUser(func(q *ent.UserQuery) { q.WithUser(func(q *ent.UserQuery) {
withUserEagerLoading(ctx, q) userCtx := context.WithValue(ctx, LoadUserGroup{}, true)
withUserEagerLoading(userCtx, q)
}) })
} }

@ -0,0 +1,83 @@
package inventory
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
entuser "github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/stretchr/testify/require"
)
func TestIsValidShareChecksOwnerAccess(t *testing.T) {
permissions := &boolset.BooleanSet{}
boolset.Set(types.GroupPermissionShare, true, permissions)
allowedGroup := &ent.Group{Permissions: permissions}
tests := []struct {
name string
status entuser.Status
group *ent.Group
wantErr error
}{
{name: "active owner with share permission", status: entuser.StatusActive, group: allowedGroup},
{name: "active owner without share permission", status: entuser.StatusActive, group: &ent.Group{Permissions: &boolset.BooleanSet{}}, wantErr: ErrSourceFileInvalid},
{name: "missing group", status: entuser.StatusActive, wantErr: ErrSourceFileInvalid},
{name: "missing permissions", status: entuser.StatusActive, group: &ent.Group{}, wantErr: ErrSourceFileInvalid},
{name: "manually banned owner", status: entuser.StatusManualBanned, group: allowedGroup, wantErr: ErrOwnerInactive},
{name: "system banned owner", status: entuser.StatusSysBanned, group: allowedGroup, wantErr: ErrOwnerInactive},
{name: "inactive owner", status: entuser.StatusInactive, group: allowedGroup, wantErr: ErrOwnerInactive},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
owner := &ent.User{ID: 1, Status: tt.status}
owner.SetGroup(tt.group)
share := &ent.Share{}
share.SetUser(owner)
share.SetFile(&ent.File{OwnerID: owner.ID, FileChildren: 1})
require.ErrorIs(t, IsValidShare(share), tt.wantErr)
})
}
}
func TestShareClientRevalidatesOwnerGroup(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
permissions := &boolset.BooleanSet{}
boolset.Set(types.GroupPermissionShare, true, permissions)
group := client.Group.Create().SetName("sharing enabled").SetPermissions(permissions).SaveX(ctx)
restrictedGroup := client.Group.Create().SetName("sharing disabled").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
owner := client.User.Create().SetEmail("owner@example.com").SetNick("owner").SetGroup(group).SaveX(ctx)
root := client.File.Create().SetName(RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
file := client.File.Create().SetName("shared.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(root).SaveX(ctx)
share := client.Share.Create().SetUser(owner).SetFile(file).SaveX(ctx)
shareClient := NewShareClient(client, conf.SQLiteDB, nil)
// Share-info and listing callers only request the owner and file edges.
ctx = context.WithValue(ctx, LoadShareUser{}, true)
ctx = context.WithValue(ctx, LoadShareFile{}, true)
checkShare := func(wantErr error) {
t.Helper()
current, err := shareClient.GetByID(ctx, share.ID)
require.NoError(t, err)
require.ErrorIs(t, IsValidShare(current), wantErr)
}
checkShare(nil)
client.Group.UpdateOne(group).SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
checkShare(ErrSourceFileInvalid)
client.Group.UpdateOne(group).SetPermissions(permissions).SaveX(ctx)
checkShare(nil)
client.User.UpdateOne(owner).SetGroup(restrictedGroup).SaveX(ctx)
checkShare(ErrSourceFileInvalid)
client.User.UpdateOne(owner).SetGroup(group).SaveX(ctx)
checkShare(nil)
}

@ -0,0 +1,89 @@
package auth
import (
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"github.com/golang-jwt/jwt/v5"
)
type OIDCIDTokenClaims struct {
jwt.RegisteredClaims
Nonce string `json:"nonce,omitempty"`
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Picture string `json:"picture,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
}
type JWKSet struct {
Keys []JWK `json:"keys"`
}
type JWK struct {
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
}
func SignOIDCIDToken(privateKeyRaw string, claims *OIDCIDTokenClaims) (string, error) {
key, err := parseRSAPrivateKey(privateKeyRaw)
if err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = oidcSigningKeyID(&key.PublicKey)
return token.SignedString(key)
}
func OIDCJWKSet(privateKeyRaw string) (*JWKSet, error) {
key, err := parseRSAPrivateKey(privateKeyRaw)
if err != nil {
return nil, err
}
kid := oidcSigningKeyID(&key.PublicKey)
return &JWKSet{Keys: []JWK{buildJWK(&key.PublicKey, kid)}}, nil
}
func parseRSAPrivateKey(privateKeyRaw string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(privateKeyRaw))
if block == nil {
return nil, fmt.Errorf("invalid OIDC signing key PEM")
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("invalid OIDC signing key: %w", err)
}
return key, nil
}
func oidcSigningKeyID(key *rsa.PublicKey) string {
der, _ := x509.MarshalPKIXPublicKey(key)
sum := sha256.Sum256(der)
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func buildJWK(key *rsa.PublicKey, kid string) JWK {
return JWK{
Kty: "RSA",
Use: "sig",
Alg: "RS256",
Kid: kid,
N: base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
}
}

@ -1,61 +0,0 @@
package cache
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestSet(t *testing.T) {
asserts := assert.New(t)
asserts.NoError(Set("123", "321", -1))
}
func TestGet(t *testing.T) {
asserts := assert.New(t)
asserts.NoError(Set("123", "321", -1))
value, ok := Get("123")
asserts.True(ok)
asserts.Equal("321", value)
value, ok = Get("not_exist")
asserts.False(ok)
}
func TestDeletes(t *testing.T) {
asserts := assert.New(t)
asserts.NoError(Set("123", "321", -1))
err := Deletes([]string{"123"}, "")
asserts.NoError(err)
_, exist := Get("123")
asserts.False(exist)
}
func TestGetSettings(t *testing.T) {
asserts := assert.New(t)
asserts.NoError(Set("test_1", "1", -1))
values, missed := GetSettings([]string{"1", "2"}, "test_")
asserts.Equal(map[string]string{"1": "1"}, values)
asserts.Equal([]string{"2"}, missed)
}
func TestSetSettings(t *testing.T) {
asserts := assert.New(t)
err := SetSettings(map[string]string{"3": "3", "4": "4"}, "test_")
asserts.NoError(err)
value1, _ := Get("test_3")
value2, _ := Get("test_4")
asserts.Equal("3", value1)
asserts.Equal("4", value2)
}
func TestInit(t *testing.T) {
asserts := assert.New(t)
asserts.NotPanics(func() {
Init()
})
}

@ -1,6 +1,7 @@
package cache package cache
import ( import (
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
"time" "time"
@ -9,7 +10,7 @@ import (
func TestNewMemoStore(t *testing.T) { func TestNewMemoStore(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore() store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
asserts.NotNil(store) asserts.NotNil(store)
asserts.NotNil(store.Store) asserts.NotNil(store.Store)
} }
@ -17,7 +18,7 @@ func TestNewMemoStore(t *testing.T) {
func TestMemoStore_Set(t *testing.T) { func TestMemoStore_Set(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore() store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
err := store.Set("KEY", "vAL", -1) err := store.Set("KEY", "vAL", -1)
asserts.NoError(err) asserts.NoError(err)
@ -28,7 +29,7 @@ func TestMemoStore_Set(t *testing.T) {
func TestMemoStore_Get(t *testing.T) { func TestMemoStore_Get(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore() store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
// 正常情况 // 正常情况
{ {
@ -72,7 +73,7 @@ func TestMemoStore_Get(t *testing.T) {
func TestMemoStore_Gets(t *testing.T) { func TestMemoStore_Gets(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore() store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
err := store.Set("1", "1,val", -1) err := store.Set("1", "1,val", -1)
err = store.Set("2", "2,val", -1) err = store.Set("2", "2,val", -1)
@ -97,7 +98,7 @@ func TestMemoStore_Gets(t *testing.T) {
func TestMemoStore_Sets(t *testing.T) { func TestMemoStore_Sets(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore() store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
err := store.Sets(map[string]interface{}{ err := store.Sets(map[string]interface{}{
"1": "1.val", "1": "1.val",
@ -119,7 +120,7 @@ func TestMemoStore_Sets(t *testing.T) {
func TestMemoStore_Delete(t *testing.T) { func TestMemoStore_Delete(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore() store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
err := store.Sets(map[string]interface{}{ err := store.Sets(map[string]interface{}{
"1": "1.val", "1": "1.val",
@ -129,7 +130,7 @@ func TestMemoStore_Delete(t *testing.T) {
}, "test_") }, "test_")
asserts.NoError(err) asserts.NoError(err)
err = store.Delete([]string{"1", "2"}, "test_") err = store.Delete("test_", "1", "2")
asserts.NoError(err) asserts.NoError(err)
values, miss := store.Gets([]string{"1", "2", "3", "4"}, "test_") values, miss := store.Gets([]string{"1", "2", "3", "4"}, "test_")
asserts.Equal([]string{"1", "2"}, miss) asserts.Equal([]string{"1", "2"}, miss)
@ -138,10 +139,10 @@ func TestMemoStore_Delete(t *testing.T) {
func TestMemoStore_GarbageCollect(t *testing.T) { func TestMemoStore_GarbageCollect(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore() store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
store.Set("test", 1, 1) store.Set("test", 1, 1)
time.Sleep(time.Duration(2000) * time.Millisecond) time.Sleep(time.Duration(2000) * time.Millisecond)
store.GarbageCollect() store.GarbageCollect(logging.NewConsoleLogger(logging.LevelDebug))
_, ok := store.Get("test") _, ok := store.Get("test")
asserts.False(ok) asserts.False(ok)
} }

@ -3,6 +3,8 @@ package cache
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/gomodule/redigo/redis" "github.com/gomodule/redigo/redis"
"github.com/rafaeljusto/redigomock" "github.com/rafaeljusto/redigomock"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -13,16 +15,16 @@ import (
func TestNewRedisStore(t *testing.T) { func TestNewRedisStore(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewRedisStore(10, "tcp", "", "", "0") store := NewRedisStore(logging.NewConsoleLogger(logging.LevelDebug), 10, &conf.Redis{Network: "tcp", Server: "", Password: "", DB: "0"})
asserts.NotNil(store) asserts.NotNil(store)
conn, err := store.pool.Dial() asserts.Panics(func() {
asserts.Nil(conn) store.pool.Dial()
asserts.Error(err) })
testConn := redigomock.NewConn() testConn := redigomock.NewConn()
cmd := testConn.Command("PING").Expect("PONG") cmd := testConn.Command("PING").Expect("PONG")
err = store.pool.TestOnBorrow(testConn, time.Now()) err := store.pool.TestOnBorrow(testConn, time.Now())
if testConn.Stats(cmd) != 1 { if testConn.Stats(cmd) != 1 {
fmt.Println("Command was not used") fmt.Println("Command was not used")
return return
@ -291,7 +293,7 @@ func TestRedisStore_Delete(t *testing.T) {
// 正常 // 正常
{ {
cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectSlice("OK") cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectSlice("OK")
err := store.Delete([]string{"1", "2", "3", "4"}, "test_") err := store.Delete("test_", "1", "2", "3", "4")
asserts.NoError(err) asserts.NoError(err)
if conn.Stats(cmd) != 1 { if conn.Stats(cmd) != 1 {
fmt.Println("Command was not used") fmt.Println("Command was not used")
@ -303,7 +305,7 @@ func TestRedisStore_Delete(t *testing.T) {
{ {
conn.Clear() conn.Clear()
cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectError(errors.New("error")) cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectError(errors.New("error"))
err := store.Delete([]string{"1", "2", "3", "4"}, "test_") err := store.Delete("test_", "1", "2", "3", "4")
asserts.Error(err) asserts.Error(err)
if conn.Stats(cmd) != 1 { if conn.Stats(cmd) != 1 {
fmt.Println("Command was not used") fmt.Println("Command was not used")
@ -318,7 +320,7 @@ func TestRedisStore_Delete(t *testing.T) {
Dial: func() (redis.Conn, error) { return nil, errors.New("error") }, Dial: func() (redis.Conn, error) { return nil, errors.New("error") },
MaxIdle: 10, MaxIdle: 10,
} }
err := store.Delete([]string{"1", "2", "3", "4"}, "test_") err := store.Delete("test_", "1", "2", "3", "4")
asserts.Error(err) asserts.Error(err)
} }
} }

@ -43,6 +43,11 @@ func MasterPingUrl(base *url.URL) *url.URL {
return base.ResolveReference(masterPing) return base.ResolveReference(masterPing)
} }
func MasterOIDCEndpointUrl(base *url.URL, endpoint string) string {
route, _ := url.Parse(endpoint)
return base.ResolveReference(route).String()
}
func MasterSlaveCallbackUrl(base *url.URL, driver, id, secret string) *url.URL { func MasterSlaveCallbackUrl(base *url.URL, driver, id, secret string) *url.URL {
apiBaseURI, _ := url.Parse(path.Join(constants.APIPrefix+"/callback", driver, id, secret)) apiBaseURI, _ := url.Parse(path.Join(constants.APIPrefix+"/callback", driver, id, secret))
return base.ResolveReference(apiBaseURI) return base.ResolveReference(apiBaseURI)

@ -1,94 +0,0 @@
package conf
import (
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/stretchr/testify/assert"
"io/ioutil"
"os"
"testing"
)
// 测试Init日志路径错误
func TestInitPanic(t *testing.T) {
asserts := assert.New(t)
// 日志路径不存在时
asserts.NotPanics(func() {
Init("not/exist/path")
})
asserts.True(util.Exists("conf.ini"))
}
// TestInitDelimiterNotFound 日志路径存在但 Key 格式错误时
func TestInitDelimiterNotFound(t *testing.T) {
asserts := assert.New(t)
testCase := `[Database]
Type = mysql
User = root
Password233root
Host = 127.0.0.1:3306
Name = v3
TablePrefix = v3_`
err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644)
defer func() { err = os.Remove("testConf.ini") }()
if err != nil {
panic(err)
}
asserts.Panics(func() {
Init("testConf.ini")
})
}
// TestInitNoPanic 日志路径存在且合法时
func TestInitNoPanic(t *testing.T) {
asserts := assert.New(t)
testCase := `
[System]
Listen = 3000
HashIDSalt = 1
[Database]
Type = mysql
User = root
Password = root
Host = 127.0.0.1:3306
Name = v3
TablePrefix = v3_`
err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644)
defer func() { err = os.Remove("testConf.ini") }()
if err != nil {
panic(err)
}
asserts.NotPanics(func() {
Init("testConf.ini")
})
}
func TestMapSection(t *testing.T) {
asserts := assert.New(t)
//正常情况
testCase := `
[System]
Listen = 3000
HashIDSalt = 1
[Database]
Type = mysql
User = root
Password:root
Host = 127.0.0.1:3306
Name = v3
TablePrefix = v3_`
err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644)
defer func() { err = os.Remove("testConf.ini") }()
if err != nil {
panic(err)
}
Init("testConf.ini")
err = mapSection("Database", DatabaseConfig)
asserts.NoError(err)
}

@ -698,6 +698,12 @@ func (f *DBFS) GetFileFromDirectLink(ctx context.Context, dl *ent.DirectLink) (f
return nil, fs.ErrDirectLinkInvalid.WithError(fmt.Errorf("file owner is not active")) return nil, fs.ErrDirectLinkInvalid.WithError(fmt.Errorf("file owner is not active"))
} }
// Check the owner's current direct-link permission.
group, err := owner.Edges.GroupOrErr()
if err != nil || group.Settings == nil || group.Settings.SourceBatchSize <= 0 {
return nil, fs.ErrDirectLinkInvalid
}
file := newFile(nil, fileModel) file := newFile(nil, fileModel)
// Traverse to the root file // Traverse to the root file

@ -0,0 +1,87 @@
package dbfs
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
entuser "github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/stretchr/testify/require"
)
type directLinkFileClient struct {
inventory.FileClient
root *ent.File
}
func (c *directLinkFileClient) GetParentFile(_ context.Context, file *ent.File, _ bool) (*ent.File, error) {
if file.FileChildren == c.root.ID {
return c.root, nil
}
return nil, &ent.NotFoundError{}
}
type directLinkSettingProvider struct {
setting.Provider
}
func (directLinkSettingProvider) DBFS(context.Context) *setting.DBFS {
return &setting.DBFS{}
}
func TestGetFileFromDirectLinkChecksOwnerAccess(t *testing.T) {
allowedGroup := &ent.Group{Settings: &types.GroupSetting{SourceBatchSize: 1}}
tests := []struct {
name string
status entuser.Status
group *ent.Group
wantErr bool
}{
{name: "active owner with direct link permission", status: entuser.StatusActive, group: allowedGroup},
{name: "active owner without direct link permission", status: entuser.StatusActive, group: &ent.Group{Settings: &types.GroupSetting{}}, wantErr: true},
{name: "negative batch size", status: entuser.StatusActive, group: &ent.Group{Settings: &types.GroupSetting{SourceBatchSize: -1}}, wantErr: true},
{name: "missing group", status: entuser.StatusActive, wantErr: true},
{name: "missing group settings", status: entuser.StatusActive, group: &ent.Group{}, wantErr: true},
{name: "manually banned owner", status: entuser.StatusManualBanned, group: allowedGroup, wantErr: true},
{name: "system banned owner", status: entuser.StatusSysBanned, group: allowedGroup, wantErr: true},
{name: "inactive owner", status: entuser.StatusInactive, group: allowedGroup, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
owner := &ent.User{ID: 1, Status: tt.status}
owner.SetGroup(tt.group)
root := &ent.File{ID: 1, Name: inventory.RootFolderName, OwnerID: owner.ID}
file := &ent.File{ID: 2, Name: "shared.txt", OwnerID: owner.ID, FileChildren: root.ID}
file.SetOwner(owner)
link := &ent.DirectLink{}
link.SetFile(file)
dbfs := &DBFS{
user: owner,
fileClient: &directLinkFileClient{root: root},
settingClient: directLinkSettingProvider{},
}
got, err := dbfs.GetFileFromDirectLink(context.Background(), link)
if got != nil {
t.Cleanup(got.(*File).Parent.Recycle)
}
if tt.wantErr {
require.Nil(t, got)
var appErr serializer.AppError
require.ErrorAs(t, err, &appErr)
require.Equal(t, fs.ErrDirectLinkInvalid.Code, appErr.Code)
require.Equal(t, fs.ErrDirectLinkInvalid.Msg, appErr.Msg)
return
}
require.NoError(t, err)
require.Same(t, file, got.(*File).Model)
})
}
}

@ -156,8 +156,7 @@ func (c *HTTPClient) Request(method, target string, body io.Reader, opts ...Opti
req.Header.Add(CorrelationHeader, logging.CorrelationID(options.ctx).String()) req.Header.Add(CorrelationHeader, logging.CorrelationID(options.ctx).String())
} }
mode := c.config.System().Mode if c.config != nil && options.masterMeta && c.config.System().Mode == conf.MasterMode {
if options.masterMeta && mode == conf.MasterMode {
req.Header.Add(SiteURLHeader, options.siteURL) req.Header.Add(SiteURLHeader, options.siteURL)
req.Header.Add(SiteIDHeader, options.siteID) req.Header.Add(SiteIDHeader, options.siteID)
req.Header.Add(SiteVersionHeader, constants.BackendVersion) req.Header.Add(SiteVersionHeader, constants.BackendVersion)

@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"github.com/cloudreve/Cloudreve/v4/pkg/auth" "github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
testMock "github.com/stretchr/testify/mock" testMock "github.com/stretchr/testify/mock"
"io" "io"
@ -54,7 +53,7 @@ func TestWithContext(t *testing.T) {
func TestHTTPClient_Request(t *testing.T) { func TestHTTPClient_Request(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
client := NewClientDeprecated(WithSlaveMeta("test")) client := NewClientDeprecated(WithSlaveMeta(1))
// 正常 // 正常
{ {
@ -230,7 +229,6 @@ func TestNopRSCloser_SetFirstFakeChunk(t *testing.T) {
func TestBlackHole(t *testing.T) { func TestBlackHole(t *testing.T) {
a := assert.New(t) a := assert.New(t)
cache.Set("setting_reset_after_upload_failed", "true", 0)
a.NotPanics(func() { a.NotPanics(func() {
BlackHole(strings.NewReader("TestBlackHole")) BlackHole(strings.NewReader("TestBlackHole"))
}) })

@ -52,6 +52,8 @@ type (
SiteURL(ctx context.Context) *url.URL SiteURL(ctx context.Context) *url.URL
// SecretKey returns the secret key for general signature. // SecretKey returns the secret key for general signature.
SecretKey(ctx context.Context) string SecretKey(ctx context.Context) string
// OIDCSigningPrivateKey returns the private key used to sign OIDC ID tokens.
OIDCSigningPrivateKey(ctx context.Context) string
// ActivationEmailTemplate returns the email template for activation. // ActivationEmailTemplate returns the email template for activation.
ActivationEmailTemplate(ctx context.Context) []EmailTemplate ActivationEmailTemplate(ctx context.Context) []EmailTemplate
// ResetEmailTemplate returns the email template for reset password. // ResetEmailTemplate returns the email template for reset password.
@ -740,6 +742,10 @@ func (s *settingProvider) SecretKey(ctx context.Context) string {
return s.getString(ctx, "secret_key", "") return s.getString(ctx, "secret_key", "")
} }
func (s *settingProvider) OIDCSigningPrivateKey(ctx context.Context) string {
return s.getString(ctx, "oidc_signing_private_key", "")
}
func (s *settingProvider) AllSiteURLs(ctx context.Context) []*url.URL { func (s *settingProvider) AllSiteURLs(ctx context.Context) []*url.URL {
rawUrls := s.getStringList(ctx, "siteURL", []string{"http://localhost"}) rawUrls := s.getStringList(ctx, "siteURL", []string{"http://localhost"})
if len(rawUrls) == 0 { if len(rawUrls) == 0 {

@ -0,0 +1,236 @@
package util
import (
"fmt"
"net"
"strings"
)
// IPMatcher checks whether an IP address matches a given filter.
// The filter can be:
// - A single IP address (e.g., "192.168.1.1" or "::1")
// - A CIDR notation (e.g., "192.168.1.0/24" or "2001:db8::/32")
// - An IP range (e.g., "192.168.1.1-192.168.1.255")
// - A wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*")
type IPMatcher struct {
cidr *net.IPNet
ipStart net.IP
ipEnd net.IP
exact net.IP
}
// NewIPMatcher creates an IPMatcher from a filter string.
// Supported formats:
// - CIDR: "192.168.1.0/24", "2001:db8::/32"
// - Range: "192.168.1.1-192.168.1.255", "::1-::10"
// - Wildcard: "192.168.1.*", "192.168.*.*", "10.*.*.*"
// - Single IP: "192.168.1.1", "::1"
func NewIPMatcher(filter string) (*IPMatcher, error) {
filter = strings.TrimSpace(filter)
if filter == "" {
return nil, fmt.Errorf("empty IP filter")
}
// Try CIDR notation first
if strings.Contains(filter, "/") {
_, cidrNet, err := net.ParseCIDR(filter)
if err == nil {
return &IPMatcher{cidr: cidrNet}, nil
}
// If it has "/" but isn't valid CIDR, continue to try other formats
}
// Try IP range (e.g., "192.168.1.1-192.168.1.255")
if strings.Count(filter, "-") == 1 {
parts := strings.SplitN(filter, "-", 2)
start := net.ParseIP(strings.TrimSpace(parts[0]))
end := net.ParseIP(strings.TrimSpace(parts[1]))
if start != nil && end != nil {
// Ensure same IP version
if (start.To4() != nil) == (end.To4() != nil) {
return &IPMatcher{ipStart: start, ipEnd: end}, nil
}
return nil, fmt.Errorf("IP range must contain same IP version: %s", filter)
}
}
// Try wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*")
if strings.Contains(filter, "*") {
cidr, err := wildcardToCIDR(filter)
if err == nil {
return &IPMatcher{cidr: cidr}, nil
}
return nil, err
}
// Try single exact IP
ip := net.ParseIP(filter)
if ip != nil {
return &IPMatcher{exact: ip}, nil
}
return nil, fmt.Errorf("invalid IP filter format: %s", filter)
}
// Match checks if the given IP address matches the filter.
// The ip parameter should be a string representation of an IP address.
// Returns an error if the IP string is invalid.
func (m *IPMatcher) Match(ip string) (bool, error) {
parsedIP := net.ParseIP(strings.TrimSpace(ip))
if parsedIP == nil {
return false, fmt.Errorf("invalid IP address: %s", ip)
}
return m.MatchIP(parsedIP), nil
}
// MatchIP checks if the given parsed IP address matches the filter.
func (m *IPMatcher) MatchIP(ip net.IP) bool {
if ip == nil {
return false
}
switch {
case m.cidr != nil:
return m.cidr.Contains(ip)
case m.ipStart != nil && m.ipEnd != nil:
return ipInRange(ip, m.ipStart, m.ipEnd)
case m.exact != nil:
return m.exact.Equal(ip)
}
return false
}
// ipInRange checks if ip is within the inclusive range [start, end].
func ipInRange(ip, start, end net.IP) bool {
// Normalize to 16-byte representation for comparison
ip16 := ip.To16()
start16 := start.To16()
end16 := end.To16()
return bytesCompare(start16, ip16) <= 0 && bytesCompare(ip16, end16) <= 0
}
// bytesCompare compares two byte slices lexicographically.
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
func bytesCompare(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] < b[i] {
return -1
}
if a[i] > b[i] {
return 1
}
}
if len(a) < len(b) {
return -1
}
if len(a) > len(b) {
return 1
}
return 0
}
// wildcardToCIDR converts a wildcard IP pattern to a CIDR net.
// Only supports IPv4 wildcards (e.g., "192.168.1.*" -> "192.168.1.0/24").
func wildcardToCIDR(pattern string) (*net.IPNet, error) {
pattern = strings.TrimSpace(pattern)
parts := strings.Split(pattern, ".")
if len(parts) != 4 {
return nil, fmt.Errorf("wildcard pattern currently only supports IPv4: %s", pattern)
}
// Count how many fixed octets
fixedOctets := 0
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "*" {
// Fill remaining octets with 0 for the network address
for j := i; j < 4; j++ {
parts[j] = "0"
}
break
}
fixedOctets++
}
// All wildcards would be 0.0.0.0/0
if fixedOctets == 0 {
return &net.IPNet{
IP: net.IPv4(0, 0, 0, 0),
Mask: net.CIDRMask(0, 32),
}, nil
}
// Construct CIDR network
cidrStr := fmt.Sprintf("%s/%d", strings.Join(parts, "."), fixedOctets*8)
_, cidrNet, err := net.ParseCIDR(cidrStr)
if err != nil {
return nil, fmt.Errorf("invalid wildcard pattern: %s (%w)", pattern, err)
}
return cidrNet, nil
}
// IsCIDRNotation checks if a filter string is a valid CIDR notation.
func IsCIDRNotation(filter string) bool {
_, _, err := net.ParseCIDR(strings.TrimSpace(filter))
return err == nil
}
// IPFilterToCIDRs converts an IP filter string to a list of CIDR networks.
// This is useful when you need to convert user-friendly IP filters
// to CIDR notation for display or storage.
// Supports CIDR, wildcard, single IP, and IP range (expanded).
func IPFilterToCIDRs(filter string) ([]*net.IPNet, error) {
matcher, err := NewIPMatcher(filter)
if err != nil {
return nil, err
}
switch {
case matcher.cidr != nil:
return []*net.IPNet{matcher.cidr}, nil
case matcher.exact != nil:
// Single IP -> /32 or /128
if matcher.exact.To4() != nil {
return []*net.IPNet{{
IP: matcher.exact,
Mask: net.CIDRMask(32, 32),
}}, nil
}
return []*net.IPNet{{
IP: matcher.exact,
Mask: net.CIDRMask(128, 128),
}}, nil
case matcher.ipStart != nil && matcher.ipEnd != nil:
// IP range - return as two /32 or /128 CIDRs
// Note: This is a simplified representation; the caller should
// use Match/MatchIP for exact range matching
cidrs := make([]*net.IPNet, 0)
if matcher.ipStart.To4() != nil {
cidrs = append(cidrs, &net.IPNet{
IP: matcher.ipStart,
Mask: net.CIDRMask(32, 32),
})
cidrs = append(cidrs, &net.IPNet{
IP: matcher.ipEnd,
Mask: net.CIDRMask(32, 32),
})
} else {
cidrs = append(cidrs, &net.IPNet{
IP: matcher.ipStart,
Mask: net.CIDRMask(128, 128),
})
cidrs = append(cidrs, &net.IPNet{
IP: matcher.ipEnd,
Mask: net.CIDRMask(128, 128),
})
}
return cidrs, nil
}
return nil, fmt.Errorf("cannot convert filter to CIDR: %s", filter)
}

@ -0,0 +1,432 @@
package util
import (
"net"
"testing"
)
func TestNewIPMatcher_CIDR(t *testing.T) {
tests := []struct {
name string
filter string
wantErr bool
}{
{"IPv4 CIDR /24", "192.168.1.0/24", false},
{"IPv4 CIDR /16", "10.0.0.0/16", false},
{"IPv4 CIDR /32", "192.168.1.1/32", false},
{"IPv4 CIDR /0", "0.0.0.0/0", false},
{"IPv6 CIDR /64", "2001:db8::/64", false},
{"IPv6 CIDR /128", "::1/128", false},
{"Invalid CIDR - bad mask", "192.168.1.0/33", true},
{"Invalid CIDR - bad IP", "999.999.999.999/24", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matcher, err := NewIPMatcher(tt.filter)
if tt.wantErr {
if err == nil {
t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter)
}
return
}
if err != nil {
t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err)
return
}
if matcher.cidr == nil {
t.Errorf("NewIPMatcher(%q) expected CIDR matcher, got nil", tt.filter)
}
})
}
}
func TestNewIPMatcher_Range(t *testing.T) {
tests := []struct {
name string
filter string
wantErr bool
}{
{"IPv4 range", "192.168.1.1-192.168.1.255", false},
{"IPv4 range with spaces", "192.168.1.1 - 192.168.1.255", false},
{"IPv6 range", "::1-::10", false},
{"Cross IP version", "192.168.1.1-::1", true},
{"Invalid start", "abc-192.168.1.255", true},
{"Invalid end", "192.168.1.1-abc", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matcher, err := NewIPMatcher(tt.filter)
if tt.wantErr {
if err == nil {
t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter)
}
return
}
if err != nil {
t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err)
return
}
if matcher.ipStart == nil || matcher.ipEnd == nil {
t.Errorf("NewIPMatcher(%q) expected range matcher, got nil", tt.filter)
}
})
}
}
func TestNewIPMatcher_Wildcard(t *testing.T) {
tests := []struct {
name string
filter string
wantErr bool
}{
{"One wildcard", "192.168.1.*", false},
{"Two wildcards", "192.168.*.*", false},
{"Three wildcards", "192.*.*.*", false},
{"All wildcards", "*.*.*.*", false},
{"IPv6 not supported", "2001:db8::*", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matcher, err := NewIPMatcher(tt.filter)
if tt.wantErr {
if err == nil {
t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter)
}
return
}
if err != nil {
t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err)
return
}
if matcher.cidr == nil {
t.Errorf("NewIPMatcher(%q) expected CIDR matcher (from wildcard), got nil", tt.filter)
}
})
}
}
func TestNewIPMatcher_SingleIP(t *testing.T) {
tests := []struct {
name string
filter string
wantErr bool
}{
{"IPv4 single", "192.168.1.1", false},
{"IPv6 single", "::1", false},
{"IPv6 full", "2001:db8::1", false},
{"Invalid", "not-an-ip", true},
{"Empty", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matcher, err := NewIPMatcher(tt.filter)
if tt.wantErr {
if err == nil {
t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter)
}
return
}
if err != nil {
t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err)
return
}
if matcher.exact == nil {
t.Errorf("NewIPMatcher(%q) expected exact IP matcher, got nil", tt.filter)
}
})
}
}
func TestIPMatcher_Match_CIDR(t *testing.T) {
matcher, err := NewIPMatcher("192.168.1.0/24")
if err != nil {
t.Fatalf("Failed to create matcher: %v", err)
}
tests := []struct {
ip string
match bool
}{
{"192.168.1.1", true},
{"192.168.1.255", true},
{"192.168.1.0", true},
{"192.168.2.1", false},
{"10.0.0.1", false},
{"invalid", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
matched, err := matcher.Match(tt.ip)
if tt.ip == "invalid" {
if err == nil {
t.Errorf("Match(%q) expected error", tt.ip)
}
return
}
if err != nil {
t.Errorf("Match(%q) unexpected error: %v", tt.ip, err)
return
}
if matched != tt.match {
t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match)
}
})
}
}
func TestIPMatcher_Match_IPv6CIDR(t *testing.T) {
matcher, err := NewIPMatcher("2001:db8::/32")
if err != nil {
t.Fatalf("Failed to create matcher: %v", err)
}
tests := []struct {
ip string
match bool
}{
{"2001:db8::1", true},
{"2001:db8:1234::1", true},
{"2001:db9::1", false},
{"::1", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
matched, err := matcher.Match(tt.ip)
if err != nil {
t.Errorf("Match(%q) unexpected error: %v", tt.ip, err)
return
}
if matched != tt.match {
t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match)
}
})
}
}
func TestIPMatcher_Match_Range(t *testing.T) {
matcher, err := NewIPMatcher("192.168.1.10-192.168.1.20")
if err != nil {
t.Fatalf("Failed to create matcher: %v", err)
}
tests := []struct {
ip string
match bool
}{
{"192.168.1.10", true},
{"192.168.1.15", true},
{"192.168.1.20", true},
{"192.168.1.9", false},
{"192.168.1.21", false},
{"192.168.2.1", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
matched, err := matcher.Match(tt.ip)
if err != nil {
t.Errorf("Match(%q) unexpected error: %v", tt.ip, err)
return
}
if matched != tt.match {
t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match)
}
})
}
}
func TestIPMatcher_Match_Wildcard(t *testing.T) {
matcher, err := NewIPMatcher("192.168.*.*")
if err != nil {
t.Fatalf("Failed to create matcher: %v", err)
}
tests := []struct {
ip string
match bool
}{
{"192.168.1.1", true},
{"192.168.255.255", true},
{"192.168.0.0", true},
{"192.169.1.1", false},
{"10.0.0.1", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
matched, err := matcher.Match(tt.ip)
if err != nil {
t.Errorf("Match(%q) unexpected error: %v", tt.ip, err)
return
}
if matched != tt.match {
t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match)
}
})
}
}
func TestIPMatcher_Match_SingleIP(t *testing.T) {
matcher, err := NewIPMatcher("192.168.1.1")
if err != nil {
t.Fatalf("Failed to create matcher: %v", err)
}
tests := []struct {
ip string
match bool
}{
{"192.168.1.1", true},
{"192.168.1.2", false},
{"192.168.1.0", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
matched, _ := matcher.Match(tt.ip)
if matched != tt.match {
t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match)
}
})
}
}
func TestIPMatcher_MatchIP_WithNetIP(t *testing.T) {
matcher, err := NewIPMatcher("10.0.0.0/8")
if err != nil {
t.Fatalf("Failed to create matcher: %v", err)
}
// Test MatchIP with pre-parsed net.IP
ip := net.ParseIP("10.255.255.255")
if !matcher.MatchIP(ip) {
t.Errorf("MatchIP(%v) = false, want true", ip)
}
ip = net.ParseIP("11.0.0.1")
if matcher.MatchIP(ip) {
t.Errorf("MatchIP(%v) = true, want false", ip)
}
// Test nil IP
if matcher.MatchIP(nil) {
t.Errorf("MatchIP(nil) = true, want false")
}
}
func TestWildcardToCIDR(t *testing.T) {
tests := []struct {
pattern string
cidr string
wantErr bool
}{
{"192.168.1.*", "192.168.1.0/24", false},
{"192.168.*.*", "192.168.0.0/16", false},
{"192.*.*.*", "192.0.0.0/8", false},
{"*.*.*.*", "0.0.0.0/0", false},
{"10.0.*.*", "10.0.0.0/16", false},
}
for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
cidrNet, err := wildcardToCIDR(tt.pattern)
if tt.wantErr {
if err == nil {
t.Errorf("wildcardToCIDR(%q) expected error, got nil", tt.pattern)
}
return
}
if err != nil {
t.Errorf("wildcardToCIDR(%q) unexpected error: %v", tt.pattern, err)
return
}
if cidrNet.String() != tt.cidr {
t.Errorf("wildcardToCIDR(%q) = %q, want %q", tt.pattern, cidrNet.String(), tt.cidr)
}
})
}
}
func TestIsCIDRNotation(t *testing.T) {
tests := []struct {
filter string
isCIDR bool
}{
{"192.168.1.0/24", true},
{"2001:db8::/32", true},
{"0.0.0.0/0", true},
{"192.168.1.1", false},
{"not-a-cidr", false},
{"192.168.1.*", false},
{"192.168.1.1-192.168.1.255", false},
}
for _, tt := range tests {
t.Run(tt.filter, func(t *testing.T) {
if got := IsCIDRNotation(tt.filter); got != tt.isCIDR {
t.Errorf("IsCIDRNotation(%q) = %v, want %v", tt.filter, got, tt.isCIDR)
}
})
}
}
func TestIPMatcher_BackwardCompatible(t *testing.T) {
// Ensures existing exact IP filtering still works
matcher, err := NewIPMatcher("123.45.67.89")
if err != nil {
t.Fatalf("Failed to create matcher for exact IP: %v", err)
}
matched, err := matcher.Match("123.45.67.89")
if err != nil {
t.Fatalf("Match failed: %v", err)
}
if !matched {
t.Error("Exact IP should match")
}
matched, err = matcher.Match("123.45.67.90")
if err != nil {
t.Fatalf("Match failed: %v", err)
}
if matched {
t.Error("Different exact IP should not match")
}
}
func TestIPMatcher_IPv6Range(t *testing.T) {
matcher, err := NewIPMatcher("::1-::5")
if err != nil {
t.Fatalf("Failed to create matcher: %v", err)
}
tests := []struct {
ip string
match bool
}{
{"::1", true},
{"::3", true},
{"::5", true},
{"::6", false},
{"::0", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
matched, err := matcher.Match(tt.ip)
if err != nil {
t.Errorf("Match(%q) unexpected error: %v", tt.ip, err)
return
}
if matched != tt.match {
t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match)
}
})
}
}

@ -6,6 +6,23 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func OpenIDConfiguration(c *gin.Context) {
service := &oauth.DiscoveryService{}
c.JSON(200, service.Get(c))
}
func OpenIDJWKS(c *gin.Context) {
service := &oauth.JWKService{}
res, err := service.Get(c)
if err != nil {
c.JSON(500, serializer.Err(c, err))
c.Abort()
return
}
c.JSON(200, res)
}
func GetAppRegistration(c *gin.Context) { func GetAppRegistration(c *gin.Context) {
service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{}) service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{})
app, err := service.Get(c) app, err := service.Get(c)

@ -208,6 +208,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
*/ */
r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"}))) r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"})))
r.Use(middleware.SharePreview(dep)) r.Use(middleware.SharePreview(dep))
r.GET(".well-known/openid-configuration", controllers.OpenIDConfiguration)
r.Use(middleware.FrontendFileHandler(dep)) r.Use(middleware.FrontendFileHandler(dep))
r.GET("manifest.json", controllers.Manifest) r.GET("manifest.json", controllers.Manifest)
@ -333,6 +334,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromForm[oauth.ExchangeTokenService](oauth.ExchangeTokenParamCtx{}), controllers.FromForm[oauth.ExchangeTokenService](oauth.ExchangeTokenParamCtx{}),
controllers.ExchangeToken, controllers.ExchangeToken,
) )
oauthRouter.GET("jwks", controllers.OpenIDJWKS)
oauthRouter.GET("userinfo", oauthRouter.GET("userinfo",
middleware.LoginRequired(), middleware.LoginRequired(),
controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}), controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}),

@ -16,6 +16,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/samber/lo" "github.com/samber/lo"
) )
@ -50,6 +51,7 @@ type (
ResponseType string `json:"response_type" binding:"required,eq=code"` ResponseType string `json:"response_type" binding:"required,eq=code"`
RedirectURI string `json:"redirect_uri" binding:"required"` RedirectURI string `json:"redirect_uri" binding:"required"`
State string `json:"state" binding:"max=4096"` State string `json:"state" binding:"max=4096"`
Nonce string `json:"nonce" binding:"max=4096"`
Scope string `json:"scope" binding:"required"` Scope string `json:"scope" binding:"required"`
CodeChallenge string `json:"code_challenge" binding:"max=255"` CodeChallenge string `json:"code_challenge" binding:"max=255"`
CodeChallengeMethod string `json:"code_challenge_method" binding:"omitempty,eq=S256"` CodeChallengeMethod string `json:"code_challenge_method" binding:"omitempty,eq=S256"`
@ -84,7 +86,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) {
} }
// Parse requested scopes (space-separated per OAuth 2.0 spec) // Parse requested scopes (space-separated per OAuth 2.0 spec)
requestedScopes := strings.Split(s.Scope, " ") requestedScopes := strings.Fields(s.Scope)
// Validate requested scopes: must be a subset of registered app scopes // Validate requested scopes: must be a subset of registered app scopes
if !auth.ValidateScopes(requestedScopes, app.Scopes) { if !auth.ValidateScopes(requestedScopes, app.Scopes) {
@ -108,6 +110,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) {
UserID: user.ID, UserID: user.ID,
Scopes: requestedScopes, Scopes: requestedScopes,
RedirectURI: s.RedirectURI, RedirectURI: s.RedirectURI,
Nonce: s.Nonce,
CodeChallenge: s.CodeChallenge, CodeChallenge: s.CodeChallenge,
} }
@ -129,6 +132,7 @@ type (
ClientSecret string `form:"client_secret" binding:"required"` ClientSecret string `form:"client_secret" binding:"required"`
GrantType string `form:"grant_type" binding:"required,eq=authorization_code"` GrantType string `form:"grant_type" binding:"required,eq=authorization_code"`
Code string `form:"code" binding:"required"` Code string `form:"code" binding:"required"`
RedirectURI string `form:"redirect_uri"`
CodeVerifier string `form:"code_verifier"` CodeVerifier string `form:"code_verifier"`
} }
) )
@ -159,6 +163,11 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error)
if authCode.ClientID != s.ClientID { if authCode.ClientID != s.ClientID {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil) return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil)
} }
if s.RedirectURI == "" {
dep.Logger().Warning("OAuth client %q did not provide redirect_uri in token request; it may become required in a future release", s.ClientID)
} else if authCode.RedirectURI != s.RedirectURI {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Redirect URI mismatch", nil)
}
// 3. Verify PKCE: SHA256(code_verifier) should match code_challenge // 3. Verify PKCE: SHA256(code_verifier) should match code_challenge
if authCode.CodeChallenge != "" { if authCode.CodeChallenge != "" {
@ -230,9 +239,47 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error)
} }
} }
if lo.Contains(authCode.Scopes, types.ScopeOpenID) {
idToken, err := buildIDToken(c, dep, user, s.ClientID, authCode.Scopes, token.AccessExpires, authCode.Nonce)
if err != nil {
return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue ID token", err)
}
resp.IDToken = idToken
}
return resp, nil return resp, nil
} }
func buildIDToken(c *gin.Context, dep dependency.Dep, user *ent.User, clientID string, scopes []string, expires time.Time, nonce string) (string, error) {
sub := hashid.EncodeUserID(dep.HashIDEncoder(), user.ID)
claims := &auth.OIDCIDTokenClaims{
Nonce: nonce,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: oidcIssuer(c).String(),
Subject: sub,
Audience: jwt.ClaimStrings{clientID},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(expires),
},
}
for _, scope := range scopes {
switch scope {
case types.ScopeProfile:
siteUrl := dep.SettingProvider().SiteURL(c)
claims.Name = user.Nick
claims.PreferredUsername = user.Nick
claims.Picture = routes.MasterUserAvatarUrl(siteUrl, sub).String()
claims.UpdatedAt = user.UpdatedAt.Unix()
case types.ScopeEmail:
claims.Email = user.Email
claims.EmailVerified = true
}
}
return auth.SignOIDCIDToken(dep.SettingProvider().OIDCSigningPrivateKey(c), claims)
}
type ( type (
DeleteOAuthGrantParamCtx struct{} DeleteOAuthGrantParamCtx struct{}
DeleteOAuthGrantService struct { DeleteOAuthGrantService struct {

@ -0,0 +1,72 @@
package oauth
import (
"net/url"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/gin-gonic/gin"
)
type DiscoveryService struct{}
type JWKService struct{}
func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse {
issuer := oidcIssuer(c)
return &DiscoveryResponse{
Issuer: issuer.String(),
AuthorizationEndpoint: routes.MasterOIDCEndpointUrl(issuer, "/session/authorize"),
TokenEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/token"),
UserInfoEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/userinfo"),
JWKSURI: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/jwks"),
ResponseTypesSupported: []string{
"code",
},
GrantTypesSupported: []string{
"authorization_code",
},
SubjectTypesSupported: []string{
"public",
},
IDTokenSigningAlgValuesSupported: []string{
"RS256",
},
TokenEndpointAuthMethods: []string{
"client_secret_post",
},
CodeChallengeMethodsSupported: []string{
"S256",
},
ScopesSupported: []string{
types.ScopeOpenID,
types.ScopeProfile,
types.ScopeEmail,
},
ClaimsSupported: []string{
"sub",
"name",
"preferred_username",
"picture",
"updated_at",
"email",
"email_verified",
},
}
}
func (s *JWKService) Get(c *gin.Context) (*auth.JWKSet, error) {
dep := dependency.FromContext(c)
return auth.OIDCJWKSet(dep.SettingProvider().OIDCSigningPrivateKey(c))
}
func oidcIssuer(c *gin.Context) *url.URL {
dep := dependency.FromContext(c)
issuer := *dep.SettingProvider().SiteURL(c)
issuer.RawQuery = ""
issuer.Fragment = ""
return &issuer
}

@ -19,6 +19,22 @@ type AppRegistration struct {
ConstentedScopes []string `json:"consented_scopes,omitempty"` ConstentedScopes []string `json:"consented_scopes,omitempty"`
} }
type DiscoveryResponse struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
JWKSURI string `json:"jwks_uri"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
}
func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration { func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration {
res := &AppRegistration{ res := &AppRegistration{
ID: app.GUID, ID: app.GUID,
@ -50,6 +66,7 @@ type TokenResponse struct {
ExpiresIn int64 `json:"expires_in"` ExpiresIn int64 `json:"expires_in"`
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"`
RefreshToken string `json:"refresh_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token,omitempty"`
Scope string `json:"scope"` Scope string `json:"scope"`
} }
@ -77,6 +94,7 @@ type AuthorizationCode struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
RedirectURI string `json:"redirect_uri"` RedirectURI string `json:"redirect_uri"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"code_challenge"` CodeChallenge string `json:"code_challenge"`
} }

Loading…
Cancel
Save