Compare commits

...

156 Commits

Author SHA1 Message Date
renovate[bot] 6ed1f82e94 fix(deps): update module github.com/protonmail/gopenpgp/v2 to v3 2026-10-05 18:40:29 +00:00
naiy_ 4c39bbe9c2 fix(drivers/189pc): align login with the official PC client (#3105)
QR code scans were authorized on the phone but the storage stayed stuck on
the QR page, and token-only setups failed with "params is null".

The driver used appId 8025431004 while the official PC client uses
9317140619. Tokens, sessions and QR sessions are all scoped to an appId, so
the mismatch meant the QR poll never saw status:0 and an accessToken could
not be exchanged for a sessionSecret.

- Use appId 9317140619 and version 7.2.4.0. QR state polling uses
  clientType=1; password login keeps 10020.
- Send the QR poll parameters the official client sends (cb_SaveName,
  isOauth2, state, user-finger header, logbox Referer) and poll locally
  instead of only checking once per save.
- Parse lt/reqId from the logbox redirect and paramId from appConf.do. The
  new login page no longer embeds them as inline variables; the old inline
  format is still supported.
- Implement the -133 second device verification via
  sendSmsCodeForSecondAuth/submitForSecondAuth. That endpoint has no
  dedicated SMS field: the code goes into epd, encrypted with the same
  public key used for the password. Persist the DEVICEID cookie so the
  verification only happens once.
- Detect refreshToken.do failures. It reports them as HTTP 200 with a
  result field, so SetError never fired and a failed refresh was treated as
  success, surfacing later as a misleading "params is null".
- username/password are no longer required, so token-only storages save
  without placeholders. clientSn/jgOpenId are optional and only sent when
  configured; user-finger is generated once per storage.
- Return named errors instead of panicking when the login page changes shape.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:45:29 +08:00
帅丘 e1d88b071f fix(drivers/lanzou): support new /fn download page structure (#3157)
Lanzou recently changed the file share page: download params moved
into an iframe (/fn?<token>) inner page, and the download API is now
an absolute URL (https://apifile.woozooo.com/ajaxfile.php?file=N)
with new sign params (wp_sign/ajaxdata, action=downprocess).

The old findFileIDReg ('/ajaxm.php?file=N' relative path) no longer
matches, causing 'failed link: failed get link: not find file id'.

Fall back to parsing the new /fn page structure when the legacy
regex does not match. Legacy flow is kept untouched.

Signed-off-by: rzsgsfm <rzsgsfm@users.noreply.github.com>
Co-authored-by: rzsgsfm <rzsgsfm@users.noreply.github.com>
Co-authored-by: GLM (ZCode CLI) <noreply@z.ai>
2026-10-03 14:50:51 +08:00
ILoveScratch ea10624fb6 fix: harden request handling for files, search, proxy and SSO
Co-authored-by: PIKACHUIM <PIKACHUIM@users.noreply.github.com>
2026-09-29 19:05:50 +08:00
ILoveScratch 54ae9d7451 fix(internal/db/searchnode): fix index update path (#3128) 2026-09-24 19:25:29 +08:00
Yinan Qin c16701b94b fix(build): update cgo-actions version to v1.3.0 and fallback FreeBSD version to 14.4 (#3130) 2026-09-24 19:23:21 +08:00
Nostalgia 893457cd50 fix(aliyundrive): limit callback concurrency (#3071)
* fix(aliyundrive): limit callback concurrency

- Share proxy callback admission by Aliyun user identity and hold permits for complete response-body lifetimes.
- Retry only verified callback-capacity rejections while preserving direct redirects and server download limiting.
- Map exhausted temporary capacity to S3 SlowDown through the merged OpenListTeam gofakes3 module.
- Cover shared limits, lifecycle release, cancellation, retry classification, and the S3 HTTP response.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

# Conflicts:
#	go.mod
#	go.sum
#	server/s3/pager.go

* fix(op): separate redirect and proxy link cache entries

- Include redirect mode in the link cache key for all drivers.

- Cover both redirect-to-proxy and proxy-to-redirect cache reuse.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(proxy): close range bodies before opening next

- make ServeHTTP own each range body and preserve cleanup failures
- remove the aggregate range closer and pass range readers directly
- replace the obsolete callback transport test with focused lifecycle coverage

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

---------

Co-authored-by: nostalume <nostalucent@gmail.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-24 12:01:50 +08:00
Nostalgia 90acfa18e4 refactor(context): centralize request origin access (#3100) 2026-09-24 01:57:21 +08:00
Nostalgia 1462d63a48 fix(net): preserve cancellation errors during partitioned downloads (#3090) 2026-09-24 01:47:30 +08:00
spark cadbf87246 fix(teldrive): use a valid root path for listings (#3108) 2026-09-24 01:46:35 +08:00
ZRHan 9de3f69b8f fix(fs): only list parent dir for related objs when file is video (#3113) 2026-09-24 01:46:14 +08:00
Wray e73a80c78c feat(drivers/pikpak): auto re-login with username/password when tokens expire (#3001) 2026-09-24 01:29:56 +08:00
ZRHan 6c6009109f fix(fs): return FOLDER type for directories in fs/get responses (#3114) 2026-09-24 01:26:42 +08:00
Nostalgia b51d1c8284 fix(s3): close ranged response bodies (#3095) 2026-09-24 01:26:26 +08:00
ILoveScratch f286862c61 refactor(stream): index parked readers by offset instead of full scans (#3048) 2026-09-24 01:25:57 +08:00
awaae 40f4f6546f fix(ftp): handle EOF and detect content type from read bytes (#3125) 2026-09-24 01:20:20 +08:00
Nostalgia 3a31b438a9 refactor(offline): centralize native tool setup (#3096)
- Keep storage-to-tool identity in the offline tool package.
- Share settings persistence, tool initialization, and storage admission across handlers.
- Preserve endpoint payloads and unsupported-storage diagnostics with focused tests.

Co-authored-by: nostalume <nostalucent@gmail.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-21 16:54:22 +08:00
Nostalgia 56064d1981 fix(op): enforce link cache lifecycle policy (#3101)
- Share one admitted lifecycle policy between regular and archive links.
- Reject and release links that combine TTL caching with owned resources.
- Keep wrapper clones independent from the source cache expiration.
- Cover reuse, reference, invalidation, conflict, and clone behavior.

Co-authored-by: nostalume <nostalucent@gmail.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-21 16:54:12 +08:00
Ziheng Mao d894a3983b fix(115_open): refresh expired OSS credentials during upload (#3063)
- Refresh OSS credentials and retry the rejected multipart operation
- Preserve upload IDs, completed parts, callbacks, and outer part retries
- Rewind parts before resending and pass the upload context to OSS requests

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-21 10:38:14 +08:00
ShenLin 084c008102 chore(build): replace uncontrolled email domain (#3103)
- Use the controlled oplist.org domain for embedded build authors
- Update shell and release workflow build metadata

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-20 14:25:20 +08:00
Fighting 4580c4db33 fix(alist_v3): set child paths so nested directories resolve (#3019)
* fix(alist_v3): set child paths so nested directories resolve

- Set `Path` on every object returned by `List`, matching the OpenList
  driver. `op.Get` hands a child object straight back to `List`, so a
  child without a path made the driver request `""` from the upstream
  server, which answered with its own root: every directory below the
  mount point served the same listing back, endlessly.
- Add tests covering the child paths and a three-level descent.

Co-authored-by: Claude <81847+claude@users.noreply.github.com>

* test(alist_v3): trim the child-path test to a single case

Collapse the two tests into one two-level descent, drop the recorder
type and the helper funcs, and inline the driver setup. 173 -> 65 lines.

Co-authored-by: Claude <81847+claude@users.noreply.github.com>

---------

Co-authored-by: Fighting <3899648+Elity@users.noreply.github.com>
Co-authored-by: Claude <81847+claude@users.noreply.github.com>
2026-09-15 23:13:10 +08:00
Nostalgia 5447ecb072 fix(s3): encode multipart fixture paths (#3074)
test(s3): encode multipart fixture paths

- Serialize the Local storage addition instead of interpolating filesystem paths into JSON.
- Keep multipart fixture behavior portable across Windows and Linux.

Co-authored-by: nostalume <nostalucent@gmail.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-14 20:30:17 +08:00
flyingrtx f18b4acc76 feat(local): add PDF thumbnails on macOS (#3017)
- add an opt-in Local driver setting for PDF thumbnails
- render PDF first pages with macOS Quick Look
- preserve unsupported-platform behavior and cover the renderer with tests

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Co-authored-by: ILoveScratch <ilovescratch@foxmail.com>
2026-09-10 19:45:24 +08:00
fryeggs d6109a7940 feat(task): persist task timestamps across restarts (#2914)
Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-09-10 19:20:11 +08:00
ShenLin 0463da4034 chore(ci): enforce AI disclosures and lock invalid issues (#3059)
ci(github): enforce AI disclosures and lock invalid issues

- Require exactly one AI disclosure option and a model name when AI is used
- Comment, close, lock, and label issues invalid for missing or malformed AI disclosures or missing model names
- Lock issues closed for unchanged placeholder titles
- Lock issues closed for selecting the unread checklist option
- Exclude AI disclosure options from unchecked task detection
- Skip automatic checks for announcement titles
- Update bilingual closure replies to mention locking and request a new issue
- Split issue replies and PR title checks into separate workflows without changing PR title rules

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-08 15:20:36 +08:00
Pikachu Ren 2d51c9ab4b feat!(init): add initialization wizard (#3041)
feat: add system initialization (setup wizard) support

Co-authored-by: PIKACHUIM <PIKACHUIM@users.noreply.github.com>
2026-09-07 14:14:22 +08:00
ShenLin d9d8aa24e6 fix(s3): default upload content types and return partial content (#3053)
- Default missing upload MIME types to application/octet-stream before passing streams to storage drivers.
- Return HTTP 206 for successful ranged GET responses while preserving error statuses.
- Add isolated response-status regression tests without database initialization.

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-07 12:01:15 +08:00
ShenLin 55530ff171 fix(release): fetch frontend assets from edge (#3052)
- Fetch frontend prerelease assets from edge after release immutability was accidentally enabled for rolling.

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-07 12:00:50 +08:00
MadDogOwner 6247cf7be2 feat(server/s3): support multipart upload (#2813) 2026-09-05 15:56:40 +08:00
renovate[bot] eee910babb fix(deps): update module github.com/rclone/rclone to v1.75.1 (#3035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-05 12:22:53 +08:00
renovate[bot] 6b55a82ffe chore(deps): update docker/setup-qemu-action digest to 1f40c72 (#3021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-05 12:14:09 +08:00
renovate[bot] 93dac1655f chore(deps): update go toolchain directive to v1.27.1 (#3024)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-05 12:12:51 +08:00
Pikachu Ren 6ad44605c0 feat(drivers/guangyapan): add md5-based instant upload support (#3034)
feat(guangyapan): add md5-based instant upload support

Co-authored-by: PIKACHUIM <PIKACHUIM@users.noreply.github.com>
2026-09-05 12:11:51 +08:00
UcnacDx2 d90d84906e fix(drivers/139): improve mail login credential renewal (#3029)
* fix(drivers/139): improve mail login credential renewal

* fix(drivers/139): guard mail login client initialization

Fall back to base.NewRestyClient() when base.RestyClient has not been initialized, while preserving cloned global-client behavior and the login/SMS retry and redirect policies.
2026-09-05 11:55:41 +08:00
ShenLin c3d3da9286 fix(drivers/189): decode JSON strings before parsing timestamps (#3033)
- Decode JSON time strings before normalizing Unicode spaces in both 189 drivers
- Exercise escaped spaces and existing date formats through JSON unmarshalling
- Cover invalid JSON input and XML time parsing

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-09-05 00:12:20 +08:00
renovate[bot] 2bdf16d596 chore(deps): pin dependencies (#2736)
* chore(deps): pin dependencies

* chore: exclude docker from renovate

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-09-01 22:58:15 +08:00
renovate[bot] f8ebaec4f1 fix(deps): update module google.golang.org/grpc to v1.85.0-dev (#3015)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 22:58:10 +08:00
renovate[bot] fc23f4e781 fix(deps): update module github.com/shirou/gopsutil/v4 to v4.26.8 (#3014)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 22:58:05 +08:00
renovate[bot] 3ea9984aea fix(deps): update module golang.org/x/image to v0.45.0 (#2994)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 22:58:00 +08:00
renovate[bot] cc11f354f1 fix(deps): update module github.com/go-webauthn/webauthn to v0.18.0 (#2988)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-09-01 22:57:54 +08:00
renovate[bot] 8869874b76 fix(deps): update module github.com/bmatcuk/doublestar/v4 to v4.10.0 (#2987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 22:57:48 +08:00
ShenLin 523af855ba fix(auth): secure SSO account binding
- Issue and verify short-lived SSO binding state and proof tokens
- Bind provider callbacks to an HttpOnly browser session cookie
- Reject invalid or already-associated SSO identities during profile updates

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
2026-09-01 18:45:40 +08:00
ShenLin bba3516693 fix(upload): authorize direct upload destinations
- Resolve and authorize the canonical destination from the request payload
- Reject upload capabilities that cross virtual storage mount boundaries
- Remove the unrelated File-Path middleware authorization check

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
2026-09-01 18:45:39 +08:00
ShenLin f244adf60e fix(meta): enforce case-insensitive access controls
- Add an invalidated metadata snapshot for case-insensitive fallback lookups
- Enforce segment-aware metadata coverage for passwords and download signatures
- Add regression tests while preserving case-sensitive write authorization

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
2026-09-01 18:45:39 +08:00
abcqqoo ce87b45d2d fix(cmd/start): use absolute executable path for child process (#2125)
* fix(cmd/start): use absolute executable path for child process

* fix(cmd/start): detect force-bin-dir flag variants

---------

Co-authored-by: Zoe Lee <zoelee@gmail.com>
2026-09-01 18:15:06 +08:00
renovate[bot] 4031b31837 fix(deps): update module github.com/azure/azure-sdk-for-go/sdk/azcore to v1.23.1 (#2985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 18:01:59 +08:00
renovate[bot] 9b34ca5bf1 fix(deps): update module github.com/coreos/go-oidc to v2.5.0+incompatible (#2711)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 17:59:51 +08:00
renovate[bot] 666b1a039d fix(deps): update module golang.org/x/crypto to v0.55.0 (#2993)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 17:57:45 +08:00
hugcabbage 8f9a09d359 fix(drivers/guangyapan): report multipart upload progress to copy task (#2989)
multipartUploadToOSS passed the UpdateProgress callback into
stream.NewStreamSectionReader, which ignores the up argument, so the
callback was never invoked while uploading parts. As a result,
cross-storage copy tasks targeting GuangYaPan stayed at 0% and showed no
progress bar or speed in the task list.

Fix by tracking the uploaded byte count and invoking up after every
part is uploaded, keeping consistent with other drivers' upload flow.

Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-09-01 17:22:50 +08:00
renovate[bot] 394bb8f80e chore(deps): update go toolchain directive to v1.26.5 (#2733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 17:25:38 +08:00
无知的错 819fdbd518 fix(drivers/189pc,drivers/189tv): fix extra requests when viewing files (#2791)
* fix(drivers/189pc,drivers/189tv): fix extra requests when viewing files

* fix(drivers/189pc,drivers/189tv): fix extra requests when viewing files
2026-08-29 01:33:16 +08:00
qbisicwate d220b8b005 fix(drivers/189pc)!: normalize escaped apostrophes in names (#2792)
fix(189pc): normalize escaped apostrophes in names

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-29 01:32:57 +08:00
jiwangyihao d12bd4c71e feat(drivers/github): add optional accurate modified time (#2388)
* feat(drivers/github): add optional accurate modified time

* refactor(github): 简化准确修改时间查询

- 将最多 200 个路径合并到一次 GraphQL history 查询,并用 ^{commit} 统一解析 ref

- 删除分批和冗余响应解析,保留失败降级、tree fallback 与缓存行为

- 使用 JSON 字符串字面量支持控制字符路径,并收敛行为测试

---------

Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-29 01:31:11 +08:00
renovate[bot] 4c611ef8de fix(deps): update github.com/cloudsoda/go-smb2 digest to 0b399b9 (#2976)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 01:28:32 +08:00
beardthelion eea8fee7f1 fix(drivers): stop the upload retry loop when the context is canceled (#2892)
The 115 and pikpak multipart uploaders checked for cancellation with a
`case <-ctx.Done()` inside a select, and a break there only leaves the
select, not the enclosing `for retry := 0; retry < 3; retry++` loop.
Cancelling an upload therefore ran all three attempts for every
remaining chunk, each allocating a chunk-sized buffer and reading it
off disk before firing a request that could not succeed, and reported
a transport error instead of the cancellation.

Move the check ahead of the select and use utils.IsCanceled, matching
the pattern the other drivers already use. Assigning ctx.Err() to err
is load-bearing: without it a cancelled chunk takes the success branch,
counts toward progress, and appends a zero-value UploadPart.

Found with staticcheck (SA4011).

Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-29 01:27:00 +08:00
AmPlace c06656958c fix(offline): expose native 115 tools for ED2K downloads (#2920)
* fix(offline): allow ED2K downloads through 115 tools

- Treat 115 Cloud and 115 Open as ED2K-capable tools.
- Keep automatic fallback limited to Thunder tools.
- Add regression coverage for supported and unsupported tools.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(offline): route ED2K to native 115 tools

- Allow ED2K requests initially using SimpleHttp to enter tool routing.
- Prefer the matching 115 tool for 115 Cloud and 115 Open destinations.
- Add regression coverage for native storage tool selection.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(offline): expose native tools for destination storage

- Include the native destination tool in the path-aware tool list.
- Keep existing ready-tool filtering for external destinations.
- Add coverage for native storage to tool mapping.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

---------

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-29 01:22:29 +08:00
Strom 0d277b8550 fix(drivers/strm): respect deployment umask for local directories (#2931)
* feat(strm): add local save permission mode

- add private and shared permission modes for local STRM files
- repair shared-mode directory and file permissions during generation
- add permission handling tests

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(strm): respect deployment umask for local directories

- remove application-level permission modes and chmod operations
- create local STRM directories with umask-controlled permissions
- preserve existing permissions and test the behavior

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

---------

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-08-29 01:20:44 +08:00
Nostalgia e0e4de5e82 fix(server/s3): paginate recursive object listings (#2968)
fix(s3): paginate recursive object listings

- stop recursive traversal after filling the requested S3 page
- preserve lexicographic marker ordering and request cancellation
- cover bounded traversal, continuation, prefixes, and cache safety

Co-authored-by: nostalume <nostalucent@gmail.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-08-29 01:18:42 +08:00
Lythen 39fcaf488b feat(ilanzou): modernize console API session and copy (#2962)
* feat(ilanzou): modernize console API session and copy

Use an isolated cookie-backed API session and preserve raw appToken syntax required by current iLanzou endpoints.

Resolve CDN download responses more robustly, align upload metadata with the console protocol, and delegate copies to OpenList background tasks instead of blocking requests.

* fix(ilanzou): escape appToken query values

Escape opaque appToken values while preserving the literal colon required by iLanzou endpoints. Ignore CDN HEAD response lengths unless the response status is successful.

* fix(ilanzou): address driver review feedback

Use the ilanzou package name consistently, document upload result polling, and bound CDN size probes. Keep the appToken and CDN challenge handling covered by focused tests and comments.

* fix(ilanzou): use context timeout for HEAD probe
2026-08-29 01:17:04 +08:00
ShenLin 31ae0f5bc9 fix(drivers/onedrive_sharelink): reuse shared HTTP clients (#2961)
fix(onedrive_sharelink): reuse shared HTTP clients

- Reuse shared client settings for redirect and GraphQL requests
- Propagate request contexts through password and pagination requests
- Close OneDrive response bodies that were missing cleanup
- Add tests for shared client settings and redirect handling

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-08-29 01:10:01 +08:00
renovate[bot] 3943180898 fix(deps): update module github.com/antchfx/xpath to v1.3.8 (#2977)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 01:04:56 +08:00
renovate[bot] b1cc7c6a93 fix(deps): update module github.com/jlaffaye/ftp to v0.2.4 (#2979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 01:04:00 +08:00
renovate[bot] f6557f3d62 chore(deps): update docker/setup-buildx-action digest to 37fe631 (#2975)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 01:03:14 +08:00
renovate[bot] 9dc6cb29d9 chore(deps): update docker/login-action digest to dbcb813 (#2974)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 01:02:56 +08:00
renovate[bot] b86e4cb8dc fix(deps): update module github.com/pkg/sftp to v1.13.11 (#2980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 01:00:46 +08:00
renovate[bot] fd7fb04ed1 fix(deps): update module github.com/rclone/rclone to v1.75.0 (#2900)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 00:59:42 +08:00
renovate[bot] 80795b8ec3 fix(deps): update module github.com/sirupsen/logrus to v1.10.2 (#2982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 00:52:48 +08:00
renovate[bot] c2b3666818 fix(deps): update module gorm.io/driver/postgres to v1.6.2 (#2981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 00:52:36 +08:00
renovate[bot] edbf42898b fix(deps): update module github.com/blevesearch/bleve/v2 to v2.6.1 (#2978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 00:50:49 +08:00
renovate[bot] ab51cd6eae fix(deps): update module github.com/quic-go/quic-go to v0.61.0 (#2899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 22:09:24 +08:00
renovate[bot] 4619aa2bc5 fix(deps): update module github.com/nwaples/rardecode/v2 to v2.4.1 (#2973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 22:07:52 +08:00
UcnacDx2 376cada3d4 fix(drivers/139): optimize login flow with cookie reuse and robust fallback (#2067)
* fix(drivers/139): optimize login flow with robust tests

* fix(drivers/139): preserve configured HTTP client settings
2026-08-28 20:29:09 +08:00
没空的人 1a6cabf45a fix(drivers/lanzou): fix file download (#2960) 2026-08-24 21:39:39 +08:00
renovate[bot] 0b1e9d0943 fix(deps): update module github.com/blevesearch/bleve/v2 to v2.6.0 (#2704)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 20:38:12 +08:00
quan-ge 18823a23d5 fix(drivers/emby): allow username‑only login (#2949)
fix(drivers/emby): Emby允许仅用户名登录

Signed-off-by: quan-ge <quan13177379368@qq.com>
2026-08-19 20:11:08 +08:00
kanoshiou 150a90f2ac feat(strm): add file size filtering for STRM generation (#2911)
* feat(strm): add file size filtering for STRM generation

- add ParseSize helper function in pkg/utils to parse human-readable byte sizes
- add minFileSize setting field to STRM driver Addition struct
- filter out files below minFileSize threshold during STRM generation

* refactor(strm): use MB as default unit for minFileSize instead of ParseSize

Replace string-based ParseSize auto-conversion with a simple int64 number
field defaulting to MB, consistent with other drivers (Google Drive,
Teldrive). Remove the now-unused ParseSize function and its tests.
2026-08-14 15:34:58 +08:00
Shelken e80ee006ad fix(drivers/189): support updated 189 time format (#2919)
fix(drivers/189): support updated 189 time format with comma, timezone and unicode spaces

189 API 变更了时间返回格式,现有 Time.Unmarshal 无法解析,导致 189pc/189_tv
驱动所有写操作失败(Put → 500)。

新格式有三种形态,现有两个模板均无法覆盖:
1. "Aug 11, 2026, 10:37:18 PM +08" — 年份后多逗号,且自带时区
2. "Aug 12, 2026, 12:35:41\u202fAM +08" — AM/PM 前为 U+202F 窄不换行空格
3. "Aug 12, 2026, 12:35:41\u00a0AM +08" — AM/PM 前为 U+00A0 不换行空格

修复(189pc/help.go、189_tv/help.go 的 Time.Unmarshal):
- 解析前将 U+202F 和 U+00A0 统一替换为普通空格
- 新增模板 "Jan 2, 2006, 3:04:05 PM -07"(带逗号,3 为 12 小时制配 PM)
- 对可能不带时区的串,先试原串再试追加 " +08"(向后兼容)

新增 help_test.go 覆盖:数字日期、旧月份格式、新格式带/不带时区、
U+202F/U+00A0 空格、非法输入。

Fixes #2917



Generated-By: pi 0.84.1

Co-authored-by: Advanced <noreply@pi.dev>
2026-08-14 11:49:51 +08:00
UVJkiNTQ 89077c35a7 feat(setting): add move transfer threads option (#2907) 2026-08-08 19:05:49 +08:00
renovate[bot] cc87e88f03 fix(deps): update module gorm.io/driver/mysql to v1.6.0 (#819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-07 23:20:41 +08:00
Ovear 7e9082f90e fix(stream): allow seeking beyond file size per Go convention (#2667)
Remove `offset > size` guard from RangeReadReadAtSeeker.Seek and
DynamicReadAtSeeker.Seek. Go's io.Seeker convention allows seeking
past EOF.

Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-07 22:53:49 +08:00
renovate[bot] 6d4ad58678 fix(deps): update module golang.org/x/net to v0.57.0 (#2748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-07 22:50:11 +08:00
renovate[bot] d8ad28f5b7 fix(deps): update module github.com/ipfs/go-cid to v0.6.2 (#2738)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 22:44:19 +08:00
renovate[bot] 2e27852952 fix(deps): update azure-sdk-for-go monorepo (#2700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 22:43:31 +08:00
renovate[bot] 194f941f15 fix(deps): update module golang.org/x/sys to v0.47.0 (#2751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 22:42:26 +08:00
renovate[bot] ec1f968de1 fix(deps): update module github.com/shirou/gopsutil/v4 to v4.26.7 (#2896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 22:41:10 +08:00
renovate[bot] c97173286c fix(deps): update module github.com/nwaples/rardecode/v2 to v2.3.0 (#2898)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-08-07 22:40:45 +08:00
ThetaPilla. cca773f07b feat(fs): add pipelined multipart upload (#2723)
* feat(multipart): add chunk reassembly window

- Reassemble concurrently uploaded chunks into a sequential stream through a ring file, bounding disk usage to slots*chunkSize per session
- Park writers up to a deadline when their slot is busy instead of rejecting instantly, so flow control does not surface as connection errors in browsers
- Record a per-chunk CRC32 table for re-fill verification and keep it readable after close
- Propagate cancellation to blocked readers via CloseWithError so drivers treat aborts like canceled requests
- Cover ordering, backpressure, idempotent resends and close/abort wake-ups with race-enabled tests

* feat(multipart): add pipelined upload session manager

- Start the driver upload at session init over a sequential stream backed by the window, so client-to-server and server-to-storage transfers run concurrently
- Attach client-provided hashes to the stream so drivers can attempt rapid upload before any chunk arrives, and absorb chunks racing pipeline completion idempotently
- Keep only metadata and chunk CRCs after a failed attempt: re-sending chunk 0 re-fills a fresh window, and content changes between attempts are rejected
- Resume receiving sessions only when client hashes prove the same file; failed_retriable sessions resume unconditionally
- Reclaim sessions with a sliding-TTL GC and sweep orphaned ring files at startup
- Cover the state machine with race-enabled tests over a stubbed storage layer

* feat(setting): add multipart upload settings

- Add multipart_enabled and multipart_chunk_size (MB) as public traffic settings
- Validate the chunk size on save (integer within 1-90) via the setting item hook

* feat(server): add multipart upload API

- Add /api/fs/multipart init/chunk/complete/status/abort endpoints with headers aligned with /fs/put
- Gate init behind the FsUp permission checks and reuse the client upload rate limiter for chunk uploads
- Drain the request body before answering chunk requests on every path, so browsers do not see early responses as network errors
- Start the multipart session GC when the router is initialized to reclaim ring files orphaned by a previous run

* refactor(multipart): remove unused overwrite session field

- The overwrite flag was stored on the session but never read: the handler
  performs the pre-check and op.Put owns the overwrite semantics

* feat(setting): allow any positive multipart chunk size

- Validate the setting as a positive integer only; self-hosted admins decide
  the ceiling themselves instead of an arbitrary 90MB cap
- Keep clamping the client-suggested X-Chunk-Size to the admin value: the
  server buffers a window of 8 chunks per session, so an unbounded client
  suggestion would translate directly into server-side disk usage

* refactor(server): simplify multipart chunk size clamp

- Fold the two-step clamp into one branch: the ceiling is already floored,
  so a client suggestion just lowers the size with a 1MB floor
2026-08-06 14:14:35 +08:00
renovate[bot] a4ae2acb65 fix(deps): update module github.com/sheltonzhu/115driver to v1.3.5 (#2895)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 12:03:42 +08:00
MadDogOwner f68f3e9f4a fix(drivers/guangyapan): remove default value (#2894)
* fix(drivers/guangyapan): remove default value for ClientID

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/guangyapan): remove unused configuration options from driver settings

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
2026-08-04 11:53:45 +08:00
Pikachu Ren 6f80df2827 feat(drivers): add GuangYaPan driver with offline download support (#2882)
* feat(drivers): add GuangYaPan driver with offline download support

* fix(drivers): GuangYaPan driver multipartUploadToOSS
2026-08-04 11:34:56 +08:00
没空的人 940b69fe38 fix(drivers/lanzou): handle acw_sc__v2 challenge in Login function (#2876)
The Login endpoint may return an acw_sc__v2 validation page when accessed,
which previously caused login failures. This change adds the same retry
logic and cookie handling already used in request() to automatically solve
the challenge, ensuring login success even when the anti-bot mechanism is
triggered.
2026-07-31 18:13:47 +08:00
Yinan Qin f2f7ee4107 fix(ci): change missed pull_request to pull_request_target (#2873) 2026-07-30 00:45:01 +08:00
ILoveScratch cf931d4760 chore: fix typos (#2859) 2026-07-28 13:18:34 +08:00
ILoveScratch be708292eb chore: fix up .gitignore (#2857) 2026-07-28 07:37:07 +08:00
Liangbin Lian 78f2f0b25d chore(deps): remove unimplemented FUSE mount functionality and dependencies (#2854)
FUSE was introduced years ago but never implemented,
let's remove it to reduce dependencies.

Signed-off-by: Liangbin Lian <jjm2473@gmail.com>
2026-07-28 01:50:22 +08:00
Yinan Qin 6b92cbd21f fix(ci): allow pr check to run by default (#2856) 2026-07-27 23:52:20 +08:00
ShenLin 84ecda35aa fix(search): apply access filtering before paginating results
* fix: replace strings.HasPrefix with utils.IsSubPath for path validation

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix: re-validate shared paths to ensure they remain within the creator's base path

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(search): apply access filtering before paginating results

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-07-23 20:15:21 +08:00
renovate[bot] 2deb16b26e fix(deps): update github.com/t3rm1n4l/go-mega digest to c6acd6a (#2829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 19:44:46 +08:00
ShenLin 8495470e63 fix(search): serialize index updates by parent (#2827)
- Prevent concurrent updates from inserting duplicate index entries
- Preserve parallel indexing across different parent paths
- Add serialization, concurrency, and lock cleanup tests

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-23 19:33:25 +08:00
ShenLin fe56ca483d fix(drivers/s3): support multipart object copy (#2826)
fix(s3): support multipart object copy

- Route objects larger than 5 GB through multipart copy
- Preserve source metadata and abort incomplete multipart copies
- Cover size routing, byte ranges, metadata, and failure cleanup

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-23 19:33:16 +08:00
ShenLin e83835dd80 fix(buffer): wait for active I/O before closing (#2825)
* fix(buffer): wait for active I/O before closing

- Track in-flight PipeBuffer read and write operations
- Wait for active I/O before releasing backing storage
- Add concurrent close regression coverage for reads and writes

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>

* ensure waitgroup handling

---------

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
Co-authored-by: j2rong4cn <j2rong@qq.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-23 19:33:14 +08:00
Firmant Xu 791a27e2ac feat(drivers): add Bunny Storage driver (#2472)
* feat(driver/bunny_storage): add Bunny Storage driver

Add Bunny Storage API support with optional CDN links and token signing.

Cover CDN mount path handling, token signing, and proxy/cache behavior with unit tests.

* replace RootFolderPath with driver.RootPath in Addition struct

* fix(driver/bunny_storage): parse fractional Bunny timestamps

* fix(bunny_storage): correct CDN token signing

- Decode URL paths without treating literal plus signs as spaces.
- Reject duplicate query parameters and cover both cases with regression tests.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

---------

Co-authored-by: j2rong4cn <j2rong@qq.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-07-23 19:29:53 +08:00
ShenLin 28cea1fb2b fix(proxy): preserve proxied download filenames (#2824)
- Override transparent proxy Content-Disposition with the OpenList object name
- Add regression coverage for incorrect upstream download headers

Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-07-23 16:10:45 +08:00
So 0a9e71ae67 feat(drivers/139): merge multiple share roots (#2783)
* feat(drivers/139): merge multiple share roots

* fix(drivers/139): fallback to share preview without account

* chore(drivers/139): address share review comments

* refactor(drivers/139): reuse crypto

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* docs(drivers/139): add share support

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: syscc <syscc@users.noreply.github.com>
Co-authored-by: MadDogOwner <xiaoran@xrgzs.top>
2026-07-23 10:49:54 +08:00
So a21bc8bd0f fix(115_open): clean parent path before lookup (#2820) 2026-07-22 22:21:00 +08:00
MadDogOwner bacd087096 refactor(drivers/139): update path handling, put for family/group (#2719)
* fix(drivers/139): update path handling for family

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* perf(drivers/139): add retry go for family upload

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* feat(drivers/139): add FamilyCloudHost and GroupCloudHost handling

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* perf(drivers/139): add retry go for personalnew upload

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): use new batchpartinfos for remaining parts

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(driver/139): do not use path in id

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(driver/139): refactor RootPath handling

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(driver/139): remove root path stripping in Init method

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* chore(drivers/139): reduce upload retry attempts to 3

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): add rate limiting for MetaPersonalNew upload

- Wrap stream with LimitedUploadStream before creating StreamSectionReader
- Aligns with the same pattern used in the MetaPersonal/MetaGroup/MetaFamily path

Co-authored-by: GitHub Copilot <copilot@github.com>

* fix(drivers/139): fix section reader leak and seek check in retry block

- Check rd.Seek() return value and free the section reader on error
- Call ss.FreeSectionReader(rd) on all error paths within retry.Do closure
- Prevents buffer.Block leak when retrying failed upload chunks

Co-authored-by: GitHub Copilot <copilot@github.com>
Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): move GetSectionReader outside retry loop

- Move ss.GetSectionReader() before retry.Do() so the sequential
  stream section reader is only called once per chunk
- Remove redundant ss.FreeSectionReader() calls from inside the
  retry closure since the reader is now owned by the outer scope
- Fixes 'stream not cached' errors when retrying failed chunk
  uploads during cross-storage WebDAV COPY operations

Co-authored-by: GitHub Copilot <copilot@github.com>

* feat(drivers/139): add UseOldStreamUpload option

- Add UseOldStreamUpload option
- Implement newRequest, newPost
- Support rapid upload for PersonalNew and Group/Family

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): add more param for group/family put

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): correct group params for new put

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): update personalPost to use getPersonalCloudHost

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* chore(drivers/139): correct typo

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): pass full partInfos slice for batched upload

- Pass the global partInfos slice instead of the batch subset to
  uploadPersonalParts, so that PartNumber-1 indexing does not go
  out of bounds when a file has more than 100 parts.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(drivers/139): avoid double-counting progress on upload retries

- Save p.Done before each part and reset it inside the retry function
  so that bytes from failed attempts are not counted toward progress.
- Each part is counted exactly once, on the successful attempt.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(drivers/139): gate route-host checks by cloud type

- Only validate GroupCloudHost and FamilyCloudHost for MetaGroup
  and MetaFamily storage types, so that personal-only accounts do
  not fail initialization when the route policy omits group/family.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(drivers/139): add ProviderRoot back for groupgetfiles

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): set path to 0 when group old upload

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/139): improve error handling for family root path retrieval

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* refactor(drivers/139): update condition checks for CloudHost initialization

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-07-22 15:14:38 +08:00
Suyunjing e1ded83d5f fix(drivers/115_open): recover transient path metadata (#2816)
Recover temporarily missing or incomplete 115 Open path metadata from parent
and ancestor directory listings. Use 115-sdk-go v0.2.6 to normalize missing
object errors, while preserving valid zero-byte files and ensuring recursive
parent resolution progresses correctly.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-22 12:59:41 +08:00
renovate[bot] ff3cb8b931 chore(deps): update actions/setup-go action to v7 (#2798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 19:50:52 +08:00
Seven 7aea393a6d fix(drivers/115_open): populate file size when resolving paths (#2797)
* fix(115_open): populate file size when resolving paths

* feat(drivers/115_open): update 115-sdk-go
2026-07-16 19:46:15 +08:00
renovate[bot] 36168c86a0 fix(deps): update module github.com/rclone/rclone to v1.74.4 (#2744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 15:53:43 +08:00
Shelton Zhu 24332e371f fix(115,115share): update github.com/SheltonZhu/115driver to v1.3.4 (#2794)
fix(115,115share): support empty User-Agent for UA-free CDN download links

Fixes #SheltonZhu/115driver/issues/80
2026-07-16 09:49:29 +08:00
renovate[bot] 6b702afc0f fix(deps): update module gorm.io/gorm to v1.31.2 (#2758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:50:53 +08:00
renovate[bot] 78db0f7aff chore(deps): update docker/setup-qemu-action action to v4 (#2765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:35:50 +08:00
renovate[bot] e469693114 fix(deps): update module github.com/bodgit/sevenzip to v1.6.5 (#2789)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:35:28 +08:00
renovate[bot] 5961289b83 fix(deps): update module github.com/nwaples/rardecode/v2 to v2.2.5 (#2699)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:29:25 +08:00
renovate[bot] 0527dc41af fix(deps): update module github.com/deckarep/golang-set/v2 to v2.9.0 (#2712)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:24:18 +08:00
renovate[bot] b1d298d192 fix(deps): update module github.com/go-resty/resty/v2 to v2.17.2 (#2718)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:20:11 +08:00
renovate[bot] 651b5cea1c fix(deps): update module github.com/protonmail/gopenpgp/v2 to v2.10.0 (#2742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:11:32 +08:00
renovate[bot] 5aeaf7a8c8 fix(deps): update module github.com/shirou/gopsutil/v4 to v4.26.6 (#2745)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 00:08:16 +08:00
ShenLin 651da18da4 fix(handles): add src_name validation in FsBatchRename
- Validate source names with checkRelativePath before batch renames
- Reject invalid source paths with a forbidden response

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: MadDogOwner <xiaoran@xrgzs.top>
2026-07-15 16:11:57 +08:00
MadDogOwner 5a5d8d6e0e refactor(drivers/github_releases): remove internal caching and add pagination support (#2775)
* refactor(github_releases): remove internal caching, use global cache instead

- Remove Release/Releases/OtherFile cache fields from MountPoint struct
- Convert instance methods to pure functions (releaseToFiles, releasesToVersionDirs, etc.)
- Make List() fetch fresh data from GitHub API on every call
- Add githubGet helper to centralize GitHub API requests
- Remove unused GetRequest method and IsAncestorDir function

Co-authored-by: GitHub Copilot <copilot@github.com>

* feat(github_releases): add pagination and max page limit for all versions

- Add PerPage config to control releases per page (default 30, max 100)
- Add MaxPage config to limit max pages fetched (0 = unlimited)
- Rewrite getAllReleases to support automatic pagination

Co-authored-by: GitHub Copilot <copilot@github.com>

* chore(github_releases): move utils from driver.go to util.go

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* chore(github_releases): revert changes to GetRequest

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* chore(github_releases): use more common format for github proxy url

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(github_releases): show_readme in empty releases case

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(github_releases): preserve README and LICENSE name variants

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: GitHub Copilot <copilot@github.com>
2026-07-15 14:19:56 +08:00
renovate[bot] 061c1e2e1e chore(deps): update docker/setup-buildx-action action to v4 (#2764)
[no ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:30:49 +08:00
renovate[bot] caed356200 chore(deps): update docker/metadata-action action to v6 (#2763)
[no ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:30:24 +08:00
renovate[bot] 74af297471 chore(deps): update docker/login-action action to v4 (#2762)
[no ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:29:43 +08:00
renovate[bot] 7c54b0e3e4 chore(deps): update docker/build-push-action action to v7 (#2761)
[no ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:29:10 +08:00
renovate[bot] a632356902 chore(deps): update benjlevesque/short-sha action to v4 (#2760)
[no ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:25:01 +08:00
renovate[bot] 82ea8d0d01 chore(deps): update actions/cache action to v6 (#2759)
[no ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:18:52 +08:00
MadDogOwner eb48671202 fix(drivers/s3): custom user agent and method fixes (#2780)
* feat(drivers/s3): support custom ua

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drivers/s3): add context to copy and remove file methods

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix(drives/s3): avoid double-prepending root path

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: Copilot <copilot@github.com>
2026-07-11 11:46:09 +08:00
renovate[bot] f3e74fbdf1 fix(deps): update module github.com/quic-go/quic-go to v0.60.0 (#2743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 12:24:49 +08:00
renovate[bot] e670322ee3 fix(deps): update module github.com/maruel/natural to v1.3.0 (#2739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 12:24:49 +08:00
renovate[bot] 8930e1945e fix(deps): update module google.golang.org/grpc to v1.82.0 (#2754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 12:24:49 +08:00
renovate[bot] daaba0d7ea fix(deps): update module gorm.io/driver/postgres to v1.6.0 (#2756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 12:24:48 +08:00
renovate[bot] a81f54488f fix(deps): update module gorm.io/driver/sqlite to v1.6.0 (#2757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 12:24:48 +08:00
ShenLin 59bd343140 fix(sharing): enforce base path boundaries
* fix: replace strings.HasPrefix with utils.IsSubPath for path validation

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* fix: re-validate shared paths to ensure they remain within the creator's base path

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: MadDogOwner <xiaoran@xrgzs.top>
2026-07-10 12:24:40 +08:00
renovate[bot] 757f9d383d fix(deps): update module github.com/dlclark/regexp2 to v1.12.0 (#2713)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-08 18:35:18 +08:00
renovate[bot] 7ded6dfe2c chore(config): migrate Renovate config (#2735) 2026-07-08 18:08:04 +08:00
New Future a283f2b52a feat(azure_blob): preserve content type and md5 (#2734) 2026-07-08 11:56:10 +08:00
MadDogOwner bb3bc31609 chore(workflows): upgrade actions (#2728)
upgrade actions cross all workflow files:

- actions/checkout@v6 -> actions/checkout@v7
- actions/cache@v4 -> actions/cache@v6
- actions/download-artifact@v4 -> actions/download-artifact@v8

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
2026-07-06 21:41:25 +08:00
Unity_exe 744021d80d chore(wps/driver): remove CompanyID check (#2727)
Fixes #2543
2026-07-06 12:23:12 +08:00
renovate[bot] 94eac2e91e fix(deps): update github.com/t3rm1n4l/go-mega digest to 4de2f12 (#2710) 2026-07-02 07:56:08 +08:00
Yinan Qin a92d59d35c fix(build.sh): remove rm -rf .git/ (#2703)
Signed-off-by: elysia <a.elysia@proton.me>
2026-07-01 17:16:57 +08:00
Yinan Qin fcae009bd3 fix(workflow): using github-script (#2701)
* fix(workflow): using github-script

* fix more

Signed-off-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>

---------

Signed-off-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>
2026-07-01 16:45:15 +08:00
Pikachu Ren f63a5e15ee fix(deps): update module github.com/stangelandcl/ppmd v0.1.1 (#2698) 2026-07-01 16:00:18 +08:00
renovate[bot] d85980d3d9 fix(deps): update github.com/cloudsoda/go-smb2 digest to d8c5600 (#2298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:41:25 +08:00
renovate[bot] c8306256ff chore(deps): update go toolchain directive to v1.26.4 (#2697)
* chore(deps): update go toolchain directive to v1.26.4

* chore(build.sh): update go version for loongarch abi1.0

Signed-off-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>

---------

Signed-off-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Yinan Qin <elysia-best@simplelinux.cn.eu.org>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-01 15:37:27 +08:00
renovate[bot] 5273a77854 fix(deps): update module github.com/gin-contrib/cors to v1.7.7 (#2688)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:36:47 +08:00
renovate[bot] cc3d8a06a7 fix(deps): update module github.com/bodgit/sevenzip to v1.6.4 (#2682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-01 15:32:28 +08:00
renovate[bot] 925a2422d6 chore(deps): update github.com/openlistteam/reflink digest to 78760ea (#2693)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-07-01 15:29:04 +08:00
renovate[bot] 33015eee9f fix(deps): update module github.com/mholt/archives to v0.1.5 (#2690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:28:42 +08:00
renovate[bot] 1be74776f6 fix(deps): update module github.com/ncw/swift/v2 to v2.0.5 (#2691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:26:43 +08:00
renovate[bot] 31810f556c fix(deps): update module github.com/sirupsen/logrus to v1.9.4 (#2695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:25:27 +08:00
renovate[bot] 30922b6d52 fix(deps): update module github.com/pkg/sftp to v1.13.10 (#2694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:24:55 +08:00
renovate[bot] 759adfca21 fix(deps): update module github.com/charmbracelet/bubbles to v0.21.1 (#2686)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 13:15:58 +08:00
Pikachu Ren bfbc441fa6 fix(drivers/189pc): sync .torrent file when copy/move/rename/upload (#2522)
* fix(torrent): sync .torrent file when copy/move/rename/upload

* fix(torrent): prevent recursive torrent generation

* fix(189pc): address review comments on torrent sidecar handling

- Only append SHA-1 piece hash when n > 0, preventing spurious piece
  for empty files (BitTorrent v1 requires 0 pieces for 0-byte files)
- Explicitly guard torrent generation with size > 0
- Log findFileByName errors in torrentFollowCopy/Move/Rename instead of
  silently discarding them
- Log WaitBatchTask failure for follow-move operation
- Rename Cloud189File.ParentId to ParentID to match Go initialism
  conventions (consistent with Cloud189Folder.ParentID, Cloud189File.ID)

* revert(ci): go .sum 115 sdk

---------

Co-authored-by: Suyunmeng <Susus0175@proton.me>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-01 11:35:45 +08:00
Yinan Qin 866793f485 fix(go.mod): update Go version to 1.25 (#2692)
Signed-off-by: elysia <a.elysia@proton.me>
2026-07-01 11:21:06 +08:00
renovate[bot] 40b39000ab fix(deps): update github.com/t3rm1n4l/go-mega digest to 6845944 (#2088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 10:01:24 +08:00
220 changed files with 16568 additions and 2327 deletions
+12 -4
View File
@@ -82,9 +82,17 @@ body:
label: 复现链接(可选)
description: |
请提供能复现此问题的链接。
- type: textarea
- type: checkboxes
id: aigenerated
attributes:
label: AI生成内容(可选)
description: |
如果此问题是由AI辅助您发现的,请提供全部聊天记录,包括使用的模型信息。
label: AI生成内容
description: 必须且只能勾选一项,请勿删除或修改声明文字。
options:
- label: 我使用了AI工具生成此内容
- label: 我没有使用AI工具生成此内容
- type: input
id: ai-model
attributes:
label: AI模型是
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
placeholder: xxx
+12 -4
View File
@@ -82,9 +82,17 @@ body:
label: Reproduction Link (optional)
description: |
Please provide a link to a repo or page that can reproduce this issue.
- type: textarea
- type: checkboxes
id: aigenerated
attributes:
label: AI Generated Content (optional)
description: |
If this issue was identified with the assistance of AI, please provide the complete chat log, including information about the model used.
label: AI Generated Content
description: Select exactly one option. Do not delete or modify the disclosure text.
options:
- label: I used AI tools to generate this content
- label: I did not use AI tools to generate this content
- type: input
id: ai-model
attributes:
label: AI model used
description: If you used AI tools, enter the model name; otherwise leave this blank.
placeholder: xxx
@@ -48,9 +48,17 @@ body:
label: 附加信息
description: |
相关的任何其他上下文或截图,或者你觉得有帮助的信息
- type: textarea
- type: checkboxes
id: aigenerated
attributes:
label: AI生成内容(可选)
description: |
如果此请求是由AI辅助您提交的,请提供全部聊天记录,包括使用的模型信息。
label: AI生成内容
description: 必须且只能勾选一项,请勿删除或修改声明文字。
options:
- label: 我使用了AI工具生成此内容
- label: 我没有使用AI工具生成此内容
- type: input
id: ai-model
attributes:
label: AI模型是
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
placeholder: xxx
@@ -48,9 +48,17 @@ body:
label: Additional Information
description: |
Any other context or screenshots related to this feature request, or information you find helpful.
- type: textarea
- type: checkboxes
id: aigenerated
attributes:
label: AI Generated Content (optional)
description: |
If this request was submitted with the assistance of an AI, please provide the complete chat log, including information about the model used.
label: AI Generated Content
description: Select exactly one option. Do not delete or modify the disclosure text.
options:
- label: I used AI tools to generate this content
- label: I did not use AI tools to generate this content
- type: input
id: ai-model
attributes:
label: AI model used
description: If you used AI tools, enter the model name; otherwise leave this blank.
placeholder: xxx
+9 -9
View File
@@ -18,13 +18,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Create or update ref
id: create-or-update-ref
uses: ovsds/create-or-update-ref-action@v1
uses: ovsds/create-or-update-ref-action@1157b2991820188d5a8e6f22656775d1d171f762 # v1
with:
ref: tags/beta
sha: ${{ github.sha }}
@@ -51,7 +51,7 @@ jobs:
gh release upload beta "CHANGELOG.md" --clobber
- name: Upload assets to github artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: beta changelog
path: ${{ github.workspace }}/CHANGELOG.md
@@ -108,14 +108,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: "1.25.0"
go-version: "1.27.1"
- name: Setup web
run: bash build.sh dev web
@@ -124,7 +124,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Build
uses: OpenListTeam/cgo-actions@v1.2.5
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
with:
targets: ${{ matrix.target }}
flags: ${{ matrix.flags || '-ldflags=' }}
@@ -136,7 +136,7 @@ jobs:
musl-base-url: "https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
x-flags: |
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@openlist.team>
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
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
@@ -185,7 +185,7 @@ jobs:
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
- name: Upload assets to github artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: beta builds for ${{ env.cleaned_target }}
path: ${{ github.workspace }}/build/compress/*
+7 -7
View File
@@ -25,15 +25,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: benjlevesque/short-sha@v3.0
- uses: benjlevesque/short-sha@dbe07338b37c456ce06d23409b35a56a7815eef4 # v4.0
id: short-sha
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: "1.25.0"
go-version: "1.27.1"
- name: Setup web
run: bash build.sh dev web
@@ -42,7 +42,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Build
uses: OpenListTeam/cgo-actions@v1.2.5
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
with:
targets: ${{ matrix.target }}
flags: ${{ contains(matrix.target, '-musl') && '-ldflags=-linkmode external -extldflags ''-static -fpic''' || '-ldflags=' }}
@@ -52,7 +52,7 @@ jobs:
out-dir: build
x-flags: |
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@openlist.team>
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
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
@@ -69,7 +69,7 @@ jobs:
fi
- name: Upload artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openlist_${{ steps.short-sha.outputs.sha }}_${{ matrix.target }}
path: build/*
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
+75
View File
@@ -0,0 +1,75 @@
name: Issue Auto Reply
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
auto-reply:
runs-on: ubuntu-latest
steps:
- name: Check issue for unchecked tasks and reply
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.payload.issue.title.startsWith('[Announcements]')) return;
const titleNotEdited = /(请修改标题|Please modify the title)/i.test(context.payload.issue.title);
const issueBody = context.payload.issue.body || "";
const aiSection = issueBody.match(/^### (AI生成内容|AI Generated Content)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
const aiOptionsPattern = aiSection?.[1] === 'AI生成内容'
? /^\s*- \[([ xX])\] 我使用了AI工具生成此内容\r?\n- \[([ xX])\] 我没有使用AI工具生成此内容\s*$/
: /^\s*- \[([ xX])\] I used AI tools to generate this content\r?\n- \[([ xX])\] I did not use AI tools to generate this content\s*$/;
const aiOptions = (aiSection?.[2] || '').match(aiOptionsPattern);
const validAiDisclosure = aiOptions !== null && (aiOptions[1] !== ' ') !== (aiOptions[2] !== ' ');
const aiModelSection = issueBody.match(/^### (AI模型是|AI model used)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
const aiModel = (aiModelSection?.[2] || '').trim();
const missingAiModel = validAiDisclosure && aiOptions[1] !== ' ' && (aiModel === '' || aiModel === '_No response_');
const confirmNotRead = /- \[[xX]\] (?:我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
const closeIssue = titleNotEdited || confirmNotRead || !validAiDisclosure || missingAiModel;
let comment;
if (titleNotEdited) {
comment = `⚠️ 请修改标题以更好地描述您的问题或需求,并删除示例提示。当前 Issue 将被自动关闭并锁定。如需继续提交,请创建新的 Issue。
⚠️ Please modify the title to better describe your issue or request, and remove the example prompt. This issue will be automatically closed and locked. If you wish to proceed, please create a new issue.
`;
} else if (confirmNotRead || !validAiDisclosure || missingAiModel) {
comment = `⚠️ 你的 Issue 不符合提交规则。请先阅读相关规范后再重新提交。当前 Issue 将被自动关闭并锁定。如需继续提交,请确认已了解规则后创建新的 Issue。
⚠️ Your issue does not comply with the submission rules. Please read the guidelines before submitting again. This issue will be automatically closed and locked. If you wish to proceed, please confirm that you have reviewed the rules before creating a new issue.
`;
} else if (/- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody.replace(aiSection[0], ''))) {
comment = `感谢您联系OpenList。我们会尽快回复您。
Thanks for contacting OpenList. We will reply to you as soon as possible.
由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。
如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!
Since your issue contains some unchecked tasks, it may be closed after manual review.
If you can confirm and provide information for the unchecked tasks, feel free to resubmit.
We will pay attention and handle it in a timely manner.
感谢您的理解与支持!
Thank you for your understanding and support!
`;
} else {
return;
}
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
if (closeIssue) {
await github.rest.issues.update({
...context.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
labels: ['invalid']
});
await github.rest.issues.lock({
...context.repo,
issue_number: context.issue.number
});
}
-101
View File
@@ -1,101 +0,0 @@
name: Issue or PR Auto Reply
on:
issues:
types: [opened]
pull_request:
types: [opened]
permissions:
issues: write
pull-requests: write
jobs:
auto-reply:
runs-on: ubuntu-latest
if: github.event_name == 'issues'
steps:
- name: Check issue for unchecked tasks and reply
uses: actions/github-script@v7
with:
script: |
let comment = "";
const issueTitle = context.payload.issue.title || "";
const titleNotEdited = /(请修改标题|Please modify the title)/i.test(issueTitle);
if (titleNotEdited) {
comment = "⚠️ 请修改标题以更好地描述您的问题或需求,并删除示例提示。当前 Issue 将被自动关闭。如需继续提交,请创建新的 Issue。\n";
comment += "⚠️ Please modify the title to better describe your issue or request, and remove the example prompt. This issue will be automatically closed. If you wish to proceed, please create a new issue.\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
await github.rest.issues.update({
...context.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
labels: ['invalid']
});
return;
}
const issueBody = context.payload.issue.body || "";
const confirmHasRead = /- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
const confirmNotRead = /- \[[xX]\] (?:我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
if (confirmNotRead) {
comment = "⚠️ 你的 Issue 不符合提交规则。请先阅读相关规范后再重新提交。当前 Issue 将被自动关闭。如需继续提交,请确认已了解规则后重新打开或创建新的 Issue。\n";
comment += "⚠️ Your issue does not comply with the submission rules. Please read the guidelines before submitting again. This issue will be automatically closed. If you wish to proceed, please confirm that you have reviewed the rules before reopening or creating a new issue.\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
await github.rest.issues.update({
...context.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
labels: ['invalid']
});
return;
}
if (confirmHasRead) {
comment = "感谢您联系OpenList。我们会尽快回复您。\n";
comment += "Thanks for contacting OpenList. We will reply to you as soon as possible.\n\n";
comment += "由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。\n";
comment += "如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!\n";
comment += "Since your issue contains some unchecked tasks, it may be closed after manual review.\n";
comment += "If you can confirm and provide information for the unchecked tasks, feel free to resubmit.\n";
comment += "We will pay attention and handle it in a timely manner.\n\n";
comment += "感谢您的理解与支持!\n";
comment += "Thank you for your understanding and support!\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
}
pr-title-check:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Check PR title for required prefix and comment
uses: actions/github-script@v7
with:
script: |
const title = context.payload.pull_request.title || "";
const ok = /^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title);
if (!ok) {
let comment = "⚠️ PR 标题需以 `feat(): `, `docs(): `, `fix(): `, `style(): `, `refactor(): `, `chore(): ` 其中之一开头,例如:`feat(component): 新增功能`。\n";
comment += "⚠️ The PR title must start with `feat(): `, `docs(): `, `fix(): `, `style(): `, or `refactor(): `, `chore(): `. For example: `feat(component): add new feature`.\n\n";
comment += "如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。\n";
comment += "If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.\n\n";
comment += "如果是破坏性变更,请在类型后添加 `!`,例如 `feat(component)!: 破坏性变更`。\n";
comment += "For breaking changes, add `!` after the type, e.g., `feat(component)!: breaking change`.\n\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
}
+34
View File
@@ -0,0 +1,34 @@
name: PR Title Check
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
pr-title-check:
runs-on: ubuntu-latest
steps:
- name: Check PR title for required prefix and comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const title = context.payload.pull_request.title;
if (/^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title)) return;
const comment = `⚠️ PR 标题需以 \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, \`refactor(): \`, \`chore(): \` 其中之一开头,例如:\`feat(component): 新增功能\`。
⚠️ The PR title must start with \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, or \`refactor(): \`, \`chore(): \`. For example: \`feat(component): add new feature\`.
如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。
If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.
如果是破坏性变更,请在类型后添加 \`!\`,例如 \`feat(component)!: 破坏性变更\`。
For breaking changes, add \`!\` after the type, e.g., \`feat(component)!: breaking change\`.
`;
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
+7 -6
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Prerelease
uses: irongut/EditRelease@v1.2.0
uses: irongut/EditRelease@ccf529ad26dddf9996e7dd0f24ca5da4ea507cc2 # v1.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
id: ${{ github.event.release.id }}
@@ -33,7 +33,7 @@ jobs:
- name: Free Disk Space (Ubuntu)
if: matrix.target-platform == ''
uses: jlumbroso/free-disk-space@main
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main
with:
tool-cache: false
android: true
@@ -44,14 +44,15 @@ jobs:
swap-storage: true
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.25.0'
go-version: '1.27.1'
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
fetch-depth: 1
fetch-tags: true
- name: Install dependencies
if: matrix.target-platform == ''
+26 -26
View File
@@ -43,15 +43,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.25.0'
go-version: '1.27'
- name: Cache Musl
id: cache-musl
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: build/musl-libs
key: docker-musl-libs-v2
@@ -69,7 +69,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ env.ARTIFACT_NAME }}
overwrite: true
@@ -83,15 +83,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.25.0'
go-version: '1.27.1'
- name: Cache Musl
id: cache-musl
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: build/musl-libs
key: docker-musl-libs-v2
@@ -109,7 +109,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ env.ARTIFACT_NAME_LITE }}
overwrite: true
@@ -146,21 +146,21 @@ jobs:
tag_favor: "suffix=-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: actions/download-artifact@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ env.ARTIFACT_NAME }}
path: 'build/'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Login to GitHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@v3
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -168,14 +168,14 @@ jobs:
- name: Login to DockerHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@v3
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
@@ -190,7 +190,7 @@ jobs:
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.ci
@@ -230,21 +230,21 @@ jobs:
tag_favor: "suffix=-lite-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: actions/download-artifact@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ env.ARTIFACT_NAME_LITE }}
path: 'build/'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Login to GitHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@v3
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -252,14 +252,14 @@ jobs:
- name: Login to DockerHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@v3
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
@@ -274,7 +274,7 @@ jobs:
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.ci
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: Sync GitHub to Gitee
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
+13 -13
View File
@@ -32,15 +32,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.25.0'
go-version: '1.27.1'
- name: Cache Musl
id: cache-musl
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: build/musl-libs
key: docker-musl-libs-v2
@@ -58,7 +58,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ env.ARTIFACT_NAME }}
overwrite: true
@@ -97,21 +97,21 @@ jobs:
tag_favor: "suffix=-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: actions/download-artifact@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ env.ARTIFACT_NAME }}
path: 'build/'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Login to GitHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@v3
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -119,14 +119,14 @@ jobs:
- name: Login to DockerHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@v3
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
${{ env.REGISTRY }}/${{ env.GHCR_ORG_NAME }}/${{ env.IMAGE_NAME }}
@@ -137,7 +137,7 @@ jobs:
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.ci
+26 -13
View File
@@ -16,20 +16,33 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger Makefile hash update
uses: peter-evans/repository-dispatch@v3
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
repository: ${{ vars.HOOK_REPO || 'OpenListTeam/OpenList-OpenWRT' }}
event-type: update-hashes
client-payload: |
{
"source_repository": "${{ github.repository }}",
"release_tag": "${{ inputs.tag || github.ref_name }}",
"release_name": "${{ inputs.tag || github.ref_name }}",
"release_url": "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}",
"triggered_by": "${{ github.actor }}",
"trigger_reason": "${{ github.event_name }}"
}
github-token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
script: |
const repository = process.env.REPOSITORY;
const [owner, repo] = repository.split('/');
await github.rest.repos.createDispatchEvent({
owner: owner,
repo: repo,
event_type: 'update-hashes',
client_payload: {
source_repository: process.env.SOURCE_REPOSITORY,
release_tag: process.env.RELEASE_TAG,
release_name: process.env.RELEASE_TAG,
release_url: process.env.RELEASE_URL,
triggered_by: process.env.ACTOR,
trigger_reason: process.env.EVENT_NAME
}
});
env:
REPOSITORY: ${{ vars.HOOK_REPO || 'OpenListTeam/OpenList-OpenWRT' }}
SOURCE_REPOSITORY: ${{ github.repository }}
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
ACTOR: ${{ github.actor }}
EVENT_NAME: ${{ github.event_name }}
- name: Log trigger information
run: |
+4 -2
View File
@@ -22,6 +22,7 @@ output/
# vendor/
/bin/*
*.json
!renovate.json
/build
/data/
/tmp/
@@ -29,6 +30,7 @@ output/
/lang/
/daemon/
/public/dist/*
/!public/dist/README.md
!/public/dist/README.md
.VSCodeCounter
.VSCodeCounter
*.syso
+1 -1
View File
@@ -67,7 +67,7 @@ Thank you for your support and understanding of the OpenList project.
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -67,7 +67,7 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3
- [x] [MediaFire](https://www.mediafire.com)
- [x] [分秒帧](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组)
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组、分享)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [百度网盘](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -66,7 +66,7 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい
- [x] Teambition([中国](https://www.teambition.com), [国際](https://us.teambition.com))
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com)(個人、家族、グループ)
- [x] [139yun](https://yun.139.com)(個人、家族、グループ、共有)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -67,7 +67,7 @@ Dank u voor uw ondersteuning en begrip
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep)
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep, Delen)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+7 -13
View File
@@ -1,7 +1,7 @@
set -e
appName="openlist"
builtAt="$(date +'%F %T %z')"
gitAuthor="The OpenList Projects Contributors <noreply@openlist.team>"
gitAuthor="The OpenList Projects Contributors <noreply@oplist.org>"
gitCommit=$(git log --pretty=format:"%h" -1)
# Set frontend repository, default to OpenListTeam/OpenList-Frontend
@@ -97,7 +97,7 @@ AssertStaticBinary() {
}
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_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/edge\"")
pre_release_assets=$(echo "$pre_release_json" | jq -r '.assets[].browser_download_url')
# There is no lite for rolling
@@ -178,7 +178,6 @@ BuildWin7() {
}
BuildDev() {
rm -rf .git/
mkdir -p "dist"
muslflags="$(GetMuslStaticLdflags)"
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
@@ -269,7 +268,6 @@ BuildDockerMultiplatform() {
}
BuildRelease() {
rm -rf .git/
mkdir -p "build"
BuildWinArm64 ./build/"$appName"-windows-arm64.exe
BuildWin7 ./build/"$appName"-windows7
@@ -289,7 +287,7 @@ BuildRelease() {
BuildLoongGLIBC() {
local target_abi="$2"
local output_file="$1"
local oldWorldGoVersion="1.25.0"
local oldWorldGoVersion="1.26.4"
local loong_tags
loong_tags=$(GetBuildTagsForTarget "linux-loong64")
@@ -309,13 +307,13 @@ BuildLoongGLIBC() {
# Download and setup patched Go compiler for old-world
if ! curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250821/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
-o go-loong64-abi1.0.tar.gz; then
echo "Error: Failed to download patched Go compiler for old-world ABI1.0"
if [ -n "$GITHUB_TOKEN" ]; then
echo "Error output from curl:"
curl -fsSL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://github.com/loong64/loong64-abi1.0-toolchains/releases/download/20250821/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
"https://github.com/loong64-abi1-0/golang/releases/download/go${oldWorldGoVersion}/go${oldWorldGoVersion}.linux-amd64.tar.gz" \
-o go-loong64-abi1.0.tar.gz || true
fi
return 1
@@ -442,7 +440,6 @@ BuildLoongGLIBC() {
}
BuildReleaseLinuxMusl() {
rm -rf .git/
mkdir -p "build"
muslflags="$(GetMuslStaticLdflags)"
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
@@ -471,7 +468,6 @@ BuildReleaseLinuxMusl() {
}
BuildReleaseLinuxMuslArm() {
rm -rf .git/
mkdir -p "build"
muslflags="$(GetMuslStaticLdflags)"
BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
@@ -502,7 +498,6 @@ BuildReleaseLinuxMuslArm() {
BuildReleaseAndroid() {
rm -rf .git/
mkdir -p "build"
wget https://dl.google.com/android/repository/android-ndk-r26b-linux.zip
unzip android-ndk-r26b-linux.zip
@@ -523,7 +518,6 @@ BuildReleaseAndroid() {
}
BuildReleaseFreeBSD() {
rm -rf .git/
mkdir -p "build/freebsd"
# Get latest FreeBSD 14.x release version from GitHub
@@ -537,8 +531,8 @@ BuildReleaseFreeBSD() {
sed 's/\.0$//')
if [ -z "$freebsd_version" ]; then
echo "Failed to get FreeBSD version, falling back to 14.3"
freebsd_version="14.3"
echo "Failed to get FreeBSD version, falling back to 14.4"
freebsd_version="14.4"
fi
echo "Using FreeBSD version: $freebsd_version"
+1 -1
View File
@@ -23,7 +23,7 @@ type options struct {
pwd string //de/encrypt password
salt string
filenameEncryption string //reference drivers\crypt\meta.go Addtion
filenameEncryption string //reference drivers\crypt\meta.go Addition
dirnameEncryption string
filenameEncode string
suffix string
+17 -7
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@@ -31,14 +32,23 @@ func start() {
return
}
}
args := os.Args
args[1] = "server"
args = append(args, "--force-bin-dir")
cmd := &exec.Cmd{
Path: args[0],
Args: args,
Env: os.Environ(),
exe, err := os.Executable()
if err != nil {
log.Fatal("failed to resolve executable path: ", err)
}
childArgs := append([]string{"server"}, os.Args[2:]...)
hasForceBinDir := false
for _, arg := range childArgs {
if arg == "--force-bin-dir" || strings.HasPrefix(arg, "--force-bin-dir=") {
hasForceBinDir = true
break
}
}
if !hasForceBinDir {
childArgs = append(childArgs, "--force-bin-dir")
}
cmd := exec.Command(exe, childArgs...)
cmd.Env = os.Environ()
stdout, err := os.OpenFile(filepath.Join(filepath.Dir(pidFile), "start.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
if err != nil {
log.Fatal(os.Getpid(), ": failed to open start log file:", err)
-4
View File
@@ -5,7 +5,6 @@ import (
"strings"
"sync"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
@@ -69,9 +68,6 @@ func (d *Pan115) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
return nil, err
}
userAgent := args.Header.Get("User-Agent")
if userAgent == "" {
userAgent = base.UserAgent
}
downloadInfo, err := d.client.DownloadWithUA(file.(*FileObj).PickCode, userAgent)
if err != nil {
return nil, err
+4 -2
View File
@@ -337,9 +337,11 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload
for chunk := range chunksCh {
var part oss.UploadPart // 出现错误就继续尝试,共尝试3次
for retry := 0; retry < 3; retry++ {
select {
case <-ctx.Done():
if utils.IsCanceled(ctx) {
err = ctx.Err()
break
}
select {
case <-ticker.C:
if ossToken, err = d.client.GetOSSToken(); err != nil { // 到时重新获取ossToken
errCh <- errors.Wrap(err, "刷新token时出现错误")
+45 -3
View File
@@ -172,17 +172,59 @@ func (d *Open115) Get(ctx context.Context, path string) (model.Obj, error) {
resp, err := d.client.GetFolderInfoByPath(ctx, path)
if err != nil {
if errors.Is(err, sdk.ErrObjectNotFound) {
return nil, errs.ObjectNotFound
return d.getFromParent(ctx, path, "")
}
return nil, err
}
return &Obj{
obj := &Obj{
Fid: resp.FileID,
Fn: resp.FileName,
Fc: resp.FileCategory,
Sha1: resp.Sha1,
Pc: resp.PickCode,
}, nil
FS: resp.SizeByte,
Upt: parseTime(resp.UTime),
UpPt: parseTime(resp.PTime),
}
if !obj.IsDir() && obj.ModTime().Unix() <= 0 {
return d.getFromParent(ctx, path, obj.GetID())
}
return obj, nil
}
func (d *Open115) getFromParent(ctx context.Context, path, id string) (model.Obj, error) {
path = stdpath.Clean(path)
parent, name := stdpath.Split(path)
parent = stdpath.Clean(parent)
parentID := d.GetRootId()
if stdpath.Clean(parent) != stdpath.Clean(d.parentPath) {
if err := d.WaitLimit(ctx); err != nil {
return nil, err
}
parentInfo, err := d.client.GetFolderInfoByPath(ctx, parent)
if err != nil {
if !errors.Is(err, sdk.ErrObjectNotFound) {
return nil, err
}
parentObj, err := d.getFromParent(ctx, parent, "")
if err != nil {
return nil, err
}
parentID = parentObj.GetID()
} else {
parentID = parentInfo.FileID
}
}
files, err := d.List(ctx, &Obj{Fid: parentID, Fc: "0"}, model.ListArgs{})
if err != nil {
return nil, err
}
for _, file := range files {
if (id != "" && file.GetID() == id) || (id == "" && file.GetName() == name) {
return file, nil
}
}
return nil, errs.ObjectNotFound
}
func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
+61 -16
View File
@@ -3,6 +3,7 @@ package _115_open
import (
"context"
"encoding/base64"
"errors"
"io"
"time"
@@ -70,6 +71,19 @@ func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp
// } `json:"data"`
// }
// retryExpiredToken retries only the rejected OSS operation, preserving the upload ID.
func retryExpiredToken(refresh func() error, operation func() error) error {
err := operation()
var serviceErr oss.ServiceError
if !errors.As(err, &serviceErr) || serviceErr.Code != "SecurityTokenExpired" {
return err
}
if err := refresh(); err != nil {
return err
}
return operation()
}
func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer, up driver.UpdateProgress, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error {
ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
if err != nil {
@@ -80,7 +94,32 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
return err
}
imur, err := bucket.InitiateMultipartUpload(initResp.Object, oss.Sequential())
refresh := func() error {
if err := d.WaitLimit(ctx); err != nil {
return err
}
token, err := d.client.UploadGetToken(ctx)
if err != nil {
return err
}
client, err := netutil.NewOSSClient(token.Endpoint, token.AccessKeyId, token.AccessKeySecret, oss.SecurityToken(token.SecurityToken))
if err != nil {
return err
}
newBucket, err := client.Bucket(initResp.Bucket)
if err != nil {
return err
}
bucket = newBucket
return nil
}
var imur oss.InitiateMultipartUploadResult
err = retryExpiredToken(refresh, func() error {
var err error
imur, err = bucket.InitiateMultipartUpload(initResp.Object, oss.Sequential(), oss.WithContext(ctx))
return err
})
if err != nil {
return err
}
@@ -109,13 +148,17 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
return err
}
err = retry.Do(func() error {
rd.Seek(0, io.SeekStart)
part, err := bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), partSize, int(i))
if err != nil {
return err
}
parts[i-1] = part
return nil
return retryExpiredToken(refresh, func() error {
if _, err := rd.Seek(0, io.SeekStart); err != nil {
return err
}
part, err := bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), partSize, int(i), oss.WithContext(ctx))
if err != nil {
return err
}
parts[i-1] = part
return nil
})
},
retry.Context(ctx),
retry.Attempts(3),
@@ -134,14 +177,16 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
up(float64(offset) * 100 / float64(fileSize))
}
// callbackRespBytes := make([]byte, 1024)
_, err = bucket.CompleteMultipartUpload(
imur,
parts,
oss.Callback(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.Callback))),
oss.CallbackVar(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.CallbackVar))),
// oss.CallbackResult(&callbackRespBytes),
)
err = retryExpiredToken(refresh, func() error {
_, err := bucket.CompleteMultipartUpload(
imur,
parts,
oss.Callback(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.Callback))),
oss.CallbackVar(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.CallbackVar))),
oss.WithContext(ctx),
)
return err
})
if err != nil {
return err
}
+9 -1
View File
@@ -1,6 +1,9 @@
package _115_open
import "encoding/json"
import (
"encoding/json"
"strconv"
)
func ParseInt64(v json.Number) (int64, error) {
i, err := v.Int64()
@@ -13,3 +16,8 @@ func ParseInt64(v json.Number) (int64, error) {
}
return int64(0), err
}
func parseTime(value string) int64 {
timestamp, _ := strconv.ParseInt(value, 10, 64)
return timestamp
}
-3
View File
@@ -90,9 +90,6 @@ func (d *Pan115Share) Link(ctx context.Context, file model.Obj, args model.LinkA
if args.Header != nil {
ua = args.Header.Get("User-Agent")
}
if ua == "" {
ua = base.UserAgent
}
downloadInfo, err := d.client.DownloadByShareCodeWithUA(ua, d.ShareCode, d.ReceiveCode, file.GetID())
if err != nil {
return nil, err
+250 -107
View File
@@ -19,6 +19,7 @@ import (
"github.com/OpenListTeam/OpenList/v4/pkg/cron"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
"github.com/avast/retry-go"
log "github.com/sirupsen/logrus"
)
@@ -29,7 +30,9 @@ type Yun139 struct {
Account string
ref *Yun139
PersonalCloudHost string
RootPath string
FamilyCloudHost string
GroupCloudHost string
ProviderRoot string
}
func (d *Yun139) Config() driver.Config {
@@ -42,53 +45,62 @@ func (d *Yun139) GetAddition() driver.Additional {
func (d *Yun139) Init(ctx context.Context) error {
if d.ref == nil {
if len(d.Authorization) == 0 {
if d.Username != "" && d.Password != "" {
log.Infof("139yun: authorization is empty, trying to login with password.")
newAuth, err := d.loginWithPassword()
log.Debugf("newAuth: Ok: %s", newAuth)
if err != nil {
return fmt.Errorf("login with password failed: %w", err)
}
} else {
return fmt.Errorf("authorization is empty and username/password is not provided")
// Share links may be browsed without account credentials. All other
// storage types need an authorization or a supported login fallback.
if !d.isShare() {
if err := d.validateAndInitCredentials(); err != nil {
return err
}
}
err := d.refreshToken()
if err != nil {
return err
}
// Query Route Policy
var resp QueryRoutePolicyResp
_, err = d.requestRoute(base.Json{
"userInfo": base.Json{
"userType": 1,
"accountType": 1,
"accountName": d.Account,
},
"modAddrType": 1,
}, &resp)
if err != nil {
return err
}
for _, policyItem := range resp.Data.RoutePolicyList {
if policyItem.ModName == "personal" {
d.PersonalCloudHost = policyItem.HttpsUrl
break
}
}
if len(d.PersonalCloudHost) == 0 {
return fmt.Errorf("PersonalCloudHost is empty")
}
d.cron = cron.NewCron(time.Hour * 12)
d.cron.Do(func() {
if d.Authorization != "" {
err := d.refreshToken()
if err != nil {
log.Errorf("%+v", err)
return err
}
})
// Query Route Policy
var resp QueryRoutePolicyResp
_, err = d.requestRoute(base.Json{
"userInfo": base.Json{
"userType": 1,
"accountType": 1,
"accountName": d.Account,
},
"modAddrType": 1,
}, &resp)
if err != nil {
return err
}
for _, policyItem := range resp.Data.RoutePolicyList {
switch policyItem.ModName {
case "personal":
d.PersonalCloudHost = policyItem.HttpsUrl
case "group":
d.GroupCloudHost = policyItem.HttpsUrl
case "family":
d.FamilyCloudHost = policyItem.HttpsUrl
}
}
if len(d.PersonalCloudHost) == 0 {
return fmt.Errorf("PersonalCloudHost is empty")
}
if d.isGroup() || d.isFamily() {
if len(d.GroupCloudHost) == 0 {
return fmt.Errorf("GroupCloudHost is empty")
}
if len(d.FamilyCloudHost) == 0 {
return fmt.Errorf("FamilyCloudHost is empty")
}
}
d.cron = cron.NewCron(time.Hour * 12)
d.cron.Do(func() {
err := d.refreshToken()
if err != nil {
log.Errorf("%+v", err)
}
})
}
}
switch d.Addition.Type {
case MetaPersonalNew:
@@ -107,15 +119,25 @@ func (d *Yun139) Init(ctx context.Context) error {
if err != nil {
return err
}
case MetaFamily:
case MetaShare:
if len(d.Addition.RootFolderID) == 0 {
// Attempt to obtain data.path as the root via a query and persist it.
if root, err := d.getFamilyRootPath(d.CloudID); err == nil && root != "" {
d.RootFolderID = root
op.MustSaveDriverStorage(d)
}
d.RootFolderID = "root"
}
_, err := d.familyGetFiles(d.RootFolderID)
if len(d.shareEntries()) == 0 {
return fmt.Errorf("link_id is empty")
}
case MetaFamily:
// Attempt to obtain data.path as the root via a query and persist it.
root, err := d.getFamilyRootPath(d.CloudID)
if err != nil || root == "" {
return fmt.Errorf("failed to get family root path: %w", err)
}
d.ProviderRoot = root
if len(d.Addition.RootFolderID) == 0 {
d.RootFolderID = root
op.MustSaveDriverStorage(d)
}
_, err = d.familyGetFiles(d.RootFolderID)
if err != nil {
return err
}
@@ -134,6 +156,19 @@ func (d *Yun139) InitReference(storage driver.Driver) error {
return errs.NotSupport
}
func (d *Yun139) Get(ctx context.Context, path string) (model.Obj, error) {
if !d.isShare() {
return nil, errs.NotImplement
}
if path == "/" {
return &model.Object{ID: "root", Name: "root", IsFolder: true, Path: "/"}, nil
}
if obj, err := d.shareGetObj(path); err == nil {
return obj, nil
}
return nil, errs.ObjectNotFound
}
func (d *Yun139) Drop(ctx context.Context) error {
if d.cron != nil {
d.cron.Stop()
@@ -152,12 +187,23 @@ func (d *Yun139) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
return d.familyGetFiles(dir.GetID())
case MetaGroup:
return d.groupGetFiles(dir.GetID())
case MetaShare:
if dir.GetID() == "root" {
return d.shareGetMergedFiles(d.shareRootEntries())
}
if refs, ok := decodeShareRefs(dir.GetID()); ok {
return d.shareGetMergedFiles(refs)
}
return d.shareGetFilesWithRef(shareRef{LinkID: d.LinkID, NodeID: dir.GetID()}, dir.GetID())
default:
return nil, errs.NotImplement
}
}
func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if file.IsDir() {
return nil, errs.NotFile
}
var url string
var err error
switch d.Addition.Type {
@@ -169,6 +215,16 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
url, err = d.familyGetLink(file.GetID(), file.GetPath())
case MetaGroup:
url, err = d.groupGetLink(file.GetID(), file.GetPath())
case MetaShare:
if refs, ok := decodeShareRefs(file.GetID()); ok && len(refs) > 0 {
return d.shareGetLinkWithRef(refs[0], refs[0].NodeID, args.Type)
}
fallbackRef := shareRef{LinkID: d.LinkID, NodeID: "root"}
if entries := d.shareEntries(); len(entries) > 0 {
fallbackRef.LinkID = entries[0].LinkID
fallbackRef.Password = entries[0].Password
}
return d.shareGetLinkWithRef(fallbackRef, file.GetID(), args.Type)
default:
return nil, errs.NotImplement
}
@@ -179,6 +235,9 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
}
func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
if d.isShare() {
return errs.NotImplement
}
var err error
switch d.Addition.Type {
case MetaPersonalNew:
@@ -212,7 +271,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
"accountType": 1,
},
"docLibName": dirName,
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
"path": d.dirPath(parentDir),
}
pathname := "/orchestration/familyCloud-rebuild/cloudCatalog/v1.0/createCloudDoc"
_, err = d.post(pathname, data, nil)
@@ -225,7 +284,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
},
"groupID": d.CloudID,
"parentFileId": parentDir.GetID(),
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
"path": d.dirPath(parentDir),
}
pathname := "/orchestration/group-rebuild/catalog/v1.0/createGroupCatalog"
_, err = d.post(pathname, data, nil)
@@ -236,6 +295,9 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
}
func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
if d.isShare() {
return nil, errs.NotImplement
}
switch d.Addition.Type {
case MetaPersonalNew:
data := base.Json{
@@ -310,9 +372,9 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
var contentList []string
var catalogList []string
if srcObj.IsDir() {
catalogList = append(catalogList, path.Join(srcObj.GetPath(), srcObj.GetID()))
catalogList = append(catalogList, d.dirPath(srcObj))
} else {
contentList = append(contentList, path.Join(srcObj.GetPath(), srcObj.GetID()))
contentList = append(contentList, d.dirPath(srcObj))
}
body := base.Json{
@@ -324,7 +386,7 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
"contentList": contentList,
"destCatalogID": dstDir.GetID(),
"destGroupID": d.CloudID,
"destPath": path.Join(dstDir.GetPath(), dstDir.GetID()),
"destPath": d.dirPath(dstDir),
"destType": 0,
"srcGroupID": d.CloudID,
"srcType": 0,
@@ -347,6 +409,9 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
}
func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
if d.isShare() {
return errs.NotImplement
}
var err error
switch d.Addition.Type {
case MetaPersonalNew:
@@ -425,7 +490,7 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
},
"docLibName": newName,
"docLibraryID": srcObj.GetID(),
"path": path.Join(srcObj.GetPath(), srcObj.GetID()),
"path": d.dirPath(srcObj),
}
var resp ModifyCloudDocV2Resp
_, err = d.andAlbumRequest(pathname, data, &resp)
@@ -456,6 +521,9 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
}
func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
if d.isShare() {
return errs.NotImplement
}
var err error
switch d.Addition.Type {
case MetaPersonalNew:
@@ -525,6 +593,9 @@ func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
}
func (d *Yun139) Remove(ctx context.Context, obj model.Obj) error {
if d.isShare() {
return errs.NotImplement
}
switch d.Addition.Type {
case MetaPersonalNew:
data := base.Json{
@@ -615,8 +686,25 @@ func (d *Yun139) getPartSize(size int64) int64 {
}
func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
switch d.Addition.Type {
case MetaPersonalNew:
if d.isShare() {
return errs.NotImplement
}
// PersonalNew 以及 Group/Family 在非旧流模式时走新上传路径
if d.Addition.Type == MetaPersonalNew ||
((d.isGroup() || d.isFamily()) && !d.UseOldStreamUpload) {
var createPath, getUploadUrlPath, completePath string
if d.isGroup() || d.isFamily() {
// 家庭云和共享群共用同一套新上传 API
createPath = "/dynamic/file/create"
getUploadUrlPath = "/dynamic/file/getUploadUrl"
completePath = "/dynamic/file/complete"
} else {
// MetaPersonalNew
createPath = "/file/create"
getUploadUrlPath = "/file/getUploadUrl"
completePath = "/file/complete"
}
var err error
fullHash := stream.GetHash().GetHash(utils.SHA256)
if len(fullHash) != utils.SHA256.Width {
@@ -671,9 +759,22 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
"type": "file",
"fileRenameMode": "auto_rename",
}
pathname := "/file/create"
// 家庭云和共享群需要额外的参数
if d.isGroup() || d.isFamily() {
if d.CloudID == "" {
return fmt.Errorf("cloud_id is required for group/family upload")
}
data["groupId"] = d.CloudID
if d.isGroup() {
data["groupType"] = 2
} else if d.isFamily() {
data["groupType"] = 1
}
data["catalogType"] = 3
data["seqNo"] = random.String(32)
}
var resp PersonalUploadResp
_, err = d.personalPost(pathname, data, &resp)
_, err = d.newPost(createPath, data, &resp)
if err != nil {
return err
}
@@ -690,10 +791,19 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
if resp.Data.PartInfos != nil {
// Progress
p := driver.NewProgress(size, up)
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
Ctx: ctx,
Reader: rateLimited,
Obj: &model.Object{Size: size},
}, int(partSize), &up)
if err != nil {
return err
}
// 先上传前100个分片
err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, rateLimited, p)
err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, ss, p)
if err != nil {
return err
}
@@ -711,13 +821,12 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
"accountType": 1,
},
}
pathname := "/file/getUploadUrl"
var moreresp PersonalUploadUrlResp
_, err = d.personalPost(pathname, moredata, &moreresp)
_, err = d.newPost(getUploadUrlPath, moredata, &moreresp)
if err != nil {
return err
}
err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, rateLimited, p)
err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, ss, p)
if err != nil {
return err
}
@@ -730,7 +839,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
"fileId": resp.Data.FileId,
"uploadId": resp.Data.UploadId,
}
_, err = d.personalPost("/file/complete", data, nil)
// 家庭云和共享群需要额外的参数
if d.isGroup() || d.isFamily() {
data["groupId"] = d.CloudID
}
_, err = d.newPost(completePath, data, nil)
if err != nil {
return err
}
@@ -775,11 +888,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
}
}
return nil
case MetaPersonal:
fallthrough
case MetaGroup:
fallthrough
case MetaFamily:
}
// 旧上传路径
switch d.Addition.Type {
case MetaPersonal, MetaGroup, MetaFamily:
// 处理冲突
// 获取文件列表
files, err := d.List(ctx, dstDir, model.ListArgs{})
@@ -826,11 +939,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
},
}
pathname := "/orchestration/personalCloud/uploadAndDownload/v1.0/pcUploadFileRequest"
if d.isFamily() || d.Addition.Type == MetaGroup {
uploadPath := path.Join(dstDir.GetPath(), dstDir.GetID())
// if dstDir is root folder
if dstDir.GetID() == d.RootFolderID {
uploadPath = d.RootPath
if d.isFamily() || d.isGroup() {
uploadPath := d.dirPath(dstDir)
// 共享群的根目录上传路径为 0
if d.isGroup() && dstDir.GetID() == d.RootFolderID {
uploadPath = "0"
}
data = d.newJson(base.Json{
"fileCount": 1,
@@ -858,56 +971,86 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
}
size := stream.GetSize()
partSize := d.getPartSize(size)
// Progress
p := driver.NewProgress(size, up)
partSize := d.getPartSize(size)
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
// StreamSectionReader for per-chunk buffering and retry
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
Ctx: ctx,
Reader: rateLimited,
Obj: &model.Object{Size: size},
}, int(partSize), &up)
if err != nil {
return err
}
part := int64(1)
if size > partSize {
part = (size + partSize - 1) / partSize
}
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
for i := int64(0); i < part; i++ {
if utils.IsCanceled(ctx) {
return ctx.Err()
}
start := i * partSize
byteSize := min(size-start, partSize)
limitReader := io.LimitReader(rateLimited, byteSize)
// Update Progress
r := io.TeeReader(limitReader, p)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resp.Data.UploadResult.RedirectionURL, r)
if err != nil {
return err
rd, getErr := ss.GetSectionReader(start, byteSize)
if getErr != nil {
return getErr
}
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
req.Header.Set("rangeType", "0")
req.ContentLength = byteSize
res, err := base.HttpClient.Do(req)
err = retry.Do(
func() error {
if _, err := rd.Seek(0, io.SeekStart); err != nil {
return err
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, resp.Data.UploadResult.RedirectionURL,
io.TeeReader(rd, p))
if reqErr != nil {
return reqErr
}
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
req.Header.Set("rangeType", "0")
req.ContentLength = byteSize
res, doErr := base.HttpClient.Do(req)
if doErr != nil {
return doErr
}
defer res.Body.Close()
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr != nil {
return fmt.Errorf("error reading response body: %v", readErr)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, string(bodyBytes))
}
var result InterLayerUploadResult
xmlErr := xml.Unmarshal(bodyBytes, &result)
if xmlErr != nil {
return fmt.Errorf("error parsing XML: %v", xmlErr)
}
if result.ResultCode != 0 {
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
}
return nil
},
retry.Context(ctx),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Second),
)
ss.FreeSectionReader(rd)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
res.Body.Close()
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("error reading response body: %v", err)
}
var result InterLayerUploadResult
err = xml.Unmarshal(bodyBytes, &result)
if err != nil {
return fmt.Errorf("error parsing XML: %v", err)
}
if result.ResultCode != 0 {
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
}
}
return nil
default:
+8 -5
View File
@@ -7,17 +7,20 @@ import (
type Addition struct {
//Account string `json:"account" required:"true"`
Authorization string `json:"authorization" type:"text" required:"true"`
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true" secret:"true"`
MailCookies string `json:"mail_cookies" required:"true" type:"text" help:"Cookies from mail.139.com used for login authentication."`
Authorization string `json:"authorization" type:"text" help:"Authorization can be used alone. If empty, use username + password; mail_cookies is optional and will be established/updated automatically. Existing mail_cookies can also be used alone for fast login."`
Username string `json:"username" help:"Use together with password when Authorization is empty. mail_cookies may be left empty on the first login."`
Password string `json:"password" secret:"true" help:"Use together with username when Authorization is empty. mail_cookies may be left empty on the first login."`
SmsCode string `json:"sms_code" secret:"true" help:"Fill this only after OpenList reports that a 139 Mail SMS verification code was sent, then save the storage again."`
MailCookies string `json:"mail_cookies" type:"text" help:"Optional cookies from mail.10086.cn. Leave empty for a first username/password login; cookies created or updated by password/SMS login are persisted and reused as device context. Existing cookies may also be used alone for fast login."`
driver.RootID
Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"`
Type string `json:"type" type:"select" options:"personal_new,family,group,personal,share" default:"personal_new"`
LinkID string `json:"link_id" type:"text" help:"Multiple shares are separated by commas or new lines. Use link_id#password for password-protected shares."`
CloudID string `json:"cloud_id"`
UserDomainID string `json:"user_domain_id" help:"ud_id in Cookie, fill in to show disk usage"`
CustomUploadPartSize int64 `json:"custom_upload_part_size" type:"number" default:"0" help:"0 for auto"`
ReportRealSize bool `json:"report_real_size" type:"bool" default:"true" help:"Enable to report the real file size during upload"`
UseLargeThumbnail bool `json:"use_large_thumbnail" type:"bool" default:"false" help:"Enable to use large thumbnail for images"`
UseOldStreamUpload bool `json:"use_old_stream_upload" type:"bool" default:"false" help:"Enable to use old stream upload method (not support rapid upload)"`
}
var config = driver.Config{
+35
View File
@@ -0,0 +1,35 @@
package _139
import (
"testing"
)
func TestShareEntriesAndRefEncoding(t *testing.T) {
d := &Yun139{Addition: Addition{
Type: MetaShare,
LinkID: "share-a,share-b,share-c#pass",
}}
entries := d.shareEntries()
if len(entries) != 3 {
t.Fatalf("expected 3 share entries, got %d", len(entries))
}
if entries[2].LinkID != "share-c" || entries[2].Password != "pass" {
t.Fatalf("unexpected password share entry: %+v", entries[2])
}
refs := []shareRef{
{LinkID: entries[0].LinkID, Password: entries[0].Password, NodeID: "root-a"},
{LinkID: entries[2].LinkID, Password: entries[2].Password, NodeID: "root-b"},
}
encoded := encodeShareRefs(refs)
decoded, ok := decodeShareRefs(encoded)
if !ok || len(decoded) != len(refs) {
t.Fatalf("failed to decode merged share refs: %q", encoded)
}
for i := range refs {
if decoded[i] != refs[i] {
t.Fatalf("unexpected decoded ref at %d: %+v", i, decoded[i])
}
}
}
+51
View File
@@ -9,6 +9,7 @@ const (
MetaFamily string = "family"
MetaGroup string = "group"
MetaPersonalNew string = "personal_new"
MetaShare string = "share"
)
type BaseResp struct {
@@ -285,6 +286,56 @@ type PersonalUploadUrlResp struct {
}
}
type ShareCatalog struct {
CaID string `json:"caId"`
CaName string `json:"caName"`
UdTime string `json:"udTime"`
}
type ShareContent struct {
CoID string `json:"coId"`
CoName string `json:"coName"`
CoSize int64 `json:"coSize"`
CoType int `json:"coType"`
UdTime string `json:"udTime"`
CoPath string `json:"coPath"`
PresentURL string `json:"presentURL"`
DownloadURL string `json:"downloadURL"`
}
type ShareListResp struct {
BaseResp
Data struct {
LKName string `json:"lkName"`
Passwd string `json:"password"`
CaLst []ShareCatalog `json:"caLst"`
CoLst []ShareContent `json:"coLst"`
} `json:"data"`
}
type ShareContentInfo struct {
PresentURL string `json:"presentURL"`
DownloadURL string `json:"cdnDownLoadUrl"`
}
type ShareDownloadResp struct {
BaseResp
Data struct {
DownloadURL string `json:"downloadURL"`
RedrURL string `json:"redrUrl"`
ExtInfo struct {
CDNDownloadURL string `json:"cdnDownloadUrl"`
} `json:"extInfo"`
} `json:"data"`
}
type ShareContentInfoResp struct {
BaseResp
Data struct {
ContentInfo ShareContentInfo `json:"contentInfo"`
} `json:"data"`
}
type QueryRoutePolicyResp struct {
Success bool `json:"success"`
Code string `json:"code"`
+826 -114
View File
File diff suppressed because it is too large Load Diff
+474
View File
@@ -0,0 +1,474 @@
package _139
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/go-resty/resty/v2"
)
func useRetryingTestClient(t *testing.T) {
t.Helper()
oldClient := base.RestyClient
base.RestyClient = resty.New().SetRetryCount(3)
t.Cleanup(func() {
base.RestyClient = oldClient
})
}
func TestMergeMailCookiesPreservesExistingOrderAndAppendsNewNames(t *testing.T) {
got := mergeMailCookieHeader("z=zv; behaviorid=b; Os_SSo_Sid=old", []*http.Cookie{
{Name: "RMKEY", Value: "rm"},
{Name: "Os_SSo_Sid", Value: "sid"},
{Name: "a", Value: "av"},
})
want := "z=zv;behaviorid=b;Os_SSo_Sid=sid;RMKEY=rm;a=av"
if got != want {
t.Fatalf("mergeMailCookieHeader() = %q, want %q", got, want)
}
}
func TestExtractFastLoginCookies(t *testing.T) {
sid, rmkey := extractFastLoginCookies("RMKEY=rm; Os_SSo_Sid=sid")
if sid != "sid" || rmkey != "rm" {
t.Fatalf("extractFastLoginCookies() = %q, %q; want sid, rm", sid, rmkey)
}
}
func TestCredentialState(t *testing.T) {
tests := []struct {
name string
d Yun139
want credentialState
err bool
}{
{
name: "authorization",
d: Yun139{Addition: Addition{Authorization: " auth "}},
want: credentialStateAuthorization,
},
{
name: "full login",
d: Yun139{Addition: Addition{
MailCookies: "RMKEY=rm; Os_SSo_Sid=sid",
Username: "user",
Password: "password",
}},
want: credentialStateFullLogin,
},
{
name: "full login without initial cookies",
d: Yun139{Addition: Addition{
Username: "user",
Password: "password",
}},
want: credentialStateFullLogin,
},
{
name: "cookies only",
d: Yun139{Addition: Addition{MailCookies: "RMKEY=rm; Os_SSo_Sid=sid"}},
want: credentialStateCookiesOnly,
},
{
name: "partial password login",
d: Yun139{Addition: Addition{Username: "user"}},
err: true,
},
{
name: "missing credentials",
d: Yun139{},
err: true,
},
{
name: "invalid cookie",
d: Yun139{Addition: Addition{MailCookies: "invalid-cookie"}},
err: true,
},
{
name: "authorization with basic prefix",
d: Yun139{Addition: Addition{Authorization: "Basic abc"}},
err: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.d.credentialState()
if tt.err {
if err == nil {
t.Fatal("credentialState() expected error")
}
return
}
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("credentialState() = %v, want %v", got, tt.want)
}
})
}
}
func TestIntegrationLoginObtainsAuthorization(t *testing.T) {
if os.Getenv("OPENLIST_139_INTEGRATION") != "1" {
t.Skip("set OPENLIST_139_INTEGRATION=1 to run live 139Yun login checks")
}
base.RestyClient = resty.New().
SetHeader("user-agent", base.UserAgent).
SetRetryCount(3).
SetRetryResetReaders(true).
SetTimeout(30 * time.Second)
username := os.Getenv("OPENLIST_139_USERNAME")
password := os.Getenv("OPENLIST_139_PASSWORD")
mailCookies := os.Getenv("OPENLIST_139_MAIL_COOKIES")
authorization := strings.TrimSpace(os.Getenv("OPENLIST_139_AUTHORIZATION"))
if authorization != "" {
t.Run("authorization", func(t *testing.T) {
d := Yun139{Addition: Addition{Authorization: authorization}}
state, err := d.credentialState()
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if state != credentialStateAuthorization {
t.Fatalf("credentialState() = %v, want authorization", state)
}
if d.Authorization == "" || strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
t.Fatal("authorization should be present without Basic prefix")
}
})
}
if mailCookies == "" {
t.Fatal("OPENLIST_139_MAIL_COOKIES is required")
}
runFastLogin := func(mailCookies string) error {
d := Yun139{Addition: Addition{MailCookies: mailCookies}}
state, err := d.credentialState()
if err != nil {
return fmt.Errorf("credentialState() error: %w", err)
}
if state != credentialStateCookiesOnly {
return fmt.Errorf("credentialState() = %v, want cookies only", state)
}
sid, rmkey := extractFastLoginCookies(d.MailCookies)
if sid == "" || rmkey == "" {
return fmt.Errorf("mail cookies are missing Os_SSo_Sid or RMKEY")
}
token, err := d.step2_get_single_token(sid)
if err != nil {
return fmt.Errorf("step2_get_single_token() error: %w", err)
}
auth, err := d.step3_third_party_login(token)
if err != nil {
return fmt.Errorf("step3_third_party_login() error: %w", err)
}
if auth == "" {
return fmt.Errorf("authorization is empty after fast login")
}
return nil
}
if username == "" || password == "" {
t.Fatal("OPENLIST_139_USERNAME and OPENLIST_139_PASSWORD are required for password fallback")
}
var refreshedMailCookies string
var generatedAuthorization string
t.Run("password login fallback", func(t *testing.T) {
d := Yun139{Addition: Addition{
MailCookies: mailCookies,
Username: username,
Password: password,
}}
state, err := d.credentialState()
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if state != credentialStateFullLogin {
t.Fatalf("credentialState() = %v, want full login", state)
}
passId, err := d.step1_password_login()
if err != nil {
t.Fatalf("step1_password_login() error: %v", err)
}
token, err := d.step2_get_single_token(passId)
if err != nil {
t.Fatalf("step2_get_single_token() error: %v", err)
}
auth, err := d.step3_third_party_login(token)
if err != nil {
t.Fatalf("step3_third_party_login() error: %v", err)
}
d.Authorization = auth
if auth == "" || d.Authorization == "" {
t.Fatal("authorization is empty after password login")
}
generatedAuthorization = auth
refreshedMailCookies = d.MailCookies
})
t.Run("authorization generated by password login", func(t *testing.T) {
if generatedAuthorization == "" {
t.Fatal("password login did not generate authorization")
}
d := Yun139{Addition: Addition{Authorization: generatedAuthorization}}
state, err := d.credentialState()
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if state != credentialStateAuthorization {
t.Fatalf("credentialState() = %v, want authorization", state)
}
if strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
t.Fatal("authorization should not include Basic prefix")
}
})
t.Run("mail cookies fast login from input", func(t *testing.T) {
sid, rmkey := extractFastLoginCookies(mailCookies)
if sid == "" || rmkey == "" {
t.Skip("input mail cookies are missing Os_SSo_Sid or RMKEY")
}
if err := runFastLogin(mailCookies); err != nil {
t.Skipf("input mail cookies are stale or unusable: %v", err)
}
})
t.Run("mail cookies fast login after password login", func(t *testing.T) {
if refreshedMailCookies == "" {
t.Fatal("password login did not refresh mail cookies")
}
if err := runFastLogin(refreshedMailCookies); err != nil {
t.Fatalf("refreshed mail cookies fast login failed: %v", err)
}
})
}
func TestInvalidAuthorizationDoesNotUseCookieFastLogin(t *testing.T) {
d := Yun139{Addition: Addition{
Authorization: "not-base64",
MailCookies: "Os_SSo_Sid=sid; RMKEY=rmkey",
}}
err := d.refreshToken()
if err == nil || !strings.Contains(err.Error(), "password login failed") {
t.Fatalf("refreshToken() error = %v, want password login fallback error", err)
}
if d.Authorization != "not-base64" {
t.Fatalf("Authorization = %q, want original invalid value retained", d.Authorization)
}
}
func TestSMSSceneForRisk(t *testing.T) {
tests := map[string]int{
"S025": 1,
"S035": 1,
"PML401010062": 2,
"MW0016": 4,
}
for riskCode, want := range tests {
got, ok := smsSceneForRisk(riskCode)
if !ok || got != want {
t.Fatalf("smsSceneForRisk(%q) = %d, %t; want %d, true", riskCode, got, ok, want)
}
}
if _, ok := smsSceneForRisk("PICTURE_ONLY"); ok {
t.Fatal("smsSceneForRisk() accepted unsupported risk code")
}
}
func TestSendSMSVerificationCode(t *testing.T) {
useRetryingTestClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
if r.URL.Query().Get("func") != "login:sendSmsCodeByScene" {
t.Errorf("func = %q", r.URL.Query().Get("func"))
}
if !strings.Contains(string(body), `<string name="scene">1</string>`) {
t.Errorf("request body = %s", body)
}
if !strings.Contains(r.Header.Get("Cookie"), "device=fingerprint") {
t.Errorf("Cookie = %q", r.Header.Get("Cookie"))
}
http.SetCookie(w, &http.Cookie{Name: "challenge", Value: "sms"})
_, _ = io.WriteString(w, `{"code":"S_OK"}`)
}))
defer server.Close()
oldURL := mailSMSURL
mailSMSURL = server.URL
defer func() { mailSMSURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000", MailCookies: "device=fingerprint"}}
if err := d.sendSMSVerificationCode("S025"); err != nil {
t.Fatalf("sendSMSVerificationCode() error = %v", err)
}
if !strings.Contains(d.MailCookies, "challenge=sms") {
t.Fatalf("MailCookies = %q", d.MailCookies)
}
}
func TestSendSMSVerificationCodeStopsAtPictureChallenge(t *testing.T) {
useRetryingTestClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"code":"PML401010021"}`)
}))
defer server.Close()
oldURL := mailSMSURL
mailSMSURL = server.URL
defer func() { mailSMSURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000"}}
err := d.sendSMSVerificationCode("S025")
if err == nil || !strings.Contains(err.Error(), "requires picture verification") {
t.Fatalf("sendSMSVerificationCode() error = %v", err)
}
}
func TestVerifySMSCode(t *testing.T) {
useRetryingTestClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
if r.URL.Query().Get("func") != "/login/inlogin.action" {
t.Errorf("func = %q", r.URL.Query().Get("func"))
}
wantHash := sha1Hash("fetion.com.cn:123456")
if !strings.Contains(string(body), `<string name="loginPassword">`+wantHash+`</string>`) {
t.Errorf("request body does not contain SMS code hash: %s", body)
}
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rmkey"})
_, _ = io.WriteString(w, `{"code":"S_OK","var":{"loginSuccessUrl":"https://mail.10086.cn/?sid=sms-sid"}}`)
}))
defer server.Close()
oldURL := mailSMSURL
mailSMSURL = server.URL
defer func() { mailSMSURL = oldURL }()
d := Yun139{Addition: Addition{
Username: "18800000000",
SmsCode: "123456",
MailCookies: "challenge=sms",
}}
sid, err := d.verifySMSCode("S025")
if err != nil {
t.Fatalf("verifySMSCode() error = %v", err)
}
if sid != "sms-sid" {
t.Fatalf("sid = %q", sid)
}
if d.SmsCode != "" {
t.Fatalf("SmsCode = %q, want cleared", d.SmsCode)
}
if !strings.Contains(d.MailCookies, "RMKEY=new-rmkey") {
t.Fatalf("MailCookies = %q", d.MailCookies)
}
}
func TestPasswordLoginWithoutInitialCookiesStopsAtRedirectAndPersistsCookies(t *testing.T) {
useRetryingTestClient(t)
var loginRequests, redirectRequests int
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Login/Login.ashx":
loginRequests++
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rm"})
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "new-sid"})
w.Header().Set("Location", server.URL+"/appmail?sid=single-sid")
w.WriteHeader(http.StatusFound)
case "/appmail":
redirectRequests++
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldURL := mailPasswordURL
mailPasswordURL = server.URL + "/Login/Login.ashx"
defer func() { mailPasswordURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password"}}
sid, err := d.step1_password_login()
if err != nil || sid != "single-sid" {
t.Fatalf("sid=%q err=%v", sid, err)
}
if loginRequests != 1 || redirectRequests != 0 {
t.Fatalf("login/redirect requests=%d/%d, want 1/0", loginRequests, redirectRequests)
}
if !strings.Contains(d.MailCookies, "RMKEY=new-rm") || !strings.Contains(d.MailCookies, "Os_SSo_Sid=new-sid") {
t.Fatalf("response cookies were not persisted: %q", d.MailCookies)
}
}
func TestPasswordLoginReusesRawRefreshedCookies(t *testing.T) {
useRetryingTestClient(t)
var n int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n++
cookie := r.Header.Get("Cookie")
if !strings.Contains(cookie, "JSESSIONID=stale") || !strings.Contains(cookie, "behaviorid=device") {
t.Errorf("login %d lost raw cookie context: %q", n, cookie)
}
if n == 1 {
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-1"})
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-1"})
w.Header().Set("Location", "https://mail.10086.cn/?sid=first")
} else {
if !strings.Contains(cookie, "RMKEY=rm-1") || !strings.Contains(cookie, "Os_SSo_Sid=sid-1") {
t.Errorf("second login did not reuse first refreshed cookies: %q", cookie)
}
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-2"})
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-2"})
w.Header().Set("Location", "https://mail.10086.cn/?sid=second")
}
w.WriteHeader(http.StatusFound)
}))
defer server.Close()
oldURL := mailPasswordURL
mailPasswordURL = server.URL
defer func() { mailPasswordURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password", MailCookies: "Os_SSo_Sid=old; RMKEY=old; JSESSIONID=stale; behaviorid=device"}}
if sid, err := d.step1_password_login(); err != nil || sid != "first" {
t.Fatalf("first login sid=%q err=%v", sid, err)
}
refreshed := d.MailCookies
if !strings.Contains(refreshed, "RMKEY=rm-1") || !strings.Contains(refreshed, "Os_SSo_Sid=sid-1") {
t.Fatalf("first login did not persist refreshed cookies: %q", refreshed)
}
d.Authorization = ""
d.MailCookies = refreshed
if sid, err := d.step1_password_login(); err != nil || sid != "second" {
t.Fatalf("second login sid=%q err=%v", sid, err)
}
if n != 2 {
t.Fatalf("login count=%d, want 2", n)
}
}
+21 -3
View File
@@ -5,12 +5,15 @@ import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
func clientSuffix() map[string]string {
@@ -57,7 +60,13 @@ func timestamp() int64 {
type Time time.Time
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
func (t *Time) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
return t.Unmarshal([]byte(s))
}
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
b, err := e.Token()
if err != nil {
@@ -72,10 +81,19 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
}
func (t *Time) Unmarshal(b []byte) error {
bs := strings.Trim(string(b), "\"")
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
bs = strings.ReplaceAll(bs, "\u202f", " ")
bs = strings.ReplaceAll(bs, "\u00a0", " ")
var v time.Time
var err error
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
for _, s := range []string{bs, bs + " +08"} {
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
v, err = time.ParseInLocation(f, s, utils.CNLoc)
if err == nil {
break
}
}
if err == nil {
break
}
+65
View File
@@ -0,0 +1,65 @@
package _189_tv
import (
"encoding/json"
"encoding/xml"
"testing"
"time"
)
func TestTimeUnmarshal(t *testing.T) {
tests := []struct {
name string
input string
want time.Time
}{
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var tm Time
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
}
if !tt.want.Equal(time.Time(tm)) {
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
}
})
}
}
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
var tm Time
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
t.Fatal("Unmarshal accepted an invalid time")
}
}
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
t.Run(input, func(t *testing.T) {
var tm Time
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
}
})
}
}
func TestTimeUnmarshalXML(t *testing.T) {
var tm Time
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33&#x202f;PM +08</time>`), &tm); err != nil {
t.Fatal(err)
}
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
if !want.Equal(time.Time(tm)) {
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
}
}
+13 -3
View File
@@ -184,6 +184,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
}
fullUrl += "/listFiles.action"
pageSize := 130 // 每一页返回的文件数量
res := make([]model.Obj, 0, 130)
for pageNum := 1; ; pageNum++ {
var resp Cloud189FilesResp
@@ -195,7 +196,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
"mediaAttr": "0",
"iconOption": "5",
"pageNum": fmt.Sprint(pageNum),
"pageSize": "130",
"pageSize": fmt.Sprint(pageSize),
})
if isFamily {
r.SetQueryParams(map[string]string{
@@ -218,13 +219,22 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
if resp.FileListAO.Count == 0 {
break
}
FolderCount := len(resp.FileListAO.FolderList) // 当前文件夹总数
FileCount := len(resp.FileListAO.FileList) // 当前文件总数
PageCount := FolderCount + FileCount // 当前页数总数
for i := 0; i < len(resp.FileListAO.FolderList); i++ {
for i := 0; i < FolderCount; i++ {
res = append(res, &resp.FileListAO.FolderList[i])
}
for i := 0; i < len(resp.FileListAO.FileList); i++ {
for i := 0; i < FileCount; i++ {
res = append(res, &resp.FileListAO.FileList[i])
}
// 文件数量小于设定数量时跳出
if PageCount < pageSize {
break
}
}
return res, nil
}
+30 -1
View File
@@ -262,6 +262,16 @@ func (y *Cloud189PC) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
return nil, err
}
// 跟随移动 torrent 文件
if !srcObj.IsDir() {
var srcFolderId string
if f, ok := srcObj.(*Cloud189File); ok {
srcFolderId = f.ParentID
}
y.torrentFollowMove(srcFolderId, srcObj.GetName(), dstDir)
}
return srcObj, nil
}
@@ -298,6 +308,12 @@ func (y *Cloud189PC) Rename(ctx context.Context, srcObj model.Obj, newName strin
}
return nil, err
}
// 跟随重命名 torrent 文件
if f, ok := srcObj.(*Cloud189File); ok {
y.torrentFollowRename(f.ParentID, srcObj.GetName(), newName)
}
switch f := srcObj.(type) {
case *Cloud189File:
return resp.toFile(f), nil
@@ -319,7 +335,20 @@ func (y *Cloud189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
if err != nil {
return err
}
return y.WaitBatchTask("COPY", resp.TaskID, time.Second)
if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil {
return err
}
// 跟随复制 torrent 文件
if !srcObj.IsDir() {
var srcFolderId string
if f, ok := srcObj.(*Cloud189File); ok {
srcFolderId = f.ParentID
}
y.torrentFollowCopy(srcFolderId, srcObj.GetName(), dstDir)
}
return nil
}
func (y *Cloud189PC) Remove(ctx context.Context, obj model.Obj) error {
+20 -3
View File
@@ -9,6 +9,7 @@ import (
"crypto/sha1"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"encoding/xml"
"fmt"
@@ -19,6 +20,7 @@ import (
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
)
@@ -101,7 +103,13 @@ func MustParseTime(str string) *time.Time {
type Time time.Time
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
func (t *Time) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
return t.Unmarshal([]byte(s))
}
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
b, err := e.Token()
if err != nil {
@@ -116,10 +124,19 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
}
func (t *Time) Unmarshal(b []byte) error {
bs := strings.Trim(string(b), "\"")
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
bs = strings.ReplaceAll(bs, "\u202f", " ")
bs = strings.ReplaceAll(bs, "\u00a0", " ")
var v time.Time
var err error
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
for _, s := range []string{bs, bs + " +08"} {
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
v, err = time.ParseInLocation(f, s, utils.CNLoc)
if err == nil {
break
}
}
if err == nil {
break
}
+65
View File
@@ -0,0 +1,65 @@
package _189pc
import (
"encoding/json"
"encoding/xml"
"testing"
"time"
)
func TestTimeUnmarshal(t *testing.T) {
tests := []struct {
name string
input string
want time.Time
}{
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var tm Time
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
}
if !tt.want.Equal(time.Time(tm)) {
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
}
})
}
}
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
var tm Time
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
t.Fatal("Unmarshal accepted an invalid time")
}
}
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
t.Run(input, func(t *testing.T) {
var tm Time
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
}
})
}
}
func TestTimeUnmarshalXML(t *testing.T) {
var tm Time
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33&#x202f;PM +08</time>`), &tm); err != nil {
t.Fatal(err)
}
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
if !want.Equal(time.Time(tm)) {
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
}
}
+7 -2
View File
@@ -7,11 +7,16 @@ import (
type Addition struct {
LoginType string `json:"login_type" type:"select" options:"password,qrcode" default:"password" required:"true"`
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true"`
Username string `json:"username" help:"Not needed when an access token or refresh token is provided"`
Password string `json:"password" help:"Not needed when an access token or refresh token is provided"`
VCode string `json:"validate_code"`
SmsCode string `json:"sms_code" help:"SMS code for the second device verification, fill it in and save again when login asks for it"`
AccessToken string `json:"access_token" required:"false"`
RefreshToken string `json:"refresh_token" help:"To switch accounts, please clear this field"`
DeviceID string `json:"device_id" help:"DEVICEID cookie issued after the second device verification, keep it to avoid verifying again"`
ClientSn string `json:"client_sn" help:"Device serial number captured from the official client, leave it empty if you do not have one"`
JgOpenId string `json:"jg_open_id" help:"Optional push id reported by the official client"`
UserFinger string `json:"user_finger" help:"Device fingerprint sent with login requests, generated and kept automatically when empty"`
driver.RootID
OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"`
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
+158
View File
@@ -1,6 +1,7 @@
package _189pc
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
@@ -8,10 +9,13 @@ import (
"io"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/torrent"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
@@ -162,6 +166,36 @@ func (y *Cloud189PC) RapidUploadFromTorrent(ctx context.Context, dstDir model.Ob
return nil, fmt.Errorf("提交上传失败: %w", err)
}
// 秒传成功后,将 torrent 文件上传到目标目录(异步,不影响秒传结果)
if y.Addition.GenerateTorrent {
capturedDstDir := dstDir
capturedIsFamily := isFamily
go func() {
torrentName := fileName + ".cas.torrent"
infoHash, _ := GetInfoHashHex(torrentData)
utils.Log.Infof("秒传成功,上传 torrent: %s (info_hash: %s, size: %d bytes)",
torrentName, infoHash, len(torrentData))
torrentFileStream := &stream.FileStream{
Ctx: context.Background(),
Obj: &model.Object{
Name: torrentName,
Size: int64(len(torrentData)),
IsFolder: false,
},
Reader: bytes.NewReader(torrentData),
Mimetype: "application/x-bittorrent",
}
_, uploadErr := y.fastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false, false)
if uploadErr != nil {
utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr)
} else {
utils.Log.Infof("torrent 文件已上传: %s", torrentName)
op.Cache.DeleteDirectory(y, capturedDstDir.GetPath())
}
}()
}
return resp.toFile(), nil
}
@@ -258,6 +292,10 @@ func GetInfoHashHex(torrentData []byte) (string, error) {
return hex.EncodeToString(t.InfoHash), nil
}
func isCASTorrentFile(fileName string) bool {
return strings.HasSuffix(fileName, ".cas.torrent")
}
// ComputeSliceMD5sFromReader 从 reader 中计算每个 10MB 分片的 MD5
// 返回:整文件 MD5、分片 MD5 列表
func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []string, error) {
@@ -289,3 +327,123 @@ func ComputeSliceMD5sFromReader(reader io.Reader, sliceSize int64) (string, []st
fileMD5Hex := strings.ToUpper(hex.EncodeToString(fileMD5Hash.Sum(nil)))
return fileMD5Hex, sliceMD5s, nil
}
// torrentFollowCopy 跟随复制 torrent 文件(异步,不影响主操作)
// srcFolderId: 源文件所在目录 ID
// srcFileName: 源文件名
// dstDir: 目标目录
func (y *Cloud189PC) torrentFollowCopy(srcFolderId string, srcFileName string, dstDir model.Obj) {
if !y.Addition.GenerateTorrent {
return
}
if srcFolderId == "" {
return
}
torrentName := srcFileName + ".cas.torrent"
isFamily := y.isFamily()
go func() {
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
if err != nil {
utils.Log.Debugf("查找 torrent 文件失败(跟随复制): %v", err)
return
}
// 复制 torrent 文件到目标目录
resp, copyErr := y.CreateBatchTask("COPY", IF(isFamily, y.FamilyID, ""), dstDir.GetID(),
map[string]string{"targetFileName": dstDir.GetName()},
BatchTaskInfo{
FileId: torrentFile.GetID(),
FileName: torrentFile.GetName(),
IsFolder: 0,
})
if copyErr != nil {
utils.Log.Warnf("跟随复制 torrent 文件失败: %v", copyErr)
return
}
if err = y.WaitBatchTask("COPY", resp.TaskID, time.Second); err != nil {
utils.Log.Warnf("等待跟随复制 torrent 文件失败: %v", err)
}
}()
}
// torrentFollowMove 跟随移动 torrent 文件(异步,不影响主操作)
// srcFolderId: 源文件所在目录 ID
// srcFileName: 源文件名
// dstDir: 目标目录
func (y *Cloud189PC) torrentFollowMove(srcFolderId string, srcFileName string, dstDir model.Obj) {
if !y.Addition.GenerateTorrent {
return
}
if srcFolderId == "" {
return
}
torrentName := srcFileName + ".cas.torrent"
isFamily := y.isFamily()
go func() {
torrentFile, err := y.findFileByName(context.Background(), torrentName, srcFolderId, isFamily)
if err != nil {
utils.Log.Debugf("查找 torrent 文件失败(跟随移动): %v", err)
return
}
// 移动 torrent 文件到目标目录
resp, moveErr := y.CreateBatchTask("MOVE", IF(isFamily, y.FamilyID, ""), dstDir.GetID(),
map[string]string{"targetFileName": dstDir.GetName()},
BatchTaskInfo{
FileId: torrentFile.GetID(),
FileName: torrentFile.GetName(),
IsFolder: 0,
})
if moveErr != nil {
utils.Log.Warnf("跟随移动 torrent 文件失败: %v", moveErr)
return
}
if err = y.WaitBatchTask("MOVE", resp.TaskID, time.Millisecond*400); err != nil {
utils.Log.Warnf("等待跟随移动 torrent 文件失败: %v", err)
}
}()
}
// torrentFollowRename 跟随重命名 torrent 文件(异步,不影响主操作)
// folderId: 文件所在目录 ID
// oldFileName: 原文件名
// newFileName: 新文件名
func (y *Cloud189PC) torrentFollowRename(folderId string, oldFileName string, newFileName string) {
if !y.Addition.GenerateTorrent {
return
}
if folderId == "" {
return
}
oldTorrentName := oldFileName + ".cas.torrent"
newTorrentName := newFileName + ".cas.torrent"
isFamily := y.isFamily()
go func() {
torrentFile, err := y.findFileByName(context.Background(), oldTorrentName, folderId, isFamily)
if err != nil {
utils.Log.Debugf("查找 torrent 文件失败(跟随重命名): %v", err)
return
}
// 重命名 torrent 文件
queryParam := make(map[string]string)
fullUrl := API_URL
method := "POST"
if isFamily {
fullUrl += "/family/file"
method = "GET"
queryParam["familyId"] = y.FamilyID
}
fullUrl += "/renameFile.action"
queryParam["fileId"] = torrentFile.GetID()
queryParam["destFileName"] = newTorrentName
_, renameErr := y.request(fullUrl, method, func(req *resty.Request) {
req.SetContext(context.Background()).SetQueryParams(queryParam)
}, nil, &RenameResp{}, isFamily)
if renameErr != nil {
utils.Log.Warnf("跟随重命名 torrent 文件失败: %v", renameErr)
}
}()
}
+75 -4
View File
@@ -72,6 +72,8 @@ type BaseLoginParam struct {
// 请求头参数
Lt string
ReqId string
// logbox页面地址,作为后续请求的Referer,缺失会被判定为陌生设备
Referer string
// 表单参数
ParamId string
@@ -97,10 +99,20 @@ type LoginParam struct {
// rsa密钥
jRsaKey string
// 加密字段的前缀,服务端下发(如 {NRP})
rsaPrefix string
// 设备二次校验时服务端返回的加密手机号
SecondAuthMobile string
BaseLoginParam
}
// encryptSecret 用登陆时拿到的公钥加密敏感值,格式与userName/epd一致
func (p *LoginParam) encryptSecret(value string) string {
return p.rsaPrefix + RsaEncrypt(p.jRsaKey, value)
}
// 登陆加密相关
type EncryptConfResp struct {
Result int `json:"result"`
@@ -116,6 +128,35 @@ type LoginResp struct {
Msg string `json:"msg"`
Result int `json:"result"`
ToUrl string `json:"toUrl"`
// 设备二次校验时返回的加密手机号
Mobile string `json:"mobile"`
}
// 登陆页配置,新版登陆页的paramId由该接口下发
// 该接口的result可能是数字也可能是字符串
type AppConfResp struct {
Result any `json:"result"`
Msg string `json:"msg"`
Data struct {
ParamId string `json:"paramId"`
AccountType string `json:"accountType"`
ReturnUrl string `json:"returnUrl"`
MailSuffix string `json:"mailSuffix"`
} `json:"data"`
}
func (r *AppConfResp) Succeeded() bool {
switch v := r.Result.(type) {
case nil:
return true
case string:
return v == "0" || v == ""
case float64:
return v == 0
case int:
return v == 0
}
return false
}
// 刷新session返回
@@ -149,6 +190,27 @@ type AppSessionResp struct {
RefreshToken string `json:"refreshToken"`
}
// 刷新token返回,失败时以HTTP 200返回result/msg,需要单独判断
type RefreshTokenResp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"`
Result int `json:"result"`
Msg string `json:"msg"`
}
func (r *RefreshTokenResp) HasError() bool {
return r.Result != 0 || r.AccessToken == ""
}
func (r *RefreshTokenResp) Error() string {
if r.Msg != "" {
return fmt.Sprintf("refresh token failed, result: %d, msg: %s", r.Result, r.Msg)
}
return fmt.Sprintf("refresh token failed, result: %d", r.Result)
}
// 家庭云账户
type FamilyInfoListResp struct {
FamilyInfoResp []FamilyInfoResp `json:"familyInfoResp"`
@@ -166,10 +228,11 @@ type FamilyInfoResp struct {
/*文件部分*/
// 文件
type Cloud189File struct {
ID String `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Md5 string `json:"md5"`
ID String `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Md5 string `json:"md5"`
ParentID string `json:"-"` // 由 getFiles 设置,不从 JSON 解析
LastOpTime Time `json:"lastOpTime"`
CreateDate Time `json:"createDate"`
@@ -190,6 +253,10 @@ type Cloud189File struct {
// StarLabel int64 `json:"starLabel"`
}
func normalizeCloud189Name(name string) string {
return strings.ReplaceAll(name, "\\'", "'")
}
func (c *Cloud189File) CreateTime() time.Time {
return time.Time(c.CreateDate)
}
@@ -206,6 +273,8 @@ func (c *Cloud189File) GetID() string { return string(c.ID) }
func (c *Cloud189File) GetPath() string { return "" }
func (c *Cloud189File) Thumb() string { return c.Icon.SmallUrl }
func (c *Cloud189File) GetDisplayName() string { return normalizeCloud189Name(c.Name) }
// 文件夹
type Cloud189Folder struct {
ID String `json:"id"`
@@ -237,6 +306,8 @@ func (c *Cloud189Folder) IsDir() bool { return true }
func (c *Cloud189Folder) GetID() string { return string(c.ID) }
func (c *Cloud189Folder) GetPath() string { return "" }
func (c *Cloud189Folder) GetDisplayName() string { return normalizeCloud189Name(c.Name) }
type Cloud189FilesResp struct {
//ResCode int `json:"res_code"`
//ResMessage string `json:"res_message"`
+431 -81
View File
@@ -30,6 +30,7 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/errgroup"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
"github.com/skip2/go-qrcode"
"github.com/avast/retry-go"
@@ -41,9 +42,13 @@ import (
const (
ACCOUNT_TYPE = "02"
APP_ID = "8025431004"
CLIENT_TYPE = "10020"
VERSION = "6.2"
// 官方 PC 端(cloud.189.cn 网页/客户端)使用的 appId,
// 登录、生成二维码、换取 session 必须全程使用同一个 appId
APP_ID = "9317140619"
CLIENT_TYPE = "10020"
// 扫码状态轮询使用的 clientType,与密码登录的 10020 不同
QR_CLIENT_TYPE = "1"
VERSION = "7.2.4.0"
WEB_URL = "https://cloud.189.cn"
AUTH_URL = "https://open.e.189.cn"
@@ -57,8 +62,18 @@ const (
CHANNEL_ID = "web_cloud.189.cn"
// 服务端通过短信二次校验后下发的设备标识,复用它可以避免再次触发校验
DEVICE_ID_COOKIE = "DEVICEID"
// 扫码登录本地轮询参数,超时后把二维码交回前端,避免请求被反向代理掐断
QRCODE_POLL_INTERVAL = 2 * time.Second
QRCODE_POLL_TIMEOUT = 20 * time.Second
// Error codes
UserInvalidOpenTokenError = "UserInvalidOpenToken"
// 密码登录返回该结果表示需要设备二次校验
SecondDeviceAuthResult = -133
)
func (y *Cloud189PC) SignatureHeader(url, method, params string, isFamily bool) map[string]string {
@@ -195,9 +210,10 @@ func (y *Cloud189PC) put(ctx context.Context, url string, headers map[string]str
}
func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool) ([]model.Obj, error) {
pageSize := 1000 // 每一页返回的文件数量
res := make([]model.Obj, 0, 100)
for pageNum := 1; ; pageNum++ {
resp, err := y.getFilesWithPage(ctx, fileId, isFamily, pageNum, 1000, y.OrderBy, y.OrderDirection)
resp, err := y.getFilesWithPage(ctx, fileId, isFamily, pageNum, pageSize, y.OrderBy, y.OrderDirection)
if err != nil {
return nil, err
}
@@ -205,13 +221,23 @@ func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool)
if resp.FileListAO.Count == 0 {
break
}
FolderCount := len(resp.FileListAO.FolderList) // 当前文件夹总数
FileCount := len(resp.FileListAO.FileList) // 当前文件总数
PageCount := FolderCount + FileCount // 当前页数总数
for i := 0; i < len(resp.FileListAO.FolderList); i++ {
for i := 0; i < FolderCount; i++ {
res = append(res, &resp.FileListAO.FolderList[i])
}
for i := 0; i < len(resp.FileListAO.FileList); i++ {
for i := 0; i < FileCount; i++ {
resp.FileListAO.FileList[i].ParentID = fileId
res = append(res, &resp.FileListAO.FileList[i])
}
// 当前文件数量小于设定数量则跳出
if PageCount < pageSize {
break
}
}
return res, nil
}
@@ -277,9 +303,72 @@ func (y *Cloud189PC) login() error {
if y.LoginType == "qrcode" {
return y.loginByQRCode()
}
if y.Username == "" || y.Password == "" {
return errors.New("please fill in the username and password, or provide an access token / refresh token")
}
return y.loginByPassword()
}
// 设备指纹,为空时生成并保存,服务端以此识别是否为同一台设备
func (y *Cloud189PC) getUserFinger() string {
if y.Addition.UserFinger == "" {
y.Addition.UserFinger = fmt.Sprint(random.Rand.Int63n(9e9) + 1e9)
op.MustSaveDriverStorage(y)
}
return y.Addition.UserFinger
}
// 换取会话时携带的设备参数,与官方PC客户端保持一致
// clientSn/jgOpenId 只在用户从官方客户端抓到并填写后才发送,避免上报一个服务端不认识的设备号
func (y *Cloud189PC) deviceParams() map[string]string {
params := map[string]string{"returnType": "JSON"}
if y.Addition.ClientSn != "" {
params["clientSn"] = y.Addition.ClientSn
}
if y.Addition.JgOpenId != "" {
params["jgOpenId"] = y.Addition.JgOpenId
}
return params
}
// logbox接口的公共请求头,缺少user-finger和Referer会被判定为陌生设备
func (y *Cloud189PC) loginHeaders(param BaseLoginParam) map[string]string {
return map[string]string{
"REQID": param.ReqId,
"lt": param.Lt,
"user-finger": y.getUserFinger(),
"Referer": IF(param.Referer != "", param.Referer, AUTH_URL),
}
}
// 把已保存的设备标识写入cookie,避免重复触发设备二次校验
func (y *Cloud189PC) applyDeviceID(jar http.CookieJar) {
if y.Addition.DeviceID == "" {
return
}
authUrl, err := url.Parse(AUTH_URL)
if err != nil {
return
}
jar.SetCookies(authUrl, []*http.Cookie{{
Name: DEVICE_ID_COOKIE,
Value: y.Addition.DeviceID,
Domain: "e.189.cn",
Path: "/",
}})
}
// 保存服务端下发的设备标识,下次登陆复用即可跳过设备二次校验
func (y *Cloud189PC) saveDeviceID(res *resty.Response) {
for _, cookie := range res.Cookies() {
if cookie.Name == DEVICE_ID_COOKIE && cookie.Value != "" && cookie.Value != y.Addition.DeviceID {
y.Addition.DeviceID = cookie.Value
op.MustSaveDriverStorage(y)
return
}
}
}
func (y *Cloud189PC) loginByPassword() (err error) {
// 初始化登陆所需参数
if y.loginParam == nil {
@@ -288,9 +377,16 @@ func (y *Cloud189PC) loginByPassword() (err error) {
return err
}
}
// 设备二次校验必须复用同一套登陆参数,此时不能销毁也不能重新初始化
keepLoginParam := false
defer func() {
// 销毁验证码
y.VCode = ""
if keepLoginParam {
y.Status = err.Error()
op.MustSaveDriverStorage(y)
return
}
// 销毁登陆参数
y.loginParam = nil
// 遇到错误,重新加载登陆参数(刷新验证码)
@@ -308,17 +404,18 @@ func (y *Cloud189PC) loginByPassword() (err error) {
param := y.loginParam
var loginresp LoginResp
_, err = y.client.R().
res, err := y.client.R().
ForceContentType("application/json;charset=UTF-8").SetResult(&loginresp).
SetHeaders(map[string]string{
"REQID": param.ReqId,
"lt": param.Lt,
}).
SetHeaders(y.loginHeaders(param.BaseLoginParam)).
SetFormData(map[string]string{
"version": "v2.0",
"apToken": "",
"appKey": APP_ID,
"pageKey": "normal",
"accountType": ACCOUNT_TYPE,
"userName": param.RsaUsername,
"password": param.RsaPassword,
"epd": param.RsaPassword,
"validateCode": y.VCode,
"captchaToken": param.CaptchaToken,
"returnUrl": RETURN_URL,
@@ -334,17 +431,106 @@ func (y *Cloud189PC) loginByPassword() (err error) {
if err != nil {
return err
}
y.saveDeviceID(res)
// 设备二次校验:服务端要求短信验证,保留登陆参数并引导填写短信验证码
if loginresp.Result == SecondDeviceAuthResult {
err = y.secondDeviceAuth(loginresp.Mobile)
// 校验未完成时保留登陆参数,等待用户回填短信验证码
keepLoginParam = err != nil && y.loginParam != nil
return err
}
if loginresp.ToUrl == "" {
return fmt.Errorf("login failed,No toUrl obtained, msg: %s", loginresp.Msg)
}
// 获取Session
return y.getSessionByRedirectURL(loginresp.ToUrl)
}
// 设备二次校验:先发短信,用户回填验证码后再提交
func (y *Cloud189PC) secondDeviceAuth(mobile string) error {
param := y.loginParam
if mobile != "" {
param.SecondAuthMobile = mobile
}
if param.SecondAuthMobile == "" {
return errors.New("second device verification is required, but no mobile was returned")
}
// 已填写短信验证码,直接提交校验
if y.SmsCode != "" {
smsCode := y.SmsCode
y.SmsCode = ""
op.MustSaveDriverStorage(y)
return y.submitSecondDeviceAuth(smsCode)
}
var smsResp LoginResp
_, err := y.client.R().
ForceContentType("application/json;charset=UTF-8").SetResult(&smsResp).
SetHeaders(y.loginHeaders(param.BaseLoginParam)).
SetFormData(map[string]string{
"mobile": param.SecondAuthMobile,
"appKey": APP_ID,
}).
Post(AUTH_URL + "/api/logbox/oauth2/sendSmsCodeForSecondAuth.do")
if err != nil {
return err
}
if smsResp.Result != 0 {
return fmt.Errorf("failed to send the verification SMS: %s", smsResp.Msg)
}
// 保留登陆参数,等待用户回填短信验证码后重新保存
return errors.New("second device verification is required, an SMS code has been sent, please fill it into `sms_code` and save again")
}
// 提交短信验证码完成设备二次校验
// 注意:该接口没有独立的短信码字段,短信码要加密后放在epd里(登陆时epd装的是密码)
func (y *Cloud189PC) submitSecondDeviceAuth(smsCode string) error {
param := y.loginParam
var authResp LoginResp
res, err := y.client.R().
ForceContentType("application/json;charset=UTF-8").SetResult(&authResp).
SetHeaders(y.loginHeaders(param.BaseLoginParam)).
SetFormData(map[string]string{
"mobile": param.SecondAuthMobile,
"appKey": APP_ID,
"userName": param.RsaUsername,
"epd": param.encryptSecret(smsCode),
"accountType": ACCOUNT_TYPE,
"returnUrl": RETURN_URL,
"isOauth2": "false",
"cb_SaveName": "1",
"state": "",
"paramId": param.ParamId,
}).
Post(AUTH_URL + "/api/logbox/oauth2/submitForSecondAuth.do")
if err != nil {
return err
}
// 校验通过后服务端会下发DEVICEID,保存下来以后就不会再触发二次校验
y.saveDeviceID(res)
if authResp.Result != 0 {
return fmt.Errorf("second device verification failed: %s", authResp.Msg)
}
if authResp.ToUrl == "" {
return fmt.Errorf("second device verification failed, no toUrl obtained, msg: %s", authResp.Msg)
}
return y.getSessionByRedirectURL(authResp.ToUrl)
}
// 用登陆结果的跳转地址换取会话
func (y *Cloud189PC) getSessionByRedirectURL(redirectURL string) error {
var erron RespErr
var tokenInfo AppSessionResp
_, err = y.client.R().
_, err := y.client.R().
SetResult(&tokenInfo).SetError(&erron).
SetQueryParams(clientSuffix()).
SetQueryParam("redirectURL", loginresp.ToUrl).
SetQueryParams(y.deviceParams()).
SetQueryParam("redirectURL", redirectURL).
SetHeader("X-Request-ID", uuid.NewString()).
Post(API_URL + "/getSessionForPC.action")
if err != nil {
return err
@@ -354,14 +540,13 @@ func (y *Cloud189PC) loginByPassword() (err error) {
return &erron
}
if tokenInfo.ResCode != 0 {
err = fmt.Errorf(tokenInfo.ResMessage)
return err
return errors.New(tokenInfo.ResMessage)
}
y.Addition.AccessToken = tokenInfo.AccessToken
y.Addition.RefreshToken = tokenInfo.RefreshToken
y.tokenInfo = &tokenInfo
op.MustSaveDriverStorage(y)
return err
return nil
}
func (y *Cloud189PC) loginByQRCode() error {
@@ -372,66 +557,74 @@ func (y *Cloud189PC) loginByQRCode() error {
}
}
var state struct {
Status int `json:"status"`
RedirectUrl string `json:"redirectUrl"`
Msg string `json:"msg"`
// 本地轮询,扫码确认后自动继续,不需要用户反复保存
deadline := time.Now().Add(QRCODE_POLL_TIMEOUT)
lastStatus := -106
for {
state, err := y.checkQRCodeState()
if err != nil {
return fmt.Errorf("failed to check QR code state: %w", err)
}
lastStatus = state.Status
switch state.Status {
case 0: // 登录成功
y.qrcodeParam = nil
return y.getSessionByRedirectURL(state.RedirectUrl)
case -106, -11002: // -106 等待扫描,-11002 已扫描等待确认
case -11001: // 二维码过期
y.qrcodeParam = nil
return errors.New("QR code expired, please try again")
default: // 其他错误
y.qrcodeParam = nil
return fmt.Errorf("QR code login failed with status %d: %s", state.Status, state.Msg)
}
if time.Now().Add(QRCODE_POLL_INTERVAL).After(deadline) {
break
}
time.Sleep(QRCODE_POLL_INTERVAL)
}
// 轮询超时,把二维码交回前端等待下一次保存
if lastStatus == -11002 {
return y.genQRCode("QR code has been scanned, please confirm the login on your phone and save again")
}
return y.genQRCode("QR code has not been scanned yet, please scan and save again")
}
type qrCodeState struct {
Status int `json:"status"`
RedirectUrl string `json:"redirectUrl"`
Msg string `json:"msg"`
}
// 查询扫码状态,参数需与官方PC端一致,否则服务端不会返回授权结果
func (y *Cloud189PC) checkQRCodeState() (*qrCodeState, error) {
now := time.Now()
var state qrCodeState
_, err := y.client.R().
SetHeaders(map[string]string{
"Referer": AUTH_URL,
"Reqid": y.qrcodeParam.ReqId,
"lt": y.qrcodeParam.Lt,
}).
SetHeaders(y.loginHeaders(y.qrcodeParam.BaseLoginParam)).
SetFormData(map[string]string{
"appId": APP_ID,
"clientType": CLIENT_TYPE,
"returnUrl": RETURN_URL,
"paramId": y.qrcodeParam.ParamId,
"uuid": y.qrcodeParam.UUID,
"encryuuid": y.qrcodeParam.EncryUUID,
"date": formatDate(now),
"timeStamp": fmt.Sprint(now.UTC().UnixNano() / 1e6),
"appId": APP_ID,
"clientType": QR_CLIENT_TYPE,
"returnUrl": RETURN_URL,
"paramId": y.qrcodeParam.ParamId,
"uuid": y.qrcodeParam.UUID,
"encryuuid": y.qrcodeParam.EncryUUID,
"cb_SaveName": "3",
"isOauth2": "false",
"state": "",
"date": formatDate(now),
"timeStamp": fmt.Sprint(now.UTC().UnixNano() / 1e6),
}).
ForceContentType("application/json;charset=UTF-8").
SetResult(&state).
Post(AUTH_URL + "/api/logbox/oauth2/qrcodeLoginState.do")
if err != nil {
return fmt.Errorf("failed to check QR code state: %w", err)
}
switch state.Status {
case 0: // 登录成功
var tokenInfo AppSessionResp
_, err = y.client.R().
SetResult(&tokenInfo).
SetQueryParams(clientSuffix()).
SetQueryParam("redirectURL", state.RedirectUrl).
Post(API_URL + "/getSessionForPC.action")
if err != nil {
return err
}
if tokenInfo.ResCode != 0 {
return fmt.Errorf(tokenInfo.ResMessage)
}
y.Addition.AccessToken = tokenInfo.AccessToken
y.Addition.RefreshToken = tokenInfo.RefreshToken
y.tokenInfo = &tokenInfo
op.MustSaveDriverStorage(y)
return nil
case -11001: // 二维码过期
y.qrcodeParam = nil
return errors.New("QR code expired, please try again")
case -106: // 等待扫描
return y.genQRCode("QR code has not been scanned yet, please scan and save again")
case -11002: // 等待确认
return y.genQRCode("QR code has been scanned, please confirm the login on your phone and save again")
default: // 其他错误
y.qrcodeParam = nil
return fmt.Errorf("QR code login failed with status %d: %s", state.Status, state.Msg)
return nil, err
}
return &state, nil
}
func (y *Cloud189PC) genQRCode(text string) error {
@@ -457,8 +650,9 @@ func (y *Cloud189PC) genQRCode(text string) error {
}
func (y *Cloud189PC) initBaseParams() (*BaseLoginParam, error) {
// 清除cookie
// 清除cookie,并带上已保存的设备标识
jar, _ := cookiejar.New(nil)
y.applyDeviceID(jar)
y.client.SetCookieJar(jar)
res, err := y.client.R().
@@ -473,14 +667,98 @@ func (y *Cloud189PC) initBaseParams() (*BaseLoginParam, error) {
return nil, err
}
// 当前登陆页把lt/reqId放在跳转地址上,老页面则写在页内变量里,两种都要支持
param, err := parseBaseParamFromRedirect(res.RawResponse.Request.URL)
if err != nil {
param, err = parseBaseParamFromPage(res.String())
if err != nil {
return nil, err
}
return param, nil
}
// 跳转地址上没有paramId,需要再问一次appConf.do
var appConf AppConfResp
_, err = y.client.R().
SetHeaders(y.loginHeaders(*param)).
ForceContentType("application/json;charset=UTF-8").
SetResult(&appConf).
SetFormData(map[string]string{
"version": "2.0",
"appKey": APP_ID,
}).
Post(AUTH_URL + "/api/logbox/oauth2/appConf.do")
if err != nil {
return nil, err
}
if !appConf.Succeeded() || appConf.Data.ParamId == "" {
return nil, fmt.Errorf("failed to get the login paramId: %s", appConf.Msg)
}
param.ParamId = appConf.Data.ParamId
return param, nil
}
// parseBaseParamFromRedirect 从logbox跳转地址提取登陆参数,并以该地址作为后续请求的Referer
func parseBaseParamFromRedirect(finalUrl *url.URL) (*BaseLoginParam, error) {
if finalUrl == nil {
return nil, errors.New("no login page redirect")
}
query := finalUrl.Query()
lt, reqId := query.Get("lt"), query.Get("reqId")
if lt == "" || reqId == "" {
return nil, errors.New("no lt/reqId in the login page redirect")
}
return &BaseLoginParam{
CaptchaToken: regexp.MustCompile(`'captchaToken' value='(.+?)'`).FindStringSubmatch(res.String())[1],
Lt: regexp.MustCompile(`lt = "(.+?)"`).FindStringSubmatch(res.String())[1],
ParamId: regexp.MustCompile(`paramId = "(.+?)"`).FindStringSubmatch(res.String())[1],
ReqId: regexp.MustCompile(`reqId = "(.+?)"`).FindStringSubmatch(res.String())[1],
Lt: lt,
ReqId: reqId,
Referer: finalUrl.String(),
}, nil
}
// parseBaseParamFromPage 兼容把参数写在页内变量里的老登陆页
func parseBaseParamFromPage(body string) (*BaseLoginParam, error) {
lt, err := matchLoginParam(body, `lt = "(.+?)"`, "lt")
if err != nil {
return nil, err
}
reqId, err := matchLoginParam(body, `reqId = "(.+?)"`, "reqId")
if err != nil {
return nil, err
}
paramId, err := matchLoginParam(body, `paramId = "(.+?)"`, "paramId")
if err != nil {
return nil, err
}
// 老页面才有内嵌的图形验证码token
captchaToken, _ := matchLoginParam(body, `'captchaToken' value='(.+?)'`, "captchaToken")
param := &BaseLoginParam{
CaptchaToken: captchaToken,
Lt: lt,
ParamId: paramId,
ReqId: reqId,
}
encryptUrl, _ := matchLoginParam(body, `encryptUrl = "(.+?)"`, "encryptUrl")
param.Referer = AUTH_URL + "/api/logbox/separate/web/index.html?" + strings.Join([]string{
"appId=" + url.QueryEscape(APP_ID),
"lt=" + url.QueryEscape(param.Lt),
"reqId=" + url.QueryEscape(param.ReqId),
}, "&")
if encryptUrl != "" {
param.Referer += "&encryptUrl=" + url.QueryEscape(encryptUrl)
}
return param, nil
}
// matchLoginParam 从登陆页面提取参数,缺失时返回可读的错误而不是panic
func matchLoginParam(body, pattern, name string) (string, error) {
matches := regexp.MustCompile(pattern).FindStringSubmatch(body)
if len(matches) < 2 {
return "", fmt.Errorf("failed to get %s from the login page", name)
}
return matches[1], nil
}
/* 初始化登陆需要的参数
* 如果遇到验证码返回错误
*/
@@ -505,12 +783,13 @@ func (y *Cloud189PC) initLoginParam() error {
}
y.loginParam.jRsaKey = fmt.Sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----", encryptConf.Data.PubKey)
y.loginParam.RsaUsername = encryptConf.Data.Pre + RsaEncrypt(y.loginParam.jRsaKey, y.Username)
y.loginParam.RsaPassword = encryptConf.Data.Pre + RsaEncrypt(y.loginParam.jRsaKey, y.Password)
y.loginParam.rsaPrefix = encryptConf.Data.Pre
y.loginParam.RsaUsername = y.loginParam.encryptSecret(y.Username)
y.loginParam.RsaPassword = y.loginParam.encryptSecret(y.Password)
// 判断是否需要验证码
resp, err := y.client.R().
SetHeader("REQID", y.loginParam.ReqId).
SetHeaders(y.loginHeaders(y.loginParam.BaseLoginParam)).
SetFormData(map[string]string{
"appKey": APP_ID,
"accountType": ACCOUNT_TYPE,
@@ -565,6 +844,7 @@ func (y *Cloud189PC) initQRCodeParam() (err error) {
var qrcodeParam QRLoginParam
_, err = y.client.R().
SetHeaders(y.loginHeaders(*baseParam)).
SetFormData(map[string]string{"appId": APP_ID}).
ForceContentType("application/json;charset=UTF-8").
SetResult(&qrcodeParam).
@@ -572,6 +852,9 @@ func (y *Cloud189PC) initQRCodeParam() (err error) {
if err != nil {
return err
}
if qrcodeParam.UUID == "" {
return errors.New("failed to get the QR code uuid")
}
qrcodeParam.BaseLoginParam = *baseParam
y.qrcodeParam = &qrcodeParam
@@ -592,6 +875,7 @@ func (y *Cloud189PC) refreshSessionWithRetry(retryCount int) (err error) {
_, err = y.client.R().
SetResult(&userSessionResp).SetError(&erron).
SetQueryParams(clientSuffix()).
SetQueryParams(y.deviceParams()).
SetQueryParams(map[string]string{
"appId": APP_ID,
"accessToken": y.tokenInfo.AccessToken,
@@ -632,12 +916,11 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
return errors.New("refresh token failed after maximum retries")
}
var erron RespErr
var tokenInfo AppSessionResp
// 该接口刷新失败时以HTTP 200返回 result/msg,SetError不会触发,必须解析响应体判断
var tokenInfo RefreshTokenResp
_, err = y.client.R().
SetResult(&tokenInfo).
ForceContentType("application/json;charset=UTF-8").
SetError(&erron).
SetFormData(map[string]string{
"clientId": APP_ID,
"refreshToken": y.tokenInfo.RefreshToken,
@@ -650,7 +933,8 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
}
// 如果刷新失败,返回错误给上层处理
if erron.HasError() {
if tokenInfo.HasError() {
refreshErr := tokenInfo.Error()
if y.Addition.RefreshToken != "" {
y.Addition.RefreshToken = ""
op.MustSaveDriverStorage(y)
@@ -658,7 +942,11 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
// 根据登录类型决定下一步行为
if y.LoginType == "qrcode" {
return errors.New("QR code session has expired, please re-scan the code to log in")
return fmt.Errorf("QR code session has expired, please re-scan the code to log in: %s", refreshErr)
}
// 没有账号密码时无法回退到完整登录,直接把刷新失败的原因返回
if y.Username == "" || y.Password == "" {
return errors.New(refreshErr)
}
// 密码登录模式下,尝试回退到完整登录
return y.login()
@@ -666,7 +954,8 @@ func (y *Cloud189PC) refreshTokenWithRetry(retryCount int) (err error) {
y.Addition.AccessToken = tokenInfo.AccessToken
y.Addition.RefreshToken = tokenInfo.RefreshToken
y.tokenInfo = &tokenInfo
y.tokenInfo.AccessToken = tokenInfo.AccessToken
y.tokenInfo.RefreshToken = tokenInfo.RefreshToken
op.MustSaveDriverStorage(y)
return y.refreshSessionWithRetry(retryCount + 1)
}
@@ -910,6 +1199,11 @@ func (y *Cloud189PC) RapidUpload(ctx context.Context, dstDir model.Obj, stream m
// 快传
func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool) (model.Obj, error) {
generateTorrent := y.Addition.GenerateTorrent && !isCASTorrentFile(file.GetName())
return y.fastUpload(ctx, dstDir, file, up, isFamily, overwrite, generateTorrent)
}
func (y *Cloud189PC) fastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress, isFamily bool, overwrite bool, generateTorrent bool) (model.Obj, error) {
var (
cache = file.GetFile()
tmpF *os.File
@@ -947,6 +1241,9 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
if tmpF != nil {
writers = append(writers, tmpF)
}
pieceSHA1Hashes := make([]byte, 0, count*20)
written := int64(0)
for i := 1; i <= count; i++ {
if utils.IsCanceled(ctx) {
@@ -957,7 +1254,17 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
byteSize = lastSliceSize
}
n, err := utils.CopyWithBufferN(io.MultiWriter(writers...), file, byteSize)
// 如果需要生成 torrent,同时计算 SHA-1
var sha1Writer hash.Hash
var multiWriter io.Writer
if generateTorrent {
sha1Writer = sha1Pkg.New()
multiWriter = io.MultiWriter(append(writers, sha1Writer)...)
} else {
multiWriter = io.MultiWriter(writers...)
}
n, err := utils.CopyWithBufferN(multiWriter, file, byteSize)
written += n
if err != nil && err != io.EOF {
return nil, err
@@ -966,6 +1273,11 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
sliceMd5Hexs = append(sliceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Byte)))
partInfos = append(partInfos, fmt.Sprint(i, "-", base64.StdEncoding.EncodeToString(md5Byte)))
sliceMd5.Reset()
// 收集 SHA-1 piece hash(仅在本次分片实际写入了数据时追加)
if generateTorrent && n > 0 {
pieceSHA1Hashes = append(pieceSHA1Hashes, sha1Writer.Sum(nil)...)
}
}
if tmpF != nil {
@@ -1081,6 +1393,44 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
if err != nil {
return nil, err
}
// 生成 torrent 文件(异步,不影响上传结果)
if generateTorrent && size > 0 && len(pieceSHA1Hashes) > 0 {
capturedDstDir := dstDir
capturedIsFamily := isFamily
capturedFileName := file.GetName()
go func() {
torrentData, err := GenerateTorrent(capturedFileName, size, fileMd5Hex, sliceMd5Hexs, sliceSize, pieceSHA1Hashes)
if err != nil {
utils.Log.Warnf("生成 torrent 失败: %v", err)
return
}
infoHash, _ := GetInfoHashHex(torrentData)
torrentName := capturedFileName + ".cas.torrent"
utils.Log.Infof("已生成 torrent: %s (info_hash: %s, size: %d bytes)",
torrentName, infoHash, len(torrentData))
// 将 torrent 文件上传到同一目录
torrentFileStream := &stream.FileStream{
Ctx: context.Background(),
Obj: &model.Object{
Name: torrentName,
Size: int64(len(torrentData)),
IsFolder: false,
},
Reader: bytes.NewReader(torrentData),
Mimetype: "application/x-bittorrent",
}
_, uploadErr := y.fastUpload(context.Background(), capturedDstDir, torrentFileStream, func(p float64) {}, capturedIsFamily, false, false)
if uploadErr != nil {
utils.Log.Warnf("上传 torrent 文件失败: %v", uploadErr)
} else {
utils.Log.Infof("torrent 文件已上传: %s", torrentName)
op.Cache.DeleteDirectory(y, capturedDstDir.GetPath())
}
}()
}
return resp.toFile(), nil
}
-1
View File
@@ -328,7 +328,6 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
return nil, err
}
resultLink := link.Clone() // 复制一份,避免修改到原始link
resultLink.Expiration = nil
if args.Redirect {
return resultLink, nil
}
+1
View File
@@ -95,6 +95,7 @@ func (d *AListV3) List(ctx context.Context, dir model.Obj, args model.ListArgs)
file := model.ObjThumb{
Object: model.Object{
Name: f.Name,
Path: path.Join(dir.GetPath(), f.Name),
Modified: f.Modified,
Ctime: f.Created,
Size: f.Size,
+65
View File
@@ -0,0 +1,65 @@
package alist_v3
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/go-resty/resty/v2"
)
// TestListSetsChildPaths descends two levels the way op.Get does, feeding an
// object from one listing back into List as dir. Without a Path on that object
// the driver asks upstream for "", which a real server answers with its own
// root -- hence the fake upstream's fallback, and the endless self-similar tree.
func TestListSetsChildPaths(t *testing.T) {
tree := map[string][]ObjResp{
"/": {{Name: "root-marker", IsDir: true}},
"/drive": {{Name: "concerts", IsDir: true}},
"/drive/concerts": {{Name: "show.mkv"}},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ListReq
_ = json.NewDecoder(r.Body).Decode(&req)
content, ok := tree[req.Path]
if !ok {
content = tree["/"]
}
w.Header().Set("Content-Type", "application/json") // resty only unmarshals JSON
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200, "message": "success",
"data": map[string]any{"content": content, "total": len(content)},
})
}))
t.Cleanup(srv.Close)
// conf.Conf is nil outside a booted server, so base.InitClient() is unusable.
prev := base.RestyClient
base.RestyClient = resty.New().SetTimeout(5 * time.Second)
t.Cleanup(func() { base.RestyClient = prev })
d := &AListV3{Addition: Addition{
RootPath: driver.RootPath{RootFolderPath: "/drive"},
Address: srv.URL,
}}
dir := model.Obj(&model.Object{Path: "/drive", IsFolder: true})
for _, want := range []string{"/drive/concerts", "/drive/concerts/show.mkv"} {
objs, err := d.List(context.Background(), dir, model.ListArgs{})
if err != nil {
t.Fatalf("List(%q): %v", dir.GetPath(), err)
}
if len(objs) != 1 {
t.Fatalf("List(%q) returned %d objects, want 1", dir.GetPath(), len(objs))
}
if got := objs[0].GetPath(); got != want {
t.Fatalf("child of %q has path %q, want %q", dir.GetPath(), got, want)
}
dir = objs[0]
}
}
+295
View File
@@ -0,0 +1,295 @@
package aliyundrive_open
import (
"context"
"fmt"
"io"
"math/rand/v2"
"net/http"
"strings"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
anet "github.com/OpenListTeam/OpenList/v4/internal/net"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
)
const (
defaultCallbackConcurrency = 1
callbackAcquireTimeout = time.Second
callbackRequestAttempts = 3
callbackRetryBaseDelay = 200 * time.Millisecond
callbackErrorBodyLimit = 64 << 10
)
var callbackLimiters = struct {
sync.Mutex
byUser map[string]*callbackLimiter
}{byUser: make(map[string]*callbackLimiter)}
type callbackLimiter struct {
userID string
mu sync.Mutex
active int
nextID uint64
registrations map[uint64]int
changed chan struct{}
}
type callbackRegistration struct {
limiter *callbackLimiter
id uint64
once sync.Once
}
type callbackPermit struct {
limiter *callbackLimiter
once sync.Once
}
func normalizeCallbackConcurrency(limit int) int {
if limit <= 0 {
return defaultCallbackConcurrency
}
return limit
}
func registerCallbackLimiter(userID string, limit int) *callbackRegistration {
callbackLimiters.Lock()
defer callbackLimiters.Unlock()
limiter := callbackLimiters.byUser[userID]
if limiter == nil {
limiter = &callbackLimiter{
userID: userID,
registrations: make(map[uint64]int),
changed: make(chan struct{}),
}
callbackLimiters.byUser[userID] = limiter
}
limiter.mu.Lock()
limiter.nextID++
id := limiter.nextID
limiter.registrations[id] = normalizeCallbackConcurrency(limit)
limiter.signalLocked()
limiter.mu.Unlock()
return &callbackRegistration{limiter: limiter, id: id}
}
func (r *callbackRegistration) unregister() {
if r == nil || r.limiter == nil {
return
}
r.once.Do(func() {
callbackLimiters.Lock()
defer callbackLimiters.Unlock()
r.limiter.mu.Lock()
delete(r.limiter.registrations, r.id)
r.limiter.signalLocked()
if len(r.limiter.registrations) == 0 && r.limiter.active == 0 {
delete(callbackLimiters.byUser, r.limiter.userID)
}
r.limiter.mu.Unlock()
})
}
func (r *callbackRegistration) acquire(ctx context.Context) (*callbackPermit, error) {
if r == nil || r.limiter == nil {
return nil, errs.NewErr(errs.TemporaryCapacity, "callback limiter is unavailable")
}
if err := ctx.Err(); err != nil {
return nil, err
}
waitCtx, cancel := context.WithTimeout(ctx, callbackAcquireTimeout)
defer cancel()
for {
r.limiter.mu.Lock()
if r.limiter.active < r.limiter.limitLocked() {
r.limiter.active++
r.limiter.mu.Unlock()
return &callbackPermit{limiter: r.limiter}, nil
}
changed := r.limiter.changed
r.limiter.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-waitCtx.Done():
if err := ctx.Err(); err != nil {
return nil, err
}
return nil, errs.NewErr(errs.TemporaryCapacity, "timed out waiting for callback admission")
case <-changed:
}
}
}
func (l *callbackLimiter) limitLocked() int {
limit := 0
for _, registered := range l.registrations {
if limit == 0 || registered < limit {
limit = registered
}
}
return limit
}
func (l *callbackLimiter) signalLocked() {
close(l.changed)
l.changed = make(chan struct{})
}
func (p *callbackPermit) release() {
if p == nil || p.limiter == nil {
return
}
p.once.Do(func() {
callbackLimiters.Lock()
defer callbackLimiters.Unlock()
p.limiter.mu.Lock()
p.limiter.active--
p.limiter.signalLocked()
if len(p.limiter.registrations) == 0 && p.limiter.active == 0 {
delete(callbackLimiters.byUser, p.limiter.userID)
}
p.limiter.mu.Unlock()
})
}
func (d *AliyundriveOpen) callbackRegistration() *callbackRegistration {
if d.callback != nil {
return d.callback
}
if d.ref != nil {
return d.ref.callbackRegistration()
}
return nil
}
func (d *AliyundriveOpen) callbackRangeReader(url string, size int64) stream.RangeReaderFunc {
return func(ctx context.Context, requested http_range.Range) (io.ReadCloser, error) {
if requested.Length < 0 || requested.Start+requested.Length > size {
requested.Length = size - requested.Start
}
for attempt := 0; attempt < callbackRequestAttempts; attempt++ {
permit, err := d.callbackRegistration().acquire(ctx)
if err != nil {
return nil, err
}
body, retry, err := openCallbackRange(ctx, url, size, requested)
if !retry && err == nil {
return newCallbackBody(ctx, body, permit.release), nil
}
permit.release()
if !retry {
return nil, err
}
if attempt+1 == callbackRequestAttempts {
return nil, errs.NewErr(errs.TemporaryCapacity, "Aliyun callback concurrency limit rejected %d attempts", callbackRequestAttempts)
}
delay := callbackRetryBaseDelay << attempt
delay += time.Duration(rand.Int64N(int64(delay / 2)))
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, errs.NewErr(errs.TemporaryCapacity, "callback attempts exhausted")
}
}
func openCallbackRange(ctx context.Context, url string, size int64, requested http_range.Range) (io.ReadCloser, bool, error) {
requestHeader, _ := ctx.Value(conf.RequestHeaderKey).(http.Header)
header := anet.ProcessHeader(requestHeader, nil)
header = http_range.ApplyRangeToHttpHeader(requested, header)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, false, fmt.Errorf("create Aliyun callback request: %w", err)
}
req.Header = header
response, err := anet.HttpClient().Do(req)
if err != nil {
return nil, false, fmt.Errorf("Aliyun callback request failed: %w", err)
}
if response.StatusCode >= http.StatusBadRequest {
defer response.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(response.Body, callbackErrorBodyLimit))
if readErr != nil {
return nil, false, fmt.Errorf("read Aliyun callback error response: %w", readErr)
}
if isCallbackCapacityRejection(response.StatusCode, body) {
return nil, true, nil
}
message := strings.ReplaceAll(strings.TrimSpace(string(body)), url, "<redacted>")
return nil, false, fmt.Errorf("Aliyun callback request failed: %w; response: %s", anet.HttpStatusCodeError(response.StatusCode), message)
}
if requested.Start == 0 && requested.Length == size || response.StatusCode == http.StatusPartialContent || callbackContentRangeStartsAt(response.Header, requested.Start) {
return response.Body, false, nil
}
if response.StatusCode == http.StatusOK {
body, rangeErr := anet.GetRangedHttpReader(response.Body, requested.Start, requested.Length)
if rangeErr != nil {
response.Body.Close()
return nil, false, rangeErr
}
return body, false, nil
}
return response.Body, false, nil
}
func isCallbackCapacityRejection(status int, body []byte) bool {
return status == http.StatusForbidden &&
strings.Contains(string(body), "RequestDeniedByCallback") &&
strings.Contains(string(body), "ExceedMaxConcurrency")
}
func callbackContentRangeStartsAt(header http.Header, offset int64) bool {
start, _, err := http_range.ParseContentRange(header.Get("Content-Range"))
return err == nil && start == offset
}
type callbackBody struct {
body io.ReadCloser
release func()
once sync.Once
mu sync.Mutex
stop func() bool
}
func newCallbackBody(ctx context.Context, body io.ReadCloser, release func()) *callbackBody {
b := &callbackBody{body: body, release: release}
stop := context.AfterFunc(ctx, func() { _ = b.Close() })
b.mu.Lock()
b.stop = stop
b.mu.Unlock()
return b
}
func (b *callbackBody) Read(p []byte) (int, error) {
n, err := b.body.Read(p)
if err != nil {
_ = b.Close()
}
return n, err
}
func (b *callbackBody) Close() error {
var err error
b.once.Do(func() {
b.mu.Lock()
stop := b.stop
b.mu.Unlock()
if stop != nil {
stop()
}
err = b.body.Close()
b.release()
})
return err
}
+365
View File
@@ -0,0 +1,365 @@
package aliyundrive_open
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"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/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
)
func TestLinkSeparatesRedirectAndProxyRepresentations(t *testing.T) {
oldConf := conf.Conf
conf.Conf = &conf.Config{}
t.Cleanup(func() { conf.Conf = oldConf })
base.InitClient()
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/adrive/v1.0/user/getDriveInfo":
_, _ = fmt.Fprint(w, `{"user_id":"user-1","resource_drive_id":"drive-1"}`)
case "/adrive/v1.0/openFile/getDownloadUrl":
_, _ = fmt.Fprintf(w, `{"url":%q}`, server.URL+"/callback")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldAPIURL := API_URL
API_URL = server.URL
defer func() { API_URL = oldAPIURL }()
d := &AliyundriveOpen{Addition: Addition{AccessToken: "token"}}
if err := d.Init(t.Context()); err != nil {
t.Fatal(err)
}
defer d.Drop(context.Background())
if d.CallbackConcurrency != defaultCallbackConcurrency {
t.Fatalf("normalized callback concurrency = %d, want %d", d.CallbackConcurrency, defaultCallbackConcurrency)
}
link, err := d.Link(t.Context(), &model.Object{ID: "file-1", Name: "file", Size: 1}, model.LinkArgs{})
if err != nil {
t.Fatal(err)
}
if link.RangeReader == nil {
t.Fatal("proxy link must own callback acquisition through a range reader")
}
if _, ok := link.RangeReader.(stream.RateLimitRangeReaderFunc); !ok {
t.Fatalf("proxy range reader type = %T, want server-rate-limited reader", link.RangeReader)
}
direct, err := d.Link(t.Context(), &model.Object{ID: "file-1", Name: "file", Size: 1}, model.LinkArgs{Redirect: true})
if err != nil {
t.Fatal(err)
}
if direct.URL == "" || direct.RangeReader != nil {
t.Fatal("redirect link must remain URL-only")
}
}
func TestCallbackRangeHoldsPermitUntilBodyClose(t *testing.T) {
oldConf := conf.Conf
conf.Conf = &conf.Config{}
t.Cleanup(func() { conf.Conf = oldConf })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "1")
w.Header().Set("Content-Range", "bytes 0-0/1")
w.WriteHeader(http.StatusPartialContent)
_, _ = io.WriteString(w, "x")
}))
defer server.Close()
registration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(registration.unregister)
d := &AliyundriveOpen{callback: registration}
body, err := d.callbackRangeReader(server.URL, 1).RangeRead(t.Context(), http_range.Range{Length: 1})
if err != nil {
t.Fatal(err)
}
registration.limiter.mu.Lock()
active := registration.limiter.active
registration.limiter.mu.Unlock()
if active != 1 {
t.Fatalf("active callback bodies = %d, want 1", active)
}
if err := body.Close(); err != nil {
t.Fatal(err)
}
registration.limiter.mu.Lock()
active = registration.limiter.active
registration.limiter.mu.Unlock()
if active != 0 {
t.Fatalf("active callback bodies after Close = %d, want 0", active)
}
}
func TestCallbackLimiterUsesMinimumRegisteredLimit(t *testing.T) {
firstRegistration := registerCallbackLimiter(t.Name(), 2)
t.Cleanup(firstRegistration.unregister)
first, err := firstRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
second, err := firstRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
defer first.release()
defer second.release()
lowerRegistration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(lowerRegistration.unregister)
acquired := make(chan *callbackPermit, 1)
go func() {
permit, acquireErr := lowerRegistration.acquire(t.Context())
if acquireErr == nil {
acquired <- permit
}
}()
first.release()
select {
case permit := <-acquired:
permit.release()
t.Fatal("lowering the shared limit must wait for all excess bodies to drain")
case <-time.After(100 * time.Millisecond):
}
second.release()
select {
case permit := <-acquired:
permit.release()
case <-time.After(time.Second):
t.Fatal("admission did not resume after active bodies drained below the new limit")
}
}
func TestCallbackLimiterSeparatesUsers(t *testing.T) {
firstUser := registerCallbackLimiter(t.Name()+"-first", 1)
secondUser := registerCallbackLimiter(t.Name()+"-second", 1)
t.Cleanup(firstUser.unregister)
t.Cleanup(secondUser.unregister)
first, err := firstUser.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
defer first.release()
second, err := secondUser.acquire(t.Context())
if err != nil {
t.Fatalf("independent user was blocked: %v", err)
}
second.release()
}
func TestCallbackLimiterReconfigureWaitsForOldBodies(t *testing.T) {
userID := t.Name()
oldRegistration := registerCallbackLimiter(userID, 2)
first, err := oldRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
second, err := oldRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
oldRegistration.unregister()
newRegistration := registerCallbackLimiter(userID, 1)
t.Cleanup(newRegistration.unregister)
acquired := make(chan *callbackPermit, 1)
go func() {
permit, acquireErr := newRegistration.acquire(t.Context())
if acquireErr == nil {
acquired <- permit
}
}()
first.release()
select {
case permit := <-acquired:
permit.release()
t.Fatal("reconfigured limiter admitted while an old body still occupied the new limit")
case <-time.After(100 * time.Millisecond):
}
second.release()
select {
case permit := <-acquired:
permit.release()
case <-time.After(time.Second):
t.Fatal("reconfigured limiter did not admit after old bodies drained")
}
}
func TestCallbackLimiterDistinguishesTimeoutAndCancellation(t *testing.T) {
registration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(registration.unregister)
permit, err := registration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
defer permit.release()
started := time.Now()
_, err = registration.acquire(t.Context())
if !errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("admission timeout error = %v, want TemporaryCapacity", err)
}
if time.Since(started) < callbackAcquireTimeout {
t.Fatal("admission timed out before the configured wait elapsed")
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err = registration.acquire(ctx)
if !errors.Is(err, context.Canceled) || errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("canceled admission error = %v, want only context.Canceled", err)
}
}
func TestCallbackCapacityRejectionRequiresBothExactMarkers(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{name: "both", body: `{"code":"RequestDeniedByCallback","message":"ExceedMaxConcurrency"}`, want: true},
{name: "code only", body: `{"code":"RequestDeniedByCallback"}`},
{name: "message only", body: `{"message":"ExceedMaxConcurrency"}`},
{name: "case differs", body: `{"code":"requestdeniedbycallback","message":"ExceedMaxConcurrency"}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isCallbackCapacityRejection(http.StatusForbidden, []byte(test.body)); got != test.want {
t.Fatalf("classification = %v, want %v", got, test.want)
}
})
}
if isCallbackCapacityRejection(http.StatusTooManyRequests, []byte(`RequestDeniedByCallback ExceedMaxConcurrency`)) {
t.Fatal("non-403 response must not be classified as callback capacity")
}
}
func TestCallbackRangeRetriesOnlyVerifiedCapacityRejections(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"code":"RequestDeniedByCallback","message":"ExceedMaxConcurrency"}`)
}))
defer server.Close()
registration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(registration.unregister)
d := &AliyundriveOpen{callback: registration}
_, err := d.callbackRangeReader(server.URL+"?token=secret", 1).RangeRead(t.Context(), http_range.Range{Length: 1})
if !errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("verified rejection error = %v, want TemporaryCapacity", err)
}
if requests.Load() != callbackRequestAttempts {
t.Fatalf("requests = %d, want %d", requests.Load(), callbackRequestAttempts)
}
if strings.Contains(err.Error(), "secret") {
t.Fatal("capacity error leaked the signed callback URL")
}
permit, acquireErr := registration.acquire(t.Context())
if acquireErr != nil {
t.Fatalf("capacity retries leaked admission: %v", acquireErr)
}
permit.release()
requests.Store(0)
permanent := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"code":"RequestDeniedByCallback","message":"denied"}`)
}))
defer permanent.Close()
_, err = d.callbackRangeReader(permanent.URL+"?token=secret", 1).RangeRead(t.Context(), http_range.Range{Length: 1})
if errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("permanent 403 error = %v, must not be TemporaryCapacity", err)
}
if requests.Load() != 1 {
t.Fatalf("permanent 403 requests = %d, want 1", requests.Load())
}
if strings.Contains(err.Error(), "secret") {
t.Fatal("permanent error leaked the signed callback URL")
}
}
type countingReadCloser struct {
reader io.Reader
closed atomic.Int32
}
func (r *countingReadCloser) Read(p []byte) (int, error) { return r.reader.Read(p) }
func (r *countingReadCloser) Close() error {
r.closed.Add(1)
return nil
}
func TestCallbackBodyReleasesExactlyOnce(t *testing.T) {
underlying := &countingReadCloser{reader: strings.NewReader("x")}
var released atomic.Int32
body := newCallbackBody(t.Context(), underlying, func() { released.Add(1) })
_, _ = io.ReadAll(body)
if err := body.Close(); err != nil {
t.Fatal(err)
}
if err := body.Close(); err != nil {
t.Fatal(err)
}
if underlying.closed.Load() != 1 || released.Load() != 1 {
t.Fatalf("close count = %d, release count = %d; want 1, 1", underlying.closed.Load(), released.Load())
}
}
type failingReadCloser struct {
closed atomic.Int32
}
func (*failingReadCloser) Read([]byte) (int, error) { return 0, errors.New("read failed") }
func (r *failingReadCloser) Close() error {
r.closed.Add(1)
return nil
}
func TestCallbackBodyReadFailureReleasesPermit(t *testing.T) {
underlying := &failingReadCloser{}
var released atomic.Int32
body := newCallbackBody(t.Context(), underlying, func() { released.Add(1) })
if _, err := body.Read(make([]byte, 1)); err == nil {
t.Fatal("read unexpectedly succeeded")
}
if underlying.closed.Load() != 1 || released.Load() != 1 {
t.Fatalf("close count = %d, release count = %d; want 1, 1", underlying.closed.Load(), released.Load())
}
}
func TestCallbackBodyCancellationReleasesPermit(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
underlying := &countingReadCloser{reader: strings.NewReader("x")}
released := make(chan struct{}, 1)
_ = newCallbackBody(ctx, underlying, func() { released <- struct{}{} })
cancel()
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("context cancellation did not release callback admission")
}
if underlying.closed.Load() != 1 {
t.Fatalf("underlying close count = %d, want 1", underlying.closed.Load())
}
}
+18 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
@@ -22,8 +23,9 @@ type AliyundriveOpen struct {
DriveId string
limiter *limiter
ref *AliyundriveOpen
limiter *limiter
ref *AliyundriveOpen
callback *callbackRegistration
}
func (d *AliyundriveOpen) Config() driver.Config {
@@ -35,6 +37,7 @@ func (d *AliyundriveOpen) GetAddition() driver.Additional {
}
func (d *AliyundriveOpen) Init(ctx context.Context) error {
d.CallbackConcurrency = normalizeCallbackConcurrency(d.CallbackConcurrency)
d.limiter = getLimiterForUser(globalLimiterUserID) // First create a globally shared limiter to limit the initial requests.
if d.LIVPDownloadFormat == "" {
d.LIVPDownloadFormat = "jpeg"
@@ -52,6 +55,7 @@ func (d *AliyundriveOpen) Init(ctx context.Context) error {
userid := utils.Json.Get(res, "user_id").ToString()
d.limiter.free()
d.limiter = getLimiterForUser(userid) // Allocate a corresponding limiter for each user.
d.callback = registerCallbackLimiter(userid, d.CallbackConcurrency)
return nil
}
@@ -65,6 +69,10 @@ func (d *AliyundriveOpen) InitReference(storage driver.Driver) error {
}
func (d *AliyundriveOpen) Drop(ctx context.Context) error {
if d.callback != nil {
d.callback.unregister()
d.callback = nil
}
d.limiter.free()
d.limiter = nil
d.ref = nil
@@ -119,10 +127,16 @@ func (d *AliyundriveOpen) Link(ctx context.Context, file model.Obj, args model.L
url = utils.Json.Get(res, "streamsUrl", d.LIVPDownloadFormat).ToString()
}
exp := time.Minute
return &model.Link{
link := &model.Link{
URL: url,
Expiration: &exp,
}, nil
}
if args.Redirect {
return link, nil
}
link.URL = ""
link.RangeReader = stream.RateLimitRangeReaderFunc(d.callbackRangeReader(url, file.GetSize()))
return link, nil
}
func (d *AliyundriveOpen) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
+14 -13
View File
@@ -8,19 +8,20 @@ import (
type Addition struct {
DriveType string `json:"drive_type" type:"select" options:"default,resource,backup" default:"resource"`
driver.RootID
RefreshToken string `json:"refresh_token" required:"true"`
OrderBy string `json:"order_by" type:"select" options:"name,size,updated_at,created_at"`
OrderDirection string `json:"order_direction" type:"select" options:"ASC,DESC"`
UseOnlineAPI bool `json:"use_online_api" default:"true"`
AlipanType string `json:"alipan_type" required:"true" type:"select" default:"default" options:"default,alipanTV"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/alicloud/renewapi"`
ClientID string `json:"client_id" help:"Keep it empty if you don't have one"`
ClientSecret string `json:"client_secret" help:"Keep it empty if you don't have one"`
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
RapidUpload bool `json:"rapid_upload" help:"If you enable this option, the file will be uploaded to the server first, so the progress will be incorrect"`
InternalUpload bool `json:"internal_upload" help:"If you are using Aliyun ECS is located in Beijing, you can turn it on to boost the upload speed"`
LIVPDownloadFormat string `json:"livp_download_format" type:"select" options:"jpeg,mov" default:"jpeg"`
AccessToken string
RefreshToken string `json:"refresh_token" required:"true"`
OrderBy string `json:"order_by" type:"select" options:"name,size,updated_at,created_at"`
OrderDirection string `json:"order_direction" type:"select" options:"ASC,DESC"`
UseOnlineAPI bool `json:"use_online_api" default:"true"`
AlipanType string `json:"alipan_type" required:"true" type:"select" default:"default" options:"default,alipanTV"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/alicloud/renewapi"`
ClientID string `json:"client_id" help:"Keep it empty if you don't have one"`
ClientSecret string `json:"client_secret" help:"Keep it empty if you don't have one"`
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
RapidUpload bool `json:"rapid_upload" help:"If you enable this option, the file will be uploaded to the server first, so the progress will be incorrect"`
InternalUpload bool `json:"internal_upload" help:"If you are using Aliyun ECS is located in Beijing, you can turn it on to boost the upload speed"`
LIVPDownloadFormat string `json:"livp_download_format" type:"select" options:"jpeg,mov" default:"jpeg"`
CallbackConcurrency int `json:"callback_concurrency" type:"number" default:"1" help:"Maximum active proxied downloads shared by Aliyun user"`
AccessToken string
}
var config = driver.Config{
+2
View File
@@ -22,6 +22,7 @@ import (
_ "github.com/OpenListTeam/OpenList/v4/drivers/azure_blob"
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_netdisk"
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo"
_ "github.com/OpenListTeam/OpenList/v4/drivers/bunny_storage"
_ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing"
_ "github.com/OpenListTeam/OpenList/v4/drivers/chunk"
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed"
@@ -41,6 +42,7 @@ import (
_ "github.com/OpenListTeam/OpenList/v4/drivers/github_releases"
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_drive"
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_photo"
_ "github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud"
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud_open"
_ "github.com/OpenListTeam/OpenList/v4/drivers/ilanzou"
+24
View File
@@ -2,6 +2,7 @@ package azure_blob
import (
"context"
"encoding/hex"
"fmt"
"io"
"path"
@@ -12,10 +13,13 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
log "github.com/sirupsen/logrus"
)
// Azure Blob Storage based on the blob APIs
@@ -283,6 +287,26 @@ func (d *AzureBlob) Put(ctx context.Context, dstDir model.Obj, stream model.File
// Determine optimal upload options based on file size
options := optimizedUploadOptions(stream.GetSize())
// Preserve the file content type for direct Azure Blob downloads.
contentType := stream.GetMimetype()
if contentType == "" {
contentType = "application/octet-stream"
}
options.HTTPHeaders = &blob.HTTPHeaders{
BlobContentType: &contentType,
}
// Preserve the MD5 checksum when it is available.
md5Hex := stream.GetHash().GetHash(utils.MD5)
if md5Hex != "" {
md5, err := hex.DecodeString(md5Hex)
if err == nil && len(md5) == 16 {
options.HTTPHeaders.BlobContentMD5 = md5
} else {
log.Warnf("Invalid MD5 hash: %s, error: %v", md5Hex, err)
}
}
// Track upload progress
progressTracker := &progressTracker{
total: stream.GetSize(),
+226
View File
@@ -0,0 +1,226 @@
package bunny_storage
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
stdpath "path"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/go-resty/resty/v2"
)
type BunnyStorage struct {
model.Storage
Addition
client *resty.Client
endpoint *url.URL
cdnBase *url.URL
}
func (d *BunnyStorage) Config() driver.Config {
cfg := config
if d.StorageZoneName != "" && d.CDNBaseURL == "" {
cfg.OnlyProxy = true
cfg.PreferProxy = true
}
if d.CDNTokenKey != "" && d.CDNTokenIncludeIP {
cfg.LinkCacheMode = driver.LinkCacheIP
}
return cfg
}
func (d *BunnyStorage) GetAddition() driver.Additional {
return &d.Addition
}
func (d *BunnyStorage) Init(ctx context.Context) error {
if d.RootFolderPath == "" {
d.RootFolderPath = "/"
}
if d.Endpoint == "" {
d.Endpoint = defaultEndpoint
}
if d.SignURLExpire <= 0 {
d.SignURLExpire = 4
}
if d.CDNTokenMethod == "" {
d.CDNTokenMethod = cdnTokenMethodSHA256
}
endpoint, err := normalizeBaseURL(d.Endpoint, defaultEndpoint)
if err != nil {
return fmt.Errorf("invalid endpoint: %w", err)
}
d.endpoint = endpoint
if d.CDNBaseURL != "" {
cdnBase, err := normalizeBaseURL(d.CDNBaseURL, "")
if err != nil {
return fmt.Errorf("invalid cdn_base_url: %w", err)
}
d.cdnBase = cdnBase
}
d.client = base.RestyClient
if d.client == nil {
d.client = base.NewRestyClient()
}
return nil
}
func (d *BunnyStorage) Drop(ctx context.Context) error {
return nil
}
func (d *BunnyStorage) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
var items []bunnyObject
resp, err := d.authRequest().
SetContext(ctx).
SetResult(&items).
Get(d.storageURL(dir.GetPath(), true))
if err != nil {
return nil, err
}
if err := d.handleResponseError(resp); err != nil {
return nil, err
}
result := make([]model.Obj, 0, len(items))
placeholder := d.placeholderName()
for _, item := range items {
if item.ObjectName == "" {
continue
}
if !args.S3ShowPlaceholder && !item.IsDirectory && item.ObjectName == placeholder {
continue
}
result = append(result, d.toObj(dir.GetPath(), item))
}
return result, nil
}
func (d *BunnyStorage) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if file.IsDir() {
return nil, errs.NotFile
}
cacheTTL := time.Duration(0)
if d.cdnBase != nil {
linkURL := d.cdnURL(d.cdnObjectPath(file.GetPath()))
link := &model.Link{
URL: linkURL,
ContentLength: file.GetSize(),
Expiration: &cacheTTL,
}
if d.CDNTokenKey != "" {
signedURL, _, err := d.signCDNURL(linkURL, args.IP)
if err != nil {
return nil, err
}
link.URL = signedURL
}
return link, nil
}
return &model.Link{
URL: d.storageURL(file.GetPath(), false),
Header: http.Header{"AccessKey": []string{d.AccessKey}},
ContentLength: file.GetSize(),
Expiration: &cacheTTL,
}, nil
}
func (d *BunnyStorage) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
dirPath := stdpath.Join(parentDir.GetPath(), dirName)
placeholderPath := stdpath.Join(dirPath, d.placeholderName())
if err := d.putReader(ctx, placeholderPath, bytes.NewReader(nil), 0, "application/octet-stream", nil); err != nil {
return nil, err
}
now := time.Now()
return &model.Object{
Path: dirPath,
Name: dirName,
Modified: now,
Ctime: now,
IsFolder: true,
}, nil
}
func (d *BunnyStorage) Remove(ctx context.Context, obj model.Obj) error {
resp, err := d.authRequest().
SetContext(ctx).
Delete(d.storageURL(obj.GetPath(), obj.IsDir()))
if err != nil {
return err
}
return d.handleResponseError(resp)
}
func (d *BunnyStorage) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
if up == nil {
up = func(float64) {}
}
dstPath := stdpath.Join(dstDir.GetPath(), file.GetName())
err := d.putReader(ctx, dstPath, driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
Reader: file,
UpdateProgress: up,
}), file.GetSize(), file.GetMimetype(), nil)
if err != nil {
return nil, err
}
now := time.Now()
return &model.Object{
Path: dstPath,
Name: file.GetName(),
Size: file.GetSize(),
Modified: now,
Ctime: now,
}, nil
}
func (d *BunnyStorage) putReader(ctx context.Context, path string, body any, size int64, contentType string, extraHeaders http.Header) error {
if contentType == "" {
contentType = "application/octet-stream"
}
req := d.authRequest().
SetContext(ctx).
SetBody(body).
SetHeader("Content-Type", contentType)
if size >= 0 {
req.SetHeader("Content-Length", fmt.Sprint(size))
}
for key, values := range extraHeaders {
for _, value := range values {
req.SetHeader(key, value)
}
}
resp, err := req.Put(d.storageURL(path, false))
if err != nil {
return err
}
return d.handleResponseError(resp)
}
func (d *BunnyStorage) Get(ctx context.Context, path string) (model.Obj, error) {
fullPath := stdpath.Join(d.GetRootPath(), path)
parentPath, name := stdpath.Split(fullPath)
parentPath = strings.TrimSuffix(parentPath, "/")
if parentPath == "" {
parentPath = "/"
}
objs, err := d.List(ctx, &model.Object{Path: parentPath, IsFolder: true}, model.ListArgs{S3ShowPlaceholder: true})
if err != nil {
return nil, err
}
for _, obj := range objs {
if obj.GetName() == name {
return obj, nil
}
}
return nil, errs.ObjectNotFound
}
var _ driver.Driver = (*BunnyStorage)(nil)
var _ driver.Getter = (*BunnyStorage)(nil)
+32
View File
@@ -0,0 +1,32 @@
package bunny_storage
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
type Addition struct {
driver.RootPath
StorageZoneName string `json:"storage_zone_name" required:"true"`
AccessKey string `json:"access_key" required:"true"`
Endpoint string `json:"endpoint" required:"true" default:"storage.bunnycdn.com"`
CDNBaseURL string `json:"cdn_base_url"`
CDNTokenKey string `json:"cdn_token_key"`
CDNTokenMethod string `json:"cdn_token_method" type:"select" options:"sha256,hmac_sha256" default:"sha256"`
CDNTokenIncludeIP bool `json:"cdn_token_include_ip" default:"false"`
SignURLExpire int `json:"sign_url_expire" type:"number" default:"4"`
Placeholder string `json:"placeholder" default:".openlist"`
}
var config = driver.Config{
Name: "Bunny Storage",
LocalSort: true,
DefaultRoot: "/",
CheckStatus: true,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &BunnyStorage{}
})
}
+27
View File
@@ -0,0 +1,27 @@
package bunny_storage
import "time"
type bunnyObject struct {
Guid string `json:"Guid"`
StorageZoneName string `json:"StorageZoneName"`
Path string `json:"Path"`
ObjectName string `json:"ObjectName"`
Length int64 `json:"Length"`
LastChanged string `json:"LastChanged"`
IsDirectory bool `json:"IsDirectory"`
ServerID int `json:"ServerId"`
UserID string `json:"UserId"`
DateCreated string `json:"DateCreated"`
StorageZoneID int64 `json:"StorageZoneId"`
}
type apiError struct {
HttpCode int `json:"HttpCode"`
Message string `json:"Message"`
}
type parsedTimes struct {
modified time.Time
created time.Time
}
+275
View File
@@ -0,0 +1,275 @@
package bunny_storage
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
stdpath "path"
"sort"
"strconv"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/go-resty/resty/v2"
)
const (
defaultEndpoint = "storage.bunnycdn.com"
defaultPlaceholder = ".openlist"
cdnTokenMethodSHA256 = "sha256"
cdnTokenMethodHMACSHA256 = "hmac_sha256"
)
func normalizeBaseURL(raw string, fallback string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = fallback
}
if raw == "" {
return nil, fmt.Errorf("empty url")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
if u.Host == "" {
return nil, fmt.Errorf("invalid url: %s", raw)
}
u.Path = strings.TrimRight(u.Path, "/")
return u, nil
}
func cleanObjectPath(path string) string {
if path == "" {
return "/"
}
return stdpath.Clean("/" + strings.TrimPrefix(path, "/"))
}
func stripObjectPathPrefix(path string, prefix string) (string, bool) {
path = cleanObjectPath(path)
prefix = cleanObjectPath(prefix)
if prefix == "/" {
return path, false
}
if path == prefix {
return "/", true
}
if strings.HasPrefix(path, prefix+"/") {
return cleanObjectPath(strings.TrimPrefix(path, prefix)), true
}
return path, false
}
func isObjectPathOrChild(path string, parent string) bool {
path = cleanObjectPath(path)
parent = cleanObjectPath(parent)
return path == parent || strings.HasPrefix(path, parent+"/")
}
func trimCDNBasePath(path string, mountPath string) string {
path = cleanObjectPath(path)
if path == "/" {
return ""
}
if stripped, ok := stripObjectPathPrefix(path, mountPath); ok {
path = stripped
}
if path == "/" {
return ""
}
return strings.TrimRight(path, "/")
}
func (d *BunnyStorage) cdnObjectPath(path string) string {
objectPath := cleanObjectPath(path)
if stripped, ok := stripObjectPathPrefix(objectPath, d.GetStorage().MountPath); ok {
objectPath = stripped
}
rootPath := cleanObjectPath(d.GetRootPath())
if rootPath != "/" && !isObjectPathOrChild(objectPath, rootPath) {
objectPath = cleanObjectPath(stdpath.Join(rootPath, objectPath))
}
return objectPath
}
func (d *BunnyStorage) placeholderName() string {
if d.Placeholder == "" {
return defaultPlaceholder
}
return d.Placeholder
}
func (d *BunnyStorage) storageURL(path string, dir bool) string {
u := *d.endpoint
cleanPath := cleanObjectPath(path)
zone := strings.Trim(d.StorageZoneName, "/")
if cleanPath == "/" {
u.Path = "/" + zone + "/"
return u.String()
}
u.Path = "/" + zone + "/" + strings.TrimPrefix(cleanPath, "/")
if dir && !strings.HasSuffix(u.Path, "/") {
u.Path += "/"
}
return u.String()
}
func (d *BunnyStorage) cdnURL(path string) string {
u := *d.cdnBase
cleanPath := cleanObjectPath(path)
basePath := trimCDNBasePath(u.Path, d.GetStorage().MountPath)
if cleanPath == "/" {
if basePath == "" {
u.Path = "/"
} else {
u.Path = basePath + "/"
}
return u.String()
}
u.Path = basePath + "/" + strings.TrimPrefix(cleanPath, "/")
return u.String()
}
func (d *BunnyStorage) authRequest() *resty.Request {
return d.client.R().SetHeader("AccessKey", d.AccessKey)
}
func (d *BunnyStorage) handleResponseError(resp *resty.Response) error {
if resp == nil {
return fmt.Errorf("empty response")
}
if resp.StatusCode() >= http.StatusOK && resp.StatusCode() < http.StatusMultipleChoices {
return nil
}
message := strings.TrimSpace(resp.String())
var apiErrors []apiError
if err := json.Unmarshal(resp.Body(), &apiErrors); err == nil && len(apiErrors) > 0 && apiErrors[0].Message != "" {
message = apiErrors[0].Message
}
switch resp.StatusCode() {
case http.StatusUnauthorized, http.StatusForbidden:
return errs.NewErr(errs.PermissionDenied, "bunny storage request failed: %s", message)
case http.StatusNotFound:
return errs.NewErr(errs.ObjectNotFound, "bunny storage request failed: %s", message)
default:
return fmt.Errorf("bunny storage request failed: %s: %s", resp.Status(), message)
}
}
func (d *BunnyStorage) parseTimes(item bunnyObject) parsedTimes {
return parsedTimes{
modified: parseBunnyTime(item.LastChanged, d.Modified),
created: parseBunnyTime(item.DateCreated, time.Time{}),
}
}
func parseBunnyTime(value string, fallback time.Time) time.Time {
if value == "" {
return fallback
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t
}
if t, err := time.Parse("2006-01-02T15:04:05.999999999", value); err == nil {
return t
}
if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil {
return t
}
return fallback
}
func (d *BunnyStorage) toObj(parentPath string, item bunnyObject) model.Obj {
times := d.parseTimes(item)
return &model.Object{
ID: item.Guid,
Path: stdpath.Join(parentPath, item.ObjectName),
Name: item.ObjectName,
Size: item.Length,
Modified: times.modified,
Ctime: times.created,
IsFolder: item.IsDirectory,
}
}
func canonicalQuery(values url.Values) (string, error) {
keys := make([]string, 0, len(values))
for key := range values {
if key == "token" || key == "expires" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
vals := values[key]
if len(vals) > 1 {
return "", fmt.Errorf("duplicate query parameter %q is not supported", key)
}
value := ""
if len(vals) == 1 {
value = vals[0]
}
parts = append(parts, key+"="+value)
}
return strings.Join(parts, "&"), nil
}
func (d *BunnyStorage) signCDNURL(rawURL string, clientIP string) (string, time.Duration, error) {
return d.signCDNURLAt(rawURL, clientIP, time.Now())
}
func (d *BunnyStorage) signCDNURLAt(rawURL string, clientIP string, now time.Time) (string, time.Duration, error) {
expire := time.Hour * time.Duration(d.SignURLExpire)
if expire <= 0 {
expire = 4 * time.Hour
}
expires := now.Add(expire).Unix()
u, err := url.Parse(rawURL)
if err != nil {
return "", 0, err
}
query := u.Query()
parameterData, err := canonicalQuery(query)
if err != nil {
return "", 0, err
}
signaturePath, err := url.PathUnescape(u.EscapedPath())
if err != nil {
signaturePath = u.Path
}
if !d.CDNTokenIncludeIP {
clientIP = ""
}
token := d.signCDNToken(signaturePath, strconv.FormatInt(expires, 10), parameterData, clientIP)
query.Set("token", token)
query.Set("expires", strconv.FormatInt(expires, 10))
u.RawQuery = query.Encode()
return u.String(), expire, nil
}
func (d *BunnyStorage) signCDNToken(signaturePath string, expires string, parameterData string, clientIP string) string {
switch strings.ToLower(strings.TrimSpace(d.CDNTokenMethod)) {
case cdnTokenMethodHMACSHA256:
message := signaturePath + expires + parameterData + clientIP
mac := hmac.New(sha256.New, []byte(d.CDNTokenKey))
_, _ = mac.Write([]byte(message))
return "HS256-" + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
default:
hashableBase := d.CDNTokenKey + signaturePath + expires + parameterData + clientIP
sum := sha256.Sum256([]byte(hashableBase))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
}
+255
View File
@@ -0,0 +1,255 @@
package bunny_storage
import (
"context"
"net/url"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
func TestStorageURL(t *testing.T) {
endpoint, err := normalizeBaseURL("ny.storage.bunnycdn.com", defaultEndpoint)
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{
Addition: Addition{
StorageZoneName: "my-zone",
},
endpoint: endpoint,
}
if got, want := driver.storageURL("/", true), "https://ny.storage.bunnycdn.com/my-zone/"; got != want {
t.Fatalf("root list url = %q, want %q", got, want)
}
if got, want := driver.storageURL("/dir/a file.txt", false), "https://ny.storage.bunnycdn.com/my-zone/dir/a%20file.txt"; got != want {
t.Fatalf("file url = %q, want %q", got, want)
}
if got, want := driver.storageURL("/dir", true), "https://ny.storage.bunnycdn.com/my-zone/dir/"; got != want {
t.Fatalf("dir url = %q, want %q", got, want)
}
}
func TestCDNURLWithBasePath(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.example.com/prefix/", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{cdnBase: cdnBase}
if got, want := driver.cdnURL("/dir/a file.txt"), "https://cdn.example.com/prefix/dir/a%20file.txt"; got != want {
t.Fatalf("cdn url = %q, want %q", got, want)
}
}
func TestCDNURLUsesObjectPathWithoutMountPath(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.firmant.me", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{
Storage: model.Storage{MountPath: "/BS"},
cdnBase: cdnBase,
}
if got, want := driver.cdnURL(driver.cdnObjectPath("/BS/Video")), "https://cdn.firmant.me/Video"; got != want {
t.Fatalf("cdn url = %q, want %q", got, want)
}
}
func TestCDNURLDropsMountPathFromBaseURL(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.firmant.me/BS/", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{
Storage: model.Storage{MountPath: "/BS"},
cdnBase: cdnBase,
}
if got, want := driver.cdnURL(driver.cdnObjectPath("/Video")), "https://cdn.firmant.me/Video"; got != want {
t.Fatalf("cdn url = %q, want %q", got, want)
}
}
func TestCDNObjectPathKeepsRootFolderPath(t *testing.T) {
driver := &BunnyStorage{}
driver.MountPath = "/BS"
driver.RootFolderPath = "/library"
if got, want := driver.cdnObjectPath("/BS/Video"), "/library/Video"; got != want {
t.Fatalf("cdn object path = %q, want %q", got, want)
}
if got, want := driver.cdnObjectPath("/library/Video"), "/library/Video"; got != want {
t.Fatalf("cdn object path = %q, want %q", got, want)
}
}
func TestLinkDisablesLongLivedCache(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.example.com", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{cdnBase: cdnBase}
link, err := driver.Link(context.Background(), &model.Object{
Path: "/video.mp4",
Name: "video.mp4",
Size: 123,
}, model.LinkArgs{})
if err != nil {
t.Fatal(err)
}
if link.Expiration == nil || *link.Expiration != 0 {
t.Fatalf("link expiration = %v, want immediate cache expiration", link.Expiration)
}
}
func TestSignCDNURL(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
CDNTokenIncludeIP: true,
SignURLExpire: 1,
},
}
signed, expire, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0))
if err != nil {
t.Fatal(err)
}
if expire <= 0 {
t.Fatal("expected positive expiration")
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
token := parsed.Query().Get("token")
if want := "FxSpFem88zFo6uHFziwTuoMQTgDaD2PEn5n1zTMBUBI"; token != want {
t.Fatalf("token = %q, want %q", token, want)
}
if parsed.Query().Get("expires") != "1700003600" {
t.Fatalf("expires = %q, want 1700003600", parsed.Query().Get("expires"))
}
if parsed.Query().Get("quality") != "high" {
t.Fatal("expected existing query parameters to be preserved")
}
}
func TestSignCDNURLSupportsHMACSHA256(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
CDNTokenMethod: cdnTokenMethodHMACSHA256,
CDNTokenIncludeIP: true,
SignURLExpire: 1,
},
}
signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0))
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
if got, want := parsed.Query().Get("token"), "HS256-sdrSSJE2JVwhSk2AoDUrmTV1muH6R5UHpZVcVfHeNxg"; got != want {
t.Fatalf("token = %q, want %q", got, want)
}
}
func TestSignCDNURLUsesDecodedPathForSHA256(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
SignURLExpire: 1,
},
}
signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/%E8%A7%86%E9%A2%91/%5Ba%20b%5D.mp4", "", time.Unix(1700000000, 0))
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
if got, want := parsed.Query().Get("token"), "yq1evD7klw0e3DjCbv8dJptbW4S4JwVW3GKLnxfeKGM"; got != want {
t.Fatalf("token = %q, want %q", got, want)
}
}
func TestSignCDNURLTreatsPlusAsLiteralPathCharacter(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
SignURLExpire: 1,
},
}
now := time.Unix(1700000000, 0)
literal, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a+b.mp4", "", now)
if err != nil {
t.Fatal(err)
}
encoded, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a%2Bb.mp4", "", now)
if err != nil {
t.Fatal(err)
}
space, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a%20b.mp4", "", now)
if err != nil {
t.Fatal(err)
}
literalURL, err := url.Parse(literal)
if err != nil {
t.Fatal(err)
}
encodedURL, err := url.Parse(encoded)
if err != nil {
t.Fatal(err)
}
spaceURL, err := url.Parse(space)
if err != nil {
t.Fatal(err)
}
if got, want := literalURL.Query().Get("token"), encodedURL.Query().Get("token"); got != want {
t.Fatalf("literal plus token = %q, encoded plus token = %q", got, want)
}
if got, notWant := literalURL.Query().Get("token"), spaceURL.Query().Get("token"); got == notWant {
t.Fatalf("literal plus token = %q, space token should differ", got)
}
}
func TestSignCDNURLRejectsDuplicateQueryParameters(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
SignURLExpire: 1,
},
}
_, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high&quality=low", "", time.Unix(1700000000, 0))
if err == nil {
t.Fatal("expected duplicate query parameters to be rejected")
}
if got, want := err.Error(), `duplicate query parameter "quality" is not supported`; got != want {
t.Fatalf("error = %q, want %q", got, want)
}
}
func TestParseBunnyTimeSupportsFractionalSecondsWithoutTimezone(t *testing.T) {
fallback := time.Unix(1, 0)
got := parseBunnyTime("2023-03-21T13:38:31.693", fallback)
want := time.Date(2023, 3, 21, 13, 38, 31, 693000000, time.UTC)
if !got.Equal(want) {
t.Fatalf("parsed time = %s, want %s", got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano))
}
}
func TestConfigProxyMode(t *testing.T) {
registeredConfig := (&BunnyStorage{}).Config()
if registeredConfig.OnlyProxy {
t.Fatal("driver registration config should allow users to choose proxy policy")
}
withoutCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone"}}).Config()
if !withoutCDN.OnlyProxy {
t.Fatal("storage API links require AccessKey headers and should be proxied without CDN")
}
withCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone", CDNBaseURL: "https://zone.b-cdn.net"}}).Config()
if withCDN.OnlyProxy {
t.Fatal("CDN links should be allowed to redirect directly")
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ func (d *Crypt) Link(ctx context.Context, file model.Obj, _ model.LinkArgs) (*mo
rrf, err := stream.GetRangeReaderFromLink(remoteSize, remoteLink)
if err != nil {
_ = remoteLink.Close()
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support encrytion")
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support encryption")
}
mu := &sync.Mutex{}
+2 -2
View File
@@ -56,8 +56,8 @@ func (d *Emby) Init(ctx context.Context) error {
return nil
}
if strings.TrimSpace(d.Username) == "" || strings.TrimSpace(d.Password) == "" {
return fmt.Errorf("please provide api_key+user_id or username+password")
if strings.TrimSpace(d.Username) == "" {
return fmt.Errorf("please provide api_key+user_id or username(+password)")
}
if err := d.login(ctx); err != nil {
+10 -8
View File
@@ -154,15 +154,17 @@ func (d *Github) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
}
}
return ret, nil
} else {
ret := make([]model.Obj, 0, len(obj.Entries))
for _, entry := range obj.Entries {
if entry.Name != ".gitkeep" {
ret = append(ret, entry.toModelObj())
}
}
return ret, nil
}
ret := make([]model.Obj, 0, len(obj.Entries))
for _, entry := range obj.Entries {
if entry.Name == ".gitkeep" {
continue
}
ret = append(ret, entry.toModelObj())
}
d.fetchAccurateModifiedTimes(ctx, dir.GetPath(), ret)
return ret, nil
}
func (d *Github) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
+18 -17
View File
@@ -7,23 +7,24 @@ import (
type Addition struct {
driver.RootPath
Token string `json:"token" type:"string" required:"true"`
Owner string `json:"owner" type:"string" required:"true"`
Repo string `json:"repo" type:"string" required:"true"`
Ref string `json:"ref" type:"string" help:"A branch, a tag or a commit SHA, main branch by default."`
GitHubProxy string `json:"gh_proxy" type:"string" help:"GitHub proxy, e.g. https://ghproxy.net/raw.githubusercontent.com or https://gh-proxy.com/raw.githubusercontent.com"`
GPGPrivateKey string `json:"gpg_private_key" type:"text"`
GPGKeyPassphrase string `json:"gpg_key_passphrase" type:"string"`
CommitterName string `json:"committer_name" type:"string"`
CommitterEmail string `json:"committer_email" type:"string"`
AuthorName string `json:"author_name" type:"string"`
AuthorEmail string `json:"author_email" type:"string"`
MkdirCommitMsg string `json:"mkdir_commit_message" type:"text" default:"{{.UserName}} mkdir {{.ObjPath}}"`
DeleteCommitMsg string `json:"delete_commit_message" type:"text" default:"{{.UserName}} remove {{.ObjPath}}"`
PutCommitMsg string `json:"put_commit_message" type:"text" default:"{{.UserName}} upload {{.ObjPath}}"`
RenameCommitMsg string `json:"rename_commit_message" type:"text" default:"{{.UserName}} rename {{.ObjPath}} to {{.TargetName}}"`
CopyCommitMsg string `json:"copy_commit_message" type:"text" default:"{{.UserName}} copy {{.ObjPath}} to {{.TargetPath}}"`
MoveCommitMsg string `json:"move_commit_message" type:"text" default:"{{.UserName}} move {{.ObjPath}} to {{.TargetPath}}"`
Token string `json:"token" type:"string" required:"true"`
Owner string `json:"owner" type:"string" required:"true"`
Repo string `json:"repo" type:"string" required:"true"`
Ref string `json:"ref" type:"string" help:"A branch, a tag or a commit SHA, main branch by default."`
AccurateModifiedTime bool `json:"accurate_modified_time" type:"bool" default:"false" help:"Best-effort accurate modified time for small directory listings. Default disabled. Adds one GitHub GraphQL request and falls back to legacy zero-time values on failure."`
GitHubProxy string `json:"gh_proxy" type:"string" help:"GitHub proxy, e.g. https://ghproxy.net/raw.githubusercontent.com or https://gh-proxy.com/raw.githubusercontent.com"`
GPGPrivateKey string `json:"gpg_private_key" type:"text"`
GPGKeyPassphrase string `json:"gpg_key_passphrase" type:"string"`
CommitterName string `json:"committer_name" type:"string"`
CommitterEmail string `json:"committer_email" type:"string"`
AuthorName string `json:"author_name" type:"string"`
AuthorEmail string `json:"author_email" type:"string"`
MkdirCommitMsg string `json:"mkdir_commit_message" type:"text" default:"{{.UserName}} mkdir {{.ObjPath}}"`
DeleteCommitMsg string `json:"delete_commit_message" type:"text" default:"{{.UserName}} remove {{.ObjPath}}"`
PutCommitMsg string `json:"put_commit_message" type:"text" default:"{{.UserName}} upload {{.ObjPath}}"`
RenameCommitMsg string `json:"rename_commit_message" type:"text" default:"{{.UserName}} rename {{.ObjPath}} to {{.TargetName}}"`
CopyCommitMsg string `json:"copy_commit_message" type:"text" default:"{{.UserName}} copy {{.ObjPath}} to {{.TargetPath}}"`
MoveCommitMsg string `json:"move_commit_message" type:"text" default:"{{.UserName}} move {{.ObjPath}} to {{.TargetPath}}"`
}
var config = driver.Config{
+106
View File
@@ -0,0 +1,106 @@
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
log "github.com/sirupsen/logrus"
)
const (
mtimeMaxEntries = 200
githubGraphQLEndpoint = "https://api.github.com/graphql"
)
var githubZeroTime = time.Unix(0, 0)
type graphQLHistory struct {
Nodes []struct {
CommittedDate time.Time `json:"committedDate"`
} `json:"nodes"`
}
type graphQLMtimeResponse struct {
Data struct {
Repository struct {
Commit map[string]graphQLHistory `json:"commit"`
} `json:"repository"`
} `json:"data"`
Errors []struct{} `json:"errors"`
}
func quoteGraphQLString(value string) string {
quoted, _ := json.Marshal(value)
return string(quoted)
}
func buildMtimeQuery(owner, repo, ref string, objs []model.Obj) string {
histories := make([]string, 0, len(objs))
for i, obj := range objs {
path := strings.TrimPrefix(obj.GetPath(), "/")
histories = append(histories, fmt.Sprintf(`p%d: history(first: 1, path: %s) { nodes { committedDate } }`, i, quoteGraphQLString(path)))
}
return fmt.Sprintf(`query {
repository(owner: %s, name: %s) {
commit: object(expression: %s) {
... on Commit {
%s
}
}
}
}`,
quoteGraphQLString(owner),
quoteGraphQLString(repo),
quoteGraphQLString(ref+"^{commit}"),
strings.Join(histories, "\n\t\t\t\t"),
)
}
func (d *Github) fetchAccurateModifiedTimes(ctx context.Context, dirPath string, objs []model.Obj) {
token := strings.TrimSpace(d.Token)
if !d.AccurateModifiedTime || token == "" || len(objs) == 0 || len(objs) > mtimeMaxEntries {
return
}
res, err := d.client.R().
SetContext(ctx).
SetHeader("Accept", "application/vnd.github+json").
SetHeader("Authorization", "Bearer "+token).
SetBody(map[string]string{"query": buildMtimeQuery(d.Owner, d.Repo, d.Ref, objs)}).
Post(githubGraphQLEndpoint)
if err != nil {
log.WithError(err).Warnf("github accurate mtime failed for %s: transport", dirPath)
return
}
if res.StatusCode() != http.StatusOK {
log.Warnf("github accurate mtime failed for %s: http_%d", dirPath, res.StatusCode())
return
}
var response graphQLMtimeResponse
if err := utils.Json.Unmarshal(res.Body(), &response); err != nil {
log.WithError(err).Warnf("github accurate mtime failed for %s: graphql", dirPath)
return
}
if len(response.Errors) > 0 || response.Data.Repository.Commit == nil {
log.Warnf("github accurate mtime failed for %s: graphql", dirPath)
return
}
for i, obj := range objs {
history := response.Data.Repository.Commit[fmt.Sprintf("p%d", i)]
if len(history.Nodes) == 0 {
continue
}
if raw, ok := obj.(*model.Object); ok {
raw.Modified = history.Nodes[0].CommittedDate
}
}
}
+378
View File
@@ -0,0 +1,378 @@
package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
odriver "github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/go-resty/resty/v2"
)
func TestDriverInfoIncludesAccurateModifiedTimeDefault(t *testing.T) {
info := op.GetDriverInfoMap()["GitHub API"]
for _, item := range info.Additional {
if item.Name != "accurate_modified_time" {
continue
}
if item.Default != "false" {
t.Fatalf("unexpected default: %q", item.Default)
}
return
}
t.Fatal("accurate_modified_time item not registered")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func newGithubTestDriver(rt roundTripFunc, token string, enabled bool) *Github {
return &Github{
Storage: model.Storage{MountPath: "/github-test", CacheExpiration: 10},
Addition: Addition{
RootPath: odriver.RootPath{RootFolderPath: "/"},
Token: token,
Owner: "owner",
Repo: "repo",
Ref: "main",
AccurateModifiedTime: enabled,
},
client: resty.New().SetTransport(rt),
}
}
func newJSONResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json: %v", err)
}
return string(data)
}
func graphQLQueryFromRequest(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read graphql request body: %v", err)
}
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode graphql request body: %v", err)
}
query := payload["query"]
if query == "" {
t.Fatalf("graphql request missing query: %s", string(body))
}
return query
}
func newContentsPayload(t *testing.T, entries []Object) string {
t.Helper()
return mustJSON(t, map[string]any{
"type": "dir",
"sha": "tree-sha",
"entries": entries,
})
}
func newTreePayload(t *testing.T, sha string, trees []TreeObjResp) string {
t.Helper()
return mustJSON(t, map[string]any{
"sha": sha,
"truncated": false,
"tree": trees,
})
}
func newCommitGraphQLPayload(t *testing.T, histories map[string][]string) string {
t.Helper()
commit := make(map[string]any, len(histories))
for alias, dates := range histories {
nodes := make([]map[string]string, 0, len(dates))
for _, date := range dates {
nodes = append(nodes, map[string]string{"committedDate": date})
}
commit[alias] = map[string]any{"nodes": nodes}
}
return mustJSON(t, map[string]any{
"data": map[string]any{
"repository": map[string]any{"commit": commit},
},
})
}
func newSequentialEntries(count int) []Object {
entries := make([]Object, 0, count)
for i := range count {
name := fmt.Sprintf("%03d.md", i)
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "file", Size: 1})
}
return entries
}
func mustObject(t *testing.T, obj model.Obj) *model.Object {
t.Helper()
raw, ok := model.UnwrapObjName(obj).(*model.Object)
if !ok {
t.Fatalf("unexpected obj type %T", obj)
}
return raw
}
func TestListAppliesAccurateModifiedTimeInOneRequest(t *testing.T) {
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
entries := []Object{
{Name: "a.md", Path: "docs/a.md", Type: "file", Size: 1},
{Name: ".gitkeep", Path: "docs/.gitkeep", Type: "file"},
{Name: `quote " 文.md`, Path: `docs/quote " 文.md`, Type: "file", Size: 1},
{Name: "control.md", Path: "docs/control\x01.md", Type: "file", Size: 1},
}
graphqlCalls := 0
var query string
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
query = graphQLQueryFromRequest(t, r)
if got := r.Header.Get("Authorization"); got != "Bearer token" {
t.Fatalf("unexpected authorization header: %q", got)
}
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
"p0": {stamp.Format(time.RFC3339)},
"p1": {},
"p2": {},
})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
}
if strings.Count(query, "history(first: 1") != 3 ||
!strings.Contains(query, `object(expression: "main^{commit}")`) ||
!strings.Contains(query, `p0: history(first: 1, path: "docs/a.md")`) ||
!strings.Contains(query, `p1: history(first: 1, path: "docs/quote \" 文.md")`) ||
!strings.Contains(query, `p2: history(first: 1, path: "docs/control\u0001.md")`) {
t.Fatalf("query should peel the ref and contain all listed paths once:\n%s", query)
}
if len(objs) != 3 {
t.Fatalf("expected three objects after .gitkeep filtering, got %d", len(objs))
}
if first := mustObject(t, objs[0]); !first.ModTime().Equal(stamp) || !first.CreateTime().Equal(githubZeroTime) {
t.Fatalf("unexpected first timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
}
if second := mustObject(t, objs[1]); !second.ModTime().Equal(githubZeroTime) || !second.CreateTime().Equal(githubZeroTime) {
t.Fatalf("unmatched entry should retain legacy timestamps: mod=%v create=%v", second.ModTime(), second.CreateTime())
}
}
func TestListSkipsAccurateModifiedTime(t *testing.T) {
tests := []struct {
name string
enabled bool
token string
entries int
}{
{name: "disabled", enabled: false, token: "token", entries: 1},
{name: "missing token", enabled: true, token: "", entries: 1},
{name: "over entry limit", enabled: true, token: "token", entries: 201},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
graphqlCalls := 0
payload := newContentsPayload(t, newSequentialEntries(tc.entries))
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, payload), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), tc.token, tc.enabled)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 0 {
t.Fatalf("expected zero GraphQL requests, got %d", graphqlCalls)
}
for _, obj := range objs {
raw := mustObject(t, obj)
if !raw.ModTime().Equal(githubZeroTime) || !raw.CreateTime().Equal(githubZeroTime) {
t.Fatalf("legacy timestamps should be preserved: mod=%v create=%v", raw.ModTime(), raw.CreateTime())
}
}
})
}
}
func TestListFallsBackWhenGraphQLFails(t *testing.T) {
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(1))), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, `{"errors":[{"message":"rate limited"}]}`), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("GraphQL failure should be best-effort: %v", err)
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
}
obj := mustObject(t, objs[0])
if !obj.ModTime().Equal(githubZeroTime) || !obj.CreateTime().Equal(githubZeroTime) {
t.Fatalf("failed GraphQL request should retain legacy timestamps: mod=%v create=%v", obj.ModTime(), obj.CreateTime())
}
}
func TestListUsesOneGraphQLRequestAtEntryLimit(t *testing.T) {
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
graphqlCalls := 0
var query string
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(200))), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
query = graphQLQueryFromRequest(t, r)
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
"p199": {stamp.Format(time.RFC3339)},
})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 1 || strings.Count(query, "history(first: 1") != 200 {
t.Fatalf("200 entries should share one request: calls=%d histories=%d", graphqlCalls, strings.Count(query, "history(first: 1"))
}
if !strings.Contains(query, `p199: history(first: 1, path: "docs/199.md")`) {
t.Fatalf("query missing final entry:\n%s", query)
}
if len(objs) != 200 || !mustObject(t, objs[199]).ModTime().Equal(stamp) {
t.Fatalf("unexpected final object timestamp")
}
}
func TestListKeepsTreeFallbackOnLegacyPath(t *testing.T) {
entries := make([]Object, 0, 1000)
for i := range 1000 {
name := fmt.Sprintf("dir-%d", i)
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "dir"})
}
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/git/trees/"):
return newJSONResponse(http.StatusOK, newTreePayload(t, "tree-sha", []TreeObjResp{{TreeObjReq: TreeObjReq{Path: "child.md", Mode: "100644", Type: "blob", Sha: "blob-sha"}, Size: 1, URL: "https://example.invalid/blob"}})), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("unexpected tree fallback result length: %d", len(objs))
}
first := mustObject(t, objs[0])
if first.GetPath() != "/child.md" {
t.Fatalf("unexpected tree fallback path: %s", first.GetPath())
}
if !first.ModTime().Equal(githubZeroTime) || !first.CreateTime().Equal(githubZeroTime) {
t.Fatalf("tree fallback should preserve legacy timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
}
if graphqlCalls != 0 {
t.Fatalf("tree fallback should skip GraphQL, got %d calls", graphqlCalls)
}
}
func TestOpListCacheHitDoesNotRepeatGraphQL(t *testing.T) {
op.Cache.ClearAll()
defer op.Cache.ClearAll()
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, []Object{{Name: "a.md", Path: "a.md", Type: "file", Size: 1}})), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{"p0": {stamp.Format(time.RFC3339)}})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
first, err := op.List(context.Background(), drv, "/", model.ListArgs{})
if err != nil {
t.Fatalf("unexpected first list error: %v", err)
}
second, err := op.List(context.Background(), drv, "/", model.ListArgs{})
if err != nil {
t.Fatalf("unexpected second list error: %v", err)
}
if len(first) != 1 || len(second) != 1 {
t.Fatalf("unexpected cached results: first=%d second=%d", len(first), len(second))
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL call across cached lists, got %d", graphqlCalls)
}
if !mustObject(t, first[0]).ModTime().Equal(stamp) {
t.Fatalf("expected first list to include backfilled modified time, got %v", mustObject(t, first[0]).ModTime())
}
if !mustObject(t, second[0]).ModTime().Equal(stamp) {
t.Fatalf("expected cached list to retain modified time, got %v", mustObject(t, second[0]).ModTime())
}
}
+4 -4
View File
@@ -1,8 +1,6 @@
package github
import (
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
@@ -35,7 +33,8 @@ func (o *Object) toModelObj() *model.Object {
return &model.Object{
Name: o.Name,
Size: o.Size,
Modified: time.Unix(0, 0),
Modified: githubZeroTime,
Ctime: githubZeroTime,
IsFolder: o.Type == "dir",
Path: utils.FixAndCleanPath(o.Path),
}
@@ -69,7 +68,8 @@ func (o *TreeObjResp) toModelObj() *model.Object {
return &model.Object{
Name: o.Path,
Size: o.Size,
Modified: time.Unix(0, 0),
Modified: githubZeroTime,
Ctime: githubZeroTime,
IsFolder: o.Type == "tree",
Path: utils.FixAndCleanPath(o.Path),
}
+56 -64
View File
@@ -45,134 +45,126 @@ func (d *GithubReleases) List(ctx context.Context, dir model.Obj, args model.Lis
for i := range d.points {
point := &d.points[i]
if !d.Addition.ShowAllVersion { // latest
err := point.RequestRelease(d.GetRequest, args.Refresh)
if !d.Addition.ShowAllVersion {
// latest version mode
release, err := d.getLatestRelease(point.Repo)
if err != nil {
log.Warnf("failed to request release for %s: %v", point.Repo, err)
continue
}
if release == nil {
continue
}
if point.Point == path { // 与仓库路径相同
if point.Release == nil {
if err != nil {
return nil, fmt.Errorf("failed to get release for %s: %w", point.Repo, err)
}
return nil, fmt.Errorf("failed to get release for %s: unknown error", point.Repo)
}
files = append(files, point.GetLatestRelease()...)
if point.Point == path {
// 当前目录就是仓库挂载点
files = append(files, releaseToFiles(point.Point, release)...)
if d.Addition.ShowReadme {
otherFiles, err := point.GetOtherFile(d.GetRequest, args.Refresh)
if err != nil {
return nil, fmt.Errorf("failed to get other files for %s: %w", point.Repo, err)
other, err := d.fetchRepoFiles(point.Repo)
if err == nil {
files = append(files, otherFiles(point.Point, other)...)
} else {
log.Warnf("failed to get other files for %s: %v", point.Repo, err)
}
files = append(files, otherFiles...)
}
if d.Addition.ShowSourceCode {
files = append(files, point.GetSourceCode()...)
files = append(files, sourceCodeFiles(point.Point, release)...)
}
} else if strings.HasPrefix(point.Point, path) { // 仓库目录的父目录
} else if strings.HasPrefix(point.Point, path) {
// 仓库目录的父目录,需要聚合显示
nextDir := GetNextDir(point.Point, path)
if nextDir == "" {
continue
}
if err != nil {
return nil, fmt.Errorf("failed to get release for %s: %w", point.Repo, err)
}
hasSameDir := false
for index := range files {
if files[index].GetName() == nextDir {
hasSameDir = true
files[index].Size += point.GetLatestSize()
files[index].Size += releaseSize(release)
break
}
}
if !hasSameDir {
var updateAt, createAt string
if point.Release != nil {
updateAt = point.Release.PublishedAt
createAt = point.Release.CreatedAt
}
files = append(files, File{
Path: stdpath.Join(path, nextDir),
FileName: nextDir,
Size: point.GetLatestSize(),
UpdateAt: updateAt,
CreateAt: createAt,
Size: releaseSize(release),
UpdateAt: release.PublishedAt,
CreateAt: release.CreatedAt,
Type: "dir",
Url: "",
})
}
}
} else { // all version
err := point.RequestReleases(d.GetRequest, args.Refresh)
} else {
// all versions mode
releases, err := d.getAllReleases(point.Repo)
if err != nil {
log.Warnf("failed to request releases for %s: %v", point.Repo, err)
continue
}
if len(releases) == 0 {
// no releases but may still have repo files (e.g. README)
if point.Point == path && d.Addition.ShowReadme {
other, err := d.fetchRepoFiles(point.Repo)
if err == nil {
files = append(files, otherFiles(point.Point, other)...)
} else {
log.Warnf("failed to get other files for %s: %v", point.Repo, err)
}
}
continue
}
if point.Point == path { // 与仓库路径相同
if point.Releases == nil {
if err != nil {
return nil, fmt.Errorf("failed to get releases for %s: %w", point.Repo, err)
}
return nil, fmt.Errorf("failed to get releases for %s: unknown error", point.Repo)
}
files = append(files, point.GetAllVersion()...)
if point.Point == path {
// 当前目录就是仓库挂载点
files = append(files, releasesToVersionDirs(point.Point, releases)...)
if d.Addition.ShowReadme {
otherFiles, err := point.GetOtherFile(d.GetRequest, args.Refresh)
if err != nil {
return nil, fmt.Errorf("failed to get other files for %s: %w", point.Repo, err)
other, err := d.fetchRepoFiles(point.Repo)
if err == nil {
files = append(files, otherFiles(point.Point, other)...)
} else {
log.Warnf("failed to get other files for %s: %v", point.Repo, err)
}
files = append(files, otherFiles...)
}
} else if strings.HasPrefix(point.Point, path) { // 仓库目录的父目录
} else if strings.HasPrefix(point.Point, path) {
// 仓库目录的父目录
nextDir := GetNextDir(point.Point, path)
if nextDir == "" {
continue
}
if err != nil {
return nil, fmt.Errorf("failed to get releases for %s: %w", point.Repo, err)
}
hasSameDir := false
for index := range files {
if files[index].GetName() == nextDir {
hasSameDir = true
files[index].Size += point.GetAllVersionSize()
files[index].Size += releasesTotalSize(releases)
break
}
}
if !hasSameDir {
var updateAt, createAt string
if point.Releases != nil && len(*point.Releases) > 0 {
updateAt = (*point.Releases)[0].PublishedAt
createAt = (*point.Releases)[0].CreatedAt
}
files = append(files, File{
FileName: nextDir,
Path: stdpath.Join(path, nextDir),
Size: point.GetAllVersionSize(),
UpdateAt: updateAt,
CreateAt: createAt,
Size: releasesTotalSize(releases),
UpdateAt: releases[0].PublishedAt,
CreateAt: releases[0].CreatedAt,
Type: "dir",
Url: "",
})
}
} else if strings.HasPrefix(path, point.Point) { // 仓库目录的子目录
} else if strings.HasPrefix(path, point.Point) {
// 仓库目录的子目录(某个版本)
tagName := GetNextDir(path, point.Point)
if tagName == "" {
continue
}
if point.Releases == nil {
if err != nil {
return nil, fmt.Errorf("failed to get releases for %s: %w", point.Repo, err)
}
return nil, fmt.Errorf("failed to get releases for %s: unknown error", point.Repo)
}
files = append(files, point.GetReleaseByTagName(tagName)...)
files = append(files, releaseAssetsByTag(point.Point, tagName, releases)...)
if d.Addition.ShowSourceCode {
files = append(files, point.GetSourceCodeByTagName(tagName)...)
files = append(files, sourceCodeFilesByTag(point.Point, releases, tagName)...)
}
}
}
+3 -1
View File
@@ -12,7 +12,9 @@ type Addition struct {
Token string `json:"token" type:"string" required:"false" help:"GitHub token, if you want to access private repositories or increase the rate limit"`
ShowSourceCode bool `json:"show_source_code" type:"bool" default:"false" help:"show Source code (zip/tar.gz)"`
ShowAllVersion bool `json:"show_all_version" type:"bool" default:"false" help:"show all versions"`
GitHubProxy string `json:"gh_proxy" type:"string" default:"" help:"GitHub proxy, e.g. https://ghproxy.net/github.com or https://gh-proxy.com/github.com "`
PerPage int `json:"per_page" type:"number" default:"30" help:"releases per page (max 100), only works when show all versions"`
MaxPage int `json:"max_page" type:"number" default:"0" help:"max pages to fetch (0 = unlimited), only works when show all versions"`
GitHubProxy string `json:"gh_proxy" type:"string" default:"" help:"GitHub proxy, e.g. https://ghproxy.net/https://github.com or https://gh-proxy.com/https://github.com"`
}
var config = driver.Config{
+71 -173
View File
@@ -1,76 +1,28 @@
package github_releases
import (
"encoding/json"
"path"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/go-resty/resty/v2"
)
// MountPoint 表示一个仓库挂载点
type MountPoint struct {
Point string // 挂载点
Repo string // 仓库名 owner/repo
Release *Release // Release 指针 latest
Releases *[]Release // []Release 指针
OtherFile *[]FileInfo // 仓库根目录下的其他文件
Point string // 挂载点路径
Repo string // 仓库名 owner/repo
}
// 请求最新版本
func (m *MountPoint) RequestRelease(get func(url string) (*resty.Response, error), refresh bool) error {
if m.Repo == "" {
// Release 转为 File 列表
func releaseToFiles(point string, release *Release) []File {
if release == nil {
return nil
}
if m.Release == nil || refresh {
resp, err := get("https://api.github.com/repos/" + m.Repo + "/releases/latest")
if err != nil {
m.Release = nil
return err
}
release := new(Release)
if err := json.Unmarshal(resp.Body(), release); err != nil {
m.Release = nil
return err
}
m.Release = release
}
return nil
}
// 请求所有版本
func (m *MountPoint) RequestReleases(get func(url string) (*resty.Response, error), refresh bool) error {
if m.Repo == "" {
return nil
}
if m.Releases == nil || refresh {
resp, err := get("https://api.github.com/repos/" + m.Repo + "/releases")
if err != nil {
m.Releases = nil
return err
}
releases := new([]Release)
if err := json.Unmarshal(resp.Body(), releases); err != nil {
m.Releases = nil
return err
}
m.Releases = releases
}
return nil
}
// 获取最新版本
func (m *MountPoint) GetLatestRelease() []File {
if m.Release == nil {
return nil
}
files := make([]File, 0, len(m.Release.Assets))
for _, asset := range m.Release.Assets {
files := make([]File, 0, len(release.Assets))
for _, asset := range release.Assets {
files = append(files, File{
Path: path.Join(m.Point, asset.Name),
Path: path.Join(point, asset.Name),
FileName: asset.Name,
Size: asset.Size,
Type: "file",
@@ -82,53 +34,43 @@ func (m *MountPoint) GetLatestRelease() []File {
return files
}
// 获取最新版本大小
func (m *MountPoint) GetLatestSize() int64 {
if m.Release == nil {
// 计算 release 的 asset 总大小
func releaseSize(release *Release) int64 {
if release == nil {
return 0
}
size := int64(0)
for _, asset := range m.Release.Assets {
for _, asset := range release.Assets {
size += asset.Size
}
return size
}
// 获取所有版本
func (m *MountPoint) GetAllVersion() []File {
if m.Releases == nil {
return nil
}
files := make([]File, 0)
for _, release := range *m.Releases {
file := File{
Path: path.Join(m.Point, release.TagName),
// Releases 列表转为版本目录 File 列表
func releasesToVersionDirs(point string, releases []Release) []File {
files := make([]File, 0, len(releases))
for _, release := range releases {
files = append(files, File{
Path: path.Join(point, release.TagName),
FileName: release.TagName,
Size: m.GetSizeByTagName(release.TagName),
Size: releaseSize(&release),
Type: "dir",
UpdateAt: release.PublishedAt,
CreateAt: release.CreatedAt,
Url: release.HtmlUrl,
}
for _, asset := range release.Assets {
file.Size += asset.Size
}
files = append(files, file)
})
}
return files
}
// 根据版本号获取版本
func (m *MountPoint) GetReleaseByTagName(tagName string) []File {
if m.Releases == nil {
return nil
}
for _, item := range *m.Releases {
// 根据 tagName 查找 release 的 asset 文件列表
func releaseAssetsByTag(point, tagName string, releases []Release) []File {
for _, item := range releases {
if item.TagName == tagName {
files := make([]File, 0)
files := make([]File, 0, len(item.Assets))
for _, asset := range item.Assets {
files = append(files, File{
Path: path.Join(m.Point, tagName, asset.Name),
Path: path.Join(point, tagName, asset.Name),
FileName: asset.Name,
Size: asset.Size,
Type: "file",
@@ -143,118 +85,74 @@ func (m *MountPoint) GetReleaseByTagName(tagName string) []File {
return nil
}
// 根据版本号获取版本大小
func (m *MountPoint) GetSizeByTagName(tagName string) int64 {
if m.Releases == nil {
return 0
}
for _, item := range *m.Releases {
// 根据 tagName 计算 asset 总大小
func releasesSizeByTag(releases []Release, tagName string) int64 {
for _, item := range releases {
if item.TagName == tagName {
size := int64(0)
for _, asset := range item.Assets {
size += asset.Size
}
return size
return releaseSize(&item)
}
}
return 0
}
// 获取所有版本大小
func (m *MountPoint) GetAllVersionSize() int64 {
if m.Releases == nil {
return 0
}
// 计算所有 releases 的 asset 总大小
func releasesTotalSize(releases []Release) int64 {
size := int64(0)
for _, release := range *m.Releases {
for _, asset := range release.Assets {
size += asset.Size
}
for _, release := range releases {
size += releaseSize(&release)
}
return size
}
func (m *MountPoint) GetSourceCode() []File {
if m.Release == nil {
// Source code 文件
func sourceCodeFiles(point string, release *Release) []File {
if release == nil {
return nil
}
files := make([]File, 0)
// 无法获取文件大小,此处设为 1
files = append(files, File{
Path: path.Join(m.Point, "Source code (zip)"),
FileName: "Source code (zip)",
Size: 1,
Type: "file",
UpdateAt: m.Release.CreatedAt,
CreateAt: m.Release.CreatedAt,
Url: m.Release.ZipballUrl,
})
files = append(files, File{
Path: path.Join(m.Point, "Source code (tar.gz)"),
FileName: "Source code (tar.gz)",
Size: 1,
Type: "file",
UpdateAt: m.Release.CreatedAt,
CreateAt: m.Release.CreatedAt,
Url: m.Release.TarballUrl,
})
return files
return []File{
{
Path: path.Join(point, "Source code (zip)"),
FileName: "Source code (zip)",
Size: 1,
Type: "file",
UpdateAt: release.CreatedAt,
CreateAt: release.CreatedAt,
Url: release.ZipballUrl,
},
{
Path: path.Join(point, "Source code (tar.gz)"),
FileName: "Source code (tar.gz)",
Size: 1,
Type: "file",
UpdateAt: release.CreatedAt,
CreateAt: release.CreatedAt,
Url: release.TarballUrl,
},
}
}
func (m *MountPoint) GetSourceCodeByTagName(tagName string) []File {
if m.Releases == nil {
return nil
}
for _, item := range *m.Releases {
// 根据 tagName 获取 Source Code 文件
func sourceCodeFilesByTag(point string, releases []Release, tagName string) []File {
for _, item := range releases {
if item.TagName == tagName {
files := make([]File, 0)
files = append(files, File{
Path: path.Join(m.Point, "Source code (zip)"),
FileName: "Source code (zip)",
Size: 1,
Type: "file",
UpdateAt: item.CreatedAt,
CreateAt: item.CreatedAt,
Url: item.ZipballUrl,
})
files = append(files, File{
Path: path.Join(m.Point, "Source code (tar.gz)"),
FileName: "Source code (tar.gz)",
Size: 1,
Type: "file",
UpdateAt: item.CreatedAt,
CreateAt: item.CreatedAt,
Url: item.TarballUrl,
})
return files
return sourceCodeFiles(point, &item)
}
}
return nil
}
func (m *MountPoint) GetOtherFile(get func(url string) (*resty.Response, error), refresh bool) ([]File, error) {
if m.OtherFile == nil || refresh {
resp, err := get("https://api.github.com/repos/" + m.Repo + "/contents")
if err != nil {
m.OtherFile = nil
return nil, err
}
otherFile := new([]FileInfo)
if err := json.Unmarshal(resp.Body(), otherFile); err != nil {
m.OtherFile = nil
return nil, err
}
m.OtherFile = otherFile
}
// 仓库根目录下的 README/LICENSE 文件
func otherFiles(point string, fileInfos []FileInfo) []File {
files := make([]File, 0)
defaultTime := "1970-01-01T00:00:00Z"
for _, file := range *m.OtherFile {
if strings.HasSuffix(file.Name, ".md") || strings.HasPrefix(file.Name, "LICENSE") {
for _, file := range fileInfos {
if file.Type == "dir" {
continue
}
name := file.Name
if strings.EqualFold(name, "README.md") || strings.HasPrefix(name, "LICENSE") {
files = append(files, File{
Path: path.Join(m.Point, file.Name),
Path: path.Join(point, file.Name),
FileName: file.Name,
Size: file.Size,
Type: "file",
@@ -264,7 +162,7 @@ func (m *MountPoint) GetOtherFile(get func(url string) (*resty.Response, error),
})
}
}
return files, nil
return files
}
type File struct {
+78 -10
View File
@@ -1,8 +1,8 @@
package github_releases
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
@@ -49,10 +49,8 @@ func (d *GithubReleases) ParseRepos(text string) ([]MountPoint, error) {
}
points = append(points, MountPoint{
Point: path,
Repo: repo,
Release: nil,
Releases: nil,
Point: path,
Repo: repo,
})
}
d.points = points
@@ -76,9 +74,79 @@ func GetNextDir(wholePath string, basePath string) string {
return ""
}
// 判断当前目录是否是目标目录的祖先目录
func IsAncestorDir(parentDir string, targetDir string) bool {
absTargetDir, _ := filepath.Abs(targetDir)
absParentDir, _ := filepath.Abs(parentDir)
return strings.HasPrefix(absTargetDir, absParentDir)
// getLatestRelease 获取最新 release
func (d *GithubReleases) getLatestRelease(repo string) (*Release, error) {
resp, err := d.GetRequest("https://api.github.com/repos/" + repo + "/releases/latest")
if err != nil {
return nil, err
}
release := new(Release)
if err := json.Unmarshal(resp.Body(), release); err != nil {
return nil, err
}
return release, nil
}
// getAllReleases 获取所有 releases(支持自动翻页)
func (d *GithubReleases) getAllReleases(repo string) ([]Release, error) {
perPage := d.Addition.PerPage
if perPage < 1 {
perPage = 30
} else if perPage > 100 {
perPage = 100
}
maxPage := d.Addition.MaxPage
if maxPage < 0 {
maxPage = 0
}
allReleases := make([]Release, 0)
page := 1
for {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d&page=%d", repo, perPage, page)
resp, err := d.GetRequest(url)
if err != nil {
return nil, err
}
releases := make([]Release, 0)
if err := json.Unmarshal(resp.Body(), &releases); err != nil {
return nil, err
}
if len(releases) == 0 {
break
}
allReleases = append(allReleases, releases...)
// 达到最大页数限制
if maxPage > 0 && page >= maxPage {
break
}
// 如果返回数量小于 perPage,说明是最后一页
if len(releases) < perPage {
break
}
page++
}
return allReleases, nil
}
// fetchRepoFiles 获取仓库根目录文件列表
func (d *GithubReleases) fetchRepoFiles(repo string) ([]FileInfo, error) {
resp, err := d.GetRequest("https://api.github.com/repos/" + repo + "/contents")
if err != nil {
return nil, err
}
files := make([]FileInfo, 0)
if err := json.Unmarshal(resp.Body(), &files); err != nil {
return nil, err
}
return files, nil
}
+792
View File
@@ -0,0 +1,792 @@
package guangyapan
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
)
const (
accountBaseURL = "https://account.guangyapan.com"
apiBaseURL = "https://api.guangyapan.com"
)
type GuangYaPan struct {
model.Storage
Addition
accountClient *resty.Client
apiClient *resty.Client
resolvedRootFolderID string
rootFolderResolved bool
// refreshMu protects concurrent access to AccessToken/RefreshToken during refresh.
refreshMu sync.Mutex
// statusTimer tracks delayed status updates for cancellation on Drop.
statusTimer *time.Timer
// apiRateLimit throttles requests per API endpoint so that batch operations
// (e.g. copying many files cross-storage) don't flood the upstream API.
apiRateLimit sync.Map
}
// apiRateInterval is the minimum gap between two requests to the same endpoint.
const apiRateInterval = 500 * time.Millisecond
func (d *GuangYaPan) Config() driver.Config {
return config
}
func (d *GuangYaPan) GetAddition() driver.Additional {
return &d.Addition
}
func (d *GuangYaPan) Init(ctx context.Context) error {
d.ClientID = strings.TrimSpace(d.ClientID)
if d.ClientID == "" {
return errors.New("client_id is required, please provide a valid client_id")
}
d.DeviceID = normalizeDeviceID(d.DeviceID)
if d.DeviceID == "" {
d.DeviceID = randomDeviceID()
}
deviceSign := strings.TrimSpace(d.DeviceSign)
if deviceSign == "" {
deviceSign = "wdi10." + d.DeviceID
}
if d.PageSize <= 0 {
d.PageSize = 100
}
if d.OrderBy < 0 {
d.OrderBy = 3
}
if d.SortType != 0 && d.SortType != 1 {
d.SortType = 1
}
d.RootPath = strings.TrimSpace(d.RootPath)
d.AccessToken = strings.TrimSpace(d.AccessToken)
d.RefreshToken = strings.TrimSpace(d.RefreshToken)
d.PhoneNumber = strings.TrimSpace(d.PhoneNumber)
d.VerifyCode = strings.TrimSpace(d.VerifyCode)
d.CaptchaToken = strings.TrimSpace(d.CaptchaToken)
d.VerificationID = strings.TrimSpace(d.VerificationID)
d.resolvedRootFolderID = ""
d.rootFolderResolved = false
d.accountClient = base.NewRestyClient().
SetBaseURL(accountBaseURL).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("X-Device-Model", "chrome%2F147.0.0.0").
SetHeader("X-Device-Name", "PC-Chrome").
SetHeader("X-Device-Sign", deviceSign).
SetHeader("X-Net-Work-Type", "NONE").
SetHeader("X-OS-Version", "MacIntel").
SetHeader("X-Platform-Version", "1").
SetHeader("X-Protocol-Version", "301").
SetHeader("X-Provider-Name", "NONE").
SetHeader("X-SDK-Version", "9.0.2").
SetHeader("X-Client-Id", d.ClientID).
SetHeader("X-Client-Version", "0.0.1").
SetHeader("X-Device-Id", d.DeviceID)
d.apiClient = base.NewRestyClient().
SetBaseURL(apiBaseURL).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("Did", d.DeviceID).
SetHeader("Dt", "4")
// Priority: access_token -> refresh_token -> sms login.
if d.AccessToken != "" {
if err := d.validateToken(ctx); err == nil {
return d.prepareRootFolder(ctx)
}
d.AccessToken = ""
}
if d.RefreshToken != "" {
if err := d.refreshToken(ctx); err == nil {
if err2 := d.validateToken(ctx); err2 == nil {
return d.prepareRootFolder(ctx)
}
}
}
// Two-stage SMS flow:
// 1) phone only + send_code=true: send code and cache verification_id (do not fail init).
// 2) phone + verify_code: complete login and save tokens.
if d.PhoneNumber != "" {
if d.canSMSLogin() {
if err := d.loginBySMSCode(ctx); err != nil {
return err
}
if err := d.validateToken(ctx); err != nil {
return err
}
return d.prepareRootFolder(ctx)
}
if d.SendCode {
d.setTempStatus("SMS sending in progress...")
if err := d.prepareSMSCode(ctx); err != nil {
d.setTempStatus(fmt.Sprintf("SMS send failed: %v. Please check captcha/meta and set send_code=true to retry.", err))
log.Warnf("guangyapan: prepare sms code failed: %v", err)
} else {
d.setTempStatus("SMS sent successfully. Please fill verify_code and save to complete login.")
}
}
return nil
}
return errors.New("login failed: provide a valid access_token, or refresh_token, or phone_number + verify_code + captcha_token")
}
func (d *GuangYaPan) Drop(ctx context.Context) error {
if d.statusTimer != nil {
d.statusTimer.Stop()
}
return nil
}
func (d *GuangYaPan) GetRoot(ctx context.Context) (model.Obj, error) {
rootID, err := d.getRootFolderID(ctx)
if err != nil {
return nil, err
}
return &model.Object{
ID: rootID,
Path: "/",
Name: "root",
Size: 0,
Modified: d.Modified,
IsFolder: true,
}, nil
}
func (d *GuangYaPan) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
parentID := dir.GetID()
const maxPage = 10000
res := make([]model.Obj, 0, d.PageSize)
for page := 0; page < maxPage; page++ {
var resp listResp
body := map[string]any{
"parentId": parentID,
"page": page,
"pageSize": d.PageSize,
"orderBy": d.OrderBy,
"sortType": d.SortType,
}
if err := d.postAPI(ctx, "/userres/v1/file/get_file_list", body, &resp); err != nil {
return nil, err
}
for _, item := range resp.Data.List {
res = append(res, &model.Object{
ID: item.FileID,
Path: parentID,
Name: item.FileName,
Size: item.FileSize,
Modified: unixOrZero(item.UTime),
Ctime: unixOrZero(item.CTime),
IsFolder: item.ResType == 2,
})
}
if len(resp.Data.List) < d.PageSize {
break
}
if resp.Data.Total > 0 && len(res) >= resp.Data.Total {
break
}
}
return res, nil
}
func (d *GuangYaPan) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if file.IsDir() {
return nil, errs.NotFile
}
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
var resp downloadResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_download_url", map[string]any{
"fileId": file.GetID(),
}, &resp); err != nil {
return nil, err
}
url := strings.TrimSpace(resp.Data.SignedURL)
if url == "" {
url = strings.TrimSpace(resp.Data.DownloadURL)
}
if url == "" {
return nil, errors.New("empty download url")
}
return &model.Link{URL: url}, nil
}
func (d *GuangYaPan) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
name := strings.TrimSpace(dirName)
if name == "" {
return errors.New("dir name is empty")
}
parentID := parentDir.GetID()
var out createDirResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/create_dir", map[string]any{
"parentId": parentID,
"dirName": name,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("make dir failed: %s", strings.TrimSpace(out.Msg))
}
return nil
}
func (d *GuangYaPan) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(srcObj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
name := strings.TrimSpace(newName)
if name == "" {
return errors.New("new name is empty")
}
var out commonResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/rename", map[string]any{
"fileId": fileID,
"newName": name,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("rename failed: %s", strings.TrimSpace(out.Msg))
}
return nil
}
func (d *GuangYaPan) Remove(ctx context.Context, obj model.Obj) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(obj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
var del taskResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/delete_file", map[string]any{
"fileIds": []string{fileID},
}, &del); err != nil {
return err
}
if !isSuccessMsg(del.Msg) {
return fmt.Errorf("delete failed: %s", strings.TrimSpace(del.Msg))
}
taskID := strings.TrimSpace(del.Data.TaskID)
if taskID == "" {
// Some backends may apply deletion synchronously.
return nil
}
return d.waitTaskDone(ctx, taskID)
}
func (d *GuangYaPan) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(srcObj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
parentID := dstDir.GetID()
var out taskResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/move_file", map[string]any{
"fileIds": []string{fileID},
"parentId": parentID,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("move failed: %s", strings.TrimSpace(out.Msg))
}
taskID := strings.TrimSpace(out.Data.TaskID)
if taskID == "" {
return nil
}
return d.waitTaskDone(ctx, taskID)
}
func (d *GuangYaPan) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(srcObj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
parentID := dstDir.GetID()
var out taskResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/copy_file", map[string]any{
"fileIds": []string{fileID},
"parentId": parentID,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("copy failed: %s", strings.TrimSpace(out.Msg))
}
taskID := strings.TrimSpace(out.Data.TaskID)
if taskID == "" {
return nil
}
return d.waitTaskDone(ctx, taskID)
}
func (d *GuangYaPan) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
if file == nil {
return nil, errors.New("file is nil")
}
if file.GetSize() < 0 {
return nil, errors.New("invalid file size")
}
name := strings.TrimSpace(file.GetName())
if name == "" {
return nil, errors.New("file name is empty")
}
parentID := dstDir.GetID()
// 优先秒传:先计算文件 MD5,后端命中相同文件时直接秒传完成,无需真实上传。
md5sum, err := d.fileMD5(file, up)
if err != nil {
return nil, err
}
token, code, err := d.getUploadToken(ctx, parentID, name, file.GetSize(), md5sum)
if err != nil {
return nil, err
}
taskID := strings.TrimSpace(token.TaskID)
// code == 156 (instant upload) or AlreadyDone mean the backend has already
// finished/imported the file; there is no OSS upload to perform.
if code == 156 || token.AlreadyDone {
if taskID == "" {
return nil, errors.New("instant upload returns empty task id")
}
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
return nil, err
}
return nil, nil
}
if token.ObjectPath == "" || token.BucketName == "" || token.EndPoint == "" || token.AccessKeyID == "" || token.SecretAccessKey == "" {
return nil, errors.New("upload token is incomplete")
}
ossEndpoint := normalizeOSSEndpoint(token.EndPoint, token.BucketName)
client, err := oss.New(ossEndpoint, token.AccessKeyID, token.SecretAccessKey, oss.SecurityToken(token.SessionToken))
if err != nil {
return nil, fmt.Errorf("create oss client failed: %w", err)
}
bucket, err := client.Bucket(token.BucketName)
if err != nil {
return nil, fmt.Errorf("create oss bucket failed: %w", err)
}
if file.GetSize() == 0 {
if err := bucket.PutObject(token.ObjectPath, strings.NewReader("")); err != nil {
return nil, err
}
} else {
if err := d.multipartUploadToOSS(ctx, bucket, token.ObjectPath, file, up); err != nil {
return nil, err
}
}
if taskID == "" {
return nil, nil
}
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
return nil, err
}
return nil, nil
}
func (d *GuangYaPan) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
var resp assetsInfoResp
if err := d.postAPI(ctx, "/nd.bizassets.s/v1/get_assets", nil, &resp); err != nil {
return nil, err
}
if resp.IsSuccess() && resp.Data.TotalSpaceSize > 0 {
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: resp.Data.TotalSpaceSize,
UsedSpace: resp.Data.UsedSpaceSize,
},
}, nil
}
return nil, errors.New("failed to get storage details")
}
func (d *GuangYaPan) getRootFolderID(ctx context.Context) (string, error) {
if d.rootFolderResolved {
return d.resolvedRootFolderID, nil
}
if err := d.ensureAccessToken(ctx); err != nil {
return "", err
}
if err := d.prepareRootFolder(ctx); err != nil {
return "", err
}
return d.resolvedRootFolderID, nil
}
func (d *GuangYaPan) prepareRootFolder(ctx context.Context) error {
rootID, err := d.resolveConfiguredRootFolderID(ctx)
if err != nil {
return err
}
d.resolvedRootFolderID = rootID
d.rootFolderResolved = true
return nil
}
func (d *GuangYaPan) resolveConfiguredRootFolderID(ctx context.Context) (string, error) {
root := strings.TrimSpace(d.RootPath)
if root == "" {
return "", nil
}
return d.resolveFolderPath(ctx, root)
}
func (d *GuangYaPan) resolveFolderPath(ctx context.Context, rootPath string) (string, error) {
cleanPath := strings.Trim(strings.ReplaceAll(strings.TrimSpace(rootPath), "\\", "/"), "/")
if cleanPath == "" {
return "", nil
}
parentID := ""
for _, name := range strings.Split(cleanPath, "/") {
if name == "" {
continue
}
childID, err := d.findChildFolderID(ctx, parentID, name)
if err != nil {
return "", err
}
parentID = childID
}
return parentID, nil
}
func (d *GuangYaPan) findChildFolderID(ctx context.Context, parentID, name string) (string, error) {
pageSize := d.PageSize
if pageSize <= 0 {
pageSize = 100
}
const maxPage = 10000
seen := 0
for page := 0; page < maxPage; page++ {
var resp listResp
body := map[string]any{
"parentId": parentID,
"page": page,
"pageSize": pageSize,
"orderBy": d.OrderBy,
"sortType": d.SortType,
}
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_file_list", body, &resp); err != nil {
return "", err
}
for _, item := range resp.Data.List {
seen++
if item.ResType == 2 && item.FileName == name {
return item.FileID, nil
}
}
if len(resp.Data.List) < pageSize {
break
}
if resp.Data.Total > 0 && seen >= resp.Data.Total {
break
}
}
if parentID == "" {
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under /", name)
}
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under parent %s", name, parentID)
}
func (d *GuangYaPan) ensureAccessToken(ctx context.Context) error {
if strings.TrimSpace(d.AccessToken) != "" {
return nil
}
if strings.TrimSpace(d.RefreshToken) == "" {
return errors.New("not logged in, please re-init storage")
}
return d.refreshToken(ctx)
}
func (d *GuangYaPan) validateToken(ctx context.Context) error {
var me userMeResp
resp, err := d.accountClient.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+d.AccessToken).
SetResult(&me).
Get("/v1/user/me")
if err != nil {
return err
}
if resp.IsError() {
return fmt.Errorf("validate token failed: status=%d body=%s", resp.StatusCode(), resp.String())
}
if strings.TrimSpace(me.Sub) == "" {
return errors.New("validate token failed: empty user sub")
}
return nil
}
func (d *GuangYaPan) refreshToken(ctx context.Context) error {
if strings.TrimSpace(d.RefreshToken) == "" {
return errors.New("refresh_token is empty")
}
d.refreshMu.Lock()
defer d.refreshMu.Unlock()
// Double-check after acquiring lock (may have been refreshed by another goroutine)
if strings.TrimSpace(d.AccessToken) != "" {
if err := d.validateToken(ctx); err == nil {
return nil
}
}
var out tokenResp
resp, err := d.accountClient.R().
SetContext(ctx).
SetBody(map[string]any{
"client_id": d.ClientID,
"grant_type": "refresh_token",
"refresh_token": d.RefreshToken,
}).
SetResult(&out).
Post("/v1/auth/token")
if err != nil {
return err
}
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
errMsg := strings.TrimSpace(out.ErrorDesc)
if errMsg == "" {
errMsg = strings.TrimSpace(out.Error)
}
if errMsg == "" {
errMsg = strings.TrimSpace(resp.String())
}
if errMsg == "" {
errMsg = fmt.Sprintf("status=%d", resp.StatusCode())
}
return fmt.Errorf("refresh token failed: %s", errMsg)
}
d.AccessToken = strings.TrimSpace(out.AccessToken)
if strings.TrimSpace(out.RefreshToken) != "" {
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
}
op.MustSaveDriverStorage(d)
return nil
}
func (d *GuangYaPan) canSMSLogin() bool {
return d.PhoneNumber != "" && d.VerifyCode != ""
}
func (d *GuangYaPan) loginBySMSCode(ctx context.Context) error {
verificationID := strings.TrimSpace(d.VerificationID)
if verificationID == "" {
var err error
verificationID, err = d.requestVerificationID(ctx)
if err != nil {
return err
}
}
var step2 verifyResp
resp, err := d.accountClient.R().
SetContext(ctx).
SetBody(map[string]any{
"verification_id": verificationID,
"verification_code": d.VerifyCode,
"client_id": d.ClientID,
}).
SetResult(&step2).
Post("/v1/auth/verification/verify")
if err != nil {
return err
}
if resp.IsError() || step2.Error != "" || strings.TrimSpace(step2.VerificationToken) == "" {
return fmt.Errorf("verify code failed: %s", d.accountErr(step2.ErrorDesc, step2.Error, resp))
}
var out tokenResp
resp, err = d.accountClient.R().
SetContext(ctx).
SetBody(map[string]any{
"verification_code": d.VerifyCode,
"verification_token": step2.VerificationToken,
"username": normalizePhoneE164(d.PhoneNumber),
"client_id": d.ClientID,
}).
SetResult(&out).
Post("/v1/auth/signin")
if err != nil {
return err
}
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
return fmt.Errorf("signin failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
}
d.AccessToken = strings.TrimSpace(out.AccessToken)
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
d.VerificationID = ""
// One-time SMS code should not be reused after successful login.
d.VerifyCode = ""
op.MustSaveDriverStorage(d)
return nil
}
func (d *GuangYaPan) prepareSMSCode(ctx context.Context) error {
// Explicit send action should always refresh verification_id.
d.VerificationID = ""
if err := d.ensureCaptchaToken(ctx, false); err != nil {
return err
}
verificationID, err := d.requestVerificationID(ctx)
if err != nil {
return err
}
d.VerificationID = verificationID
d.SendCode = false
op.MustSaveDriverStorage(d)
return nil
}
func (d *GuangYaPan) requestVerificationID(ctx context.Context) (string, error) {
req := d.accountClient.R().SetContext(ctx)
if d.CaptchaToken != "" {
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
}
var step1 verificationResp
resp, err := req.
SetBody(map[string]any{
"phone_number": normalizePhoneE164(d.PhoneNumber),
"target": "ANY",
"client_id": d.ClientID,
}).
SetResult(&step1).
Post("/v1/auth/verification")
if err != nil {
return "", err
}
if resp.IsError() || step1.Error != "" || strings.TrimSpace(step1.VerificationID) == "" {
// If captcha token is expired/invalid, refresh it once and retry.
if strings.Contains(step1.Error, "captcha_invalid") || strings.Contains(step1.ErrorDesc, "captcha_token expired") {
if err := d.ensureCaptchaToken(ctx, true); err == nil {
return d.requestVerificationID(ctx)
}
}
return "", fmt.Errorf("request verification failed: %s", d.accountErr(step1.ErrorDesc, step1.Error, resp))
}
return strings.TrimSpace(step1.VerificationID), nil
}
func (d *GuangYaPan) ensureCaptchaToken(ctx context.Context, force bool) error {
if !force && d.CaptchaToken != "" {
return nil
}
var out captchaInitResp
req := d.accountClient.R().SetContext(ctx)
if d.CaptchaToken != "" {
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
}
resp, err := req.
SetBody(map[string]any{
"client_id": d.ClientID,
"action": "POST:/v1/auth/verification",
"device_id": d.DeviceID,
"meta": map[string]any{
"username": normalizePhoneE164(d.PhoneNumber),
"phone_number": normalizePhoneE164(d.PhoneNumber),
"VERIFICATION_PHONE": normalizePhoneE164(d.PhoneNumber),
},
}).
SetResult(&out).
Post("/v1/shield/captcha/init")
if err != nil {
return err
}
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.CaptchaToken) == "" {
return fmt.Errorf("init captcha token failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
}
d.CaptchaToken = strings.TrimSpace(out.CaptchaToken)
op.MustSaveDriverStorage(d)
return nil
}
// Interface compliance checks
var (
_ driver.Driver = (*GuangYaPan)(nil)
_ driver.GetRooter = (*GuangYaPan)(nil)
_ driver.Mkdir = (*GuangYaPan)(nil)
_ driver.Move = (*GuangYaPan)(nil)
_ driver.Copy = (*GuangYaPan)(nil)
_ driver.Rename = (*GuangYaPan)(nil)
_ driver.Remove = (*GuangYaPan)(nil)
_ driver.PutResult = (*GuangYaPan)(nil)
_ driver.WithDetails = (*GuangYaPan)(nil)
)
+37
View File
@@ -0,0 +1,37 @@
package guangyapan
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
type Addition struct {
RootPath string `json:"root_path" help:"Full path in GuangYaPan cloud drive"`
PhoneNumber string `json:"phone_number" type:"text" help:"Phone number for SMS login, e.g. +86 13800000000"`
CaptchaToken string `json:"captcha_token" help:"Captcha token required by /v1/auth/verification"`
SendCode bool `json:"send_code" type:"bool" help:"Set true and save to send SMS code, it auto-resets to false after sending"`
VerifyCode string `json:"verify_code" type:"text" help:"SMS verification code used with phone_number; fill then save to finish login"`
VerificationID string `json:"verification_id" type:"text" help:"Auto-generated after sending SMS code; do not edit manually"`
AccessToken string `json:"access_token" help:"Bearer access token (optional if refresh_token is provided)"`
RefreshToken string `json:"refresh_token" help:"Refresh token for auto-login/auto-refresh"`
ClientID string `json:"client_id" required:"true" help:"Client ID for GuangYaPan API, must be provided"`
DeviceID string `json:"device_id" help:"Optional custom device id (32 hex chars), auto-generated when empty"`
DeviceSign string `json:"device_sign" help:"Optional custom X-Device-Sign header (generated from device_id when empty)"`
PageSize int `json:"page_size" type:"number" default:"100"`
OrderBy int `json:"order_by" type:"number" options:"0,1,2,3,4" default:"3" help:"Sort field used by the file list"`
SortType int `json:"sort_type" type:"number" options:"0,1" default:"1" help:"Sort direction used by the file list"`
}
var config = driver.Config{
Name: "GuangYaPan",
DefaultRoot: "",
CheckStatus: true,
Alert: "info|Two-stage SMS login: (1) fill phone_number (+ captcha_token if needed), set send_code=true and save; (2) fill verify_code and save to finish login and auto-save access_token/refresh_token.",
NoOverwriteUpload: true,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &GuangYaPan{}
})
}
+169
View File
@@ -0,0 +1,169 @@
package guangyapan
import (
"context"
"errors"
"fmt"
"net/url"
stdpath "path"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
func (d *GuangYaPan) ResolveOfflineResource(ctx context.Context, fileURL string) (*OfflineResolveData, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
fileURL = strings.TrimSpace(fileURL)
if fileURL == "" {
return nil, errors.New("offline url is empty")
}
var resp offlineResolveResp
if err := d.postAPI(ctx, "/cloudcollection/v1/resolve_res", map[string]any{
"url": fileURL,
}, &resp); err != nil {
return nil, err
}
if !isSuccessMsg(resp.Msg) {
return nil, fmt.Errorf("resolve offline resource failed: %s", strings.TrimSpace(resp.Msg))
}
return &resp.Data, nil
}
func (d *GuangYaPan) OfflineDownload(ctx context.Context, fileURL string, parentDir model.Obj, fileName string) (*OfflineTask, error) {
resolved, err := d.ResolveOfflineResource(ctx, fileURL)
if err != nil {
return nil, err
}
parentID := parentDir.GetID()
rootID, err := d.getRootFolderID(ctx)
if err != nil {
return nil, err
}
if parentID == rootID {
parentID = ""
}
taskURL := strings.TrimSpace(resolved.URL)
if taskURL == "" {
taskURL = strings.TrimSpace(fileURL)
}
name := strings.TrimSpace(fileName)
if name == "" {
name = resolved.defaultName(taskURL)
}
body := map[string]any{
"url": taskURL,
"parentId": parentID,
"newName": name,
}
if indexes := resolved.fileIndexes(); len(indexes) > 0 {
body["fileIndexes"] = indexes
}
var resp offlineCreateResp
if err := d.postAPI(ctx, "/cloudcollection/v1/create_task", body, &resp); err != nil {
return nil, err
}
if !isSuccessMsg(resp.Msg) {
return nil, fmt.Errorf("create offline task failed: %s", strings.TrimSpace(resp.Msg))
}
taskID := strings.TrimSpace(resp.Data.TaskID)
if taskID == "" {
return nil, errors.New("create offline task failed: empty task id")
}
return &OfflineTask{
TaskID: taskID,
FileName: name,
Res: taskURL,
}, nil
}
func (d *GuangYaPan) OfflineList(ctx context.Context, taskIDs []string, statuses []int, cursor string, pageSize int) ([]OfflineTask, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
body := map[string]any{}
if len(taskIDs) > 0 {
body["taskIds"] = taskIDs
}
if len(statuses) > 0 {
body["status"] = statuses
}
if cursor = strings.TrimSpace(cursor); cursor != "" {
body["cursor"] = cursor
}
if pageSize > 0 {
body["pageSize"] = pageSize
}
var resp offlineListResp
if err := d.postAPI(ctx, "/cloudcollection/v1/list_task", body, &resp); err != nil {
return nil, err
}
if !isSuccessMsg(resp.Msg) {
return nil, fmt.Errorf("list offline tasks failed: %s", strings.TrimSpace(resp.Msg))
}
return resp.Data.List, nil
}
func (d *GuangYaPan) DeleteOfflineTasks(ctx context.Context, taskIDs []string) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
if len(taskIDs) == 0 {
return nil
}
var resp offlineDeleteResp
if err := d.postAPI(ctx, "/cloudcollection/v2/delete_task", map[string]any{
"taskIds": taskIDs,
}, &resp); err != nil {
return err
}
if !isSuccessMsg(resp.Msg) {
return fmt.Errorf("delete offline tasks failed: %s", strings.TrimSpace(resp.Msg))
}
return nil
}
func (d OfflineResolveData) defaultName(fileURL string) string {
if d.BTResInfo != nil && strings.TrimSpace(d.BTResInfo.FileName) != "" {
return strings.TrimSpace(d.BTResInfo.FileName)
}
u, err := url.Parse(fileURL)
if err == nil {
name := strings.TrimSpace(stdpath.Base(u.Path))
if name != "" && name != "." && name != "/" {
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
return name
}
}
return "offline_download"
}
func (d OfflineResolveData) fileIndexes() []int {
if d.BTResInfo == nil || len(d.BTResInfo.Subfiles) == 0 {
return nil
}
indexes := make([]int, 0, len(d.BTResInfo.Subfiles))
for i, file := range d.BTResInfo.Subfiles {
if file.FileIndex != nil {
indexes = append(indexes, *file.FileIndex)
continue
}
indexes = append(indexes, i)
}
return indexes
}
func isSuccessMsg(msg string) bool {
msg = strings.TrimSpace(msg)
return msg == "" || strings.EqualFold(msg, "success")
}
+232
View File
@@ -0,0 +1,232 @@
package guangyapan
import "time"
type tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Sub string `json:"sub"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type verificationResp struct {
VerificationID string `json:"verification_id"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type captchaInitResp struct {
CaptchaToken string `json:"captcha_token"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type verifyResp struct {
VerificationToken string `json:"verification_token"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type userMeResp struct {
Sub string `json:"sub"`
}
type listResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Total int `json:"total"`
List []fileItem `json:"list"`
} `json:"data"`
}
type fileItem struct {
FileID string `json:"fileId"`
ParentID string `json:"parentId"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
ResType int `json:"resType"`
CTime int64 `json:"ctime"`
UTime int64 `json:"utime"`
}
type downloadResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
SignedURL string `json:"signedURL"`
DownloadURL string `json:"downloadUrl"`
} `json:"data"`
}
type createDirResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ResType int `json:"resType"`
CTime int64 `json:"ctime"`
UTime int64 `json:"utime"`
} `json:"data"`
}
type commonResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// taskResp is used for async operations (delete, move, copy) that return a task ID.
type taskResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TaskID string `json:"taskId"`
} `json:"data"`
}
type taskStatusResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Status int `json:"status"`
} `json:"data"`
}
type uploadTokenResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data uploadTokenData `json:"data"`
}
type uploadTokenData struct {
TaskID string `json:"taskId"`
AlreadyDone bool `json:"-"`
ObjectPath string `json:"objectPath"`
Provider any `json:"provider"`
Region string `json:"region"`
BucketName string `json:"bucketName"`
EndPoint string `json:"endPoint"`
FullEndPoint string `json:"fullEndPoint"`
CallbackVar string `json:"callbackVar"`
AccessKeyID string `json:"accessKeyID"`
SecretAccessKey string `json:"secretAccessKey"`
SessionToken string `json:"sessionToken"`
Creds struct {
AccessKeyID string `json:"accessKeyID"`
SecretAccessKey string `json:"secretAccessKey"`
SessionToken string `json:"sessionToken"`
} `json:"creds"`
}
type taskInfoResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
FileID string `json:"fileId"`
} `json:"data"`
}
// assetsInfoResp is the response for storage details query.
type assetsInfoResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TotalSpaceSize int64 `json:"totalSpaceSize"`
UsedSpaceSize int64 `json:"usedSpaceSize"`
} `json:"data"`
}
func (r assetsInfoResp) IsSuccess() bool {
return isSuccessMsg(r.Msg)
}
// Offline download types
type offlineResolveResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data OfflineResolveData `json:"data"`
}
type OfflineResolveData struct {
ResType int `json:"resType"`
BTResInfo *OfflineBTResInfo `json:"btResInfo"`
URL string `json:"url"`
}
type OfflineBTResInfo struct {
InfoHash string `json:"infoHash"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
SubfilesNum int `json:"subfilesNum"`
Subfiles []OfflineSubfile `json:"subfiles"`
CreateTime int64 `json:"createTime"`
ExcludeIndices []int `json:"excludeIndices"`
}
type OfflineSubfile struct {
FileName string `json:"fileName"`
FileIndex *int `json:"fileIndex"`
FileSize int64 `json:"fileSize"`
}
type offlineCreateResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TaskID string `json:"taskId"`
URL string `json:"url"`
} `json:"data"`
}
type offlineDeleteResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TaskIDs []string `json:"taskIds"`
} `json:"data"`
}
type offlineListResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
StatusCounts []struct {
Status int `json:"status"`
Count int `json:"count"`
} `json:"statusCounts"`
Cursor string `json:"cursor"`
List []OfflineTask `json:"list"`
Total int `json:"total"`
} `json:"data"`
}
type OfflineTask struct {
TaskID string `json:"taskId"`
FileName string `json:"fileName"`
TotalSize int64 `json:"totalSize"`
Status int `json:"status"`
CreateTime int64 `json:"createTime"`
Res string `json:"res"`
ResType int `json:"resType"`
Progress int `json:"progress"`
FileID string `json:"fileId"`
IsDir bool `json:"isDir"`
Exist bool `json:"exist"`
}
func unixOrZero(v int64) time.Time {
if v <= 0 {
return time.Time{}
}
return time.Unix(v, 0)
}
+436
View File
@@ -0,0 +1,436 @@
package guangyapan
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/avast/retry-go"
"github.com/go-resty/resty/v2"
"golang.org/x/time/rate"
)
// --- HTTP request helpers ---
func (d *GuangYaPan) accountErr(desc, short string, resp *resty.Response) string {
msg := strings.TrimSpace(desc)
if msg == "" {
msg = strings.TrimSpace(short)
}
if msg == "" && resp != nil {
msg = strings.TrimSpace(resp.String())
}
if msg == "" && resp != nil {
msg = fmt.Sprintf("status=%d", resp.StatusCode())
}
if msg == "" {
msg = "unknown error"
}
return msg
}
func (d *GuangYaPan) apiRateLimitWait(ctx context.Context, path string) error {
value, _ := d.apiRateLimit.LoadOrStore(path, rate.NewLimiter(rate.Every(apiRateInterval), 1))
return value.(*rate.Limiter).Wait(ctx)
}
func (d *GuangYaPan) postAPI(ctx context.Context, path string, body any, out any) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
if err := d.apiRateLimitWait(ctx, path); err != nil {
return err
}
resp, err := d.apiClient.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+d.AccessToken).
SetBody(body).
SetResult(out).
Post(path)
if err != nil {
return err
}
if resp.StatusCode() == 401 || resp.StatusCode() == 403 {
if strings.TrimSpace(d.RefreshToken) == "" {
return fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode(), resp.String())
}
if err := d.refreshToken(ctx); err != nil {
return err
}
resp, err = d.apiClient.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+d.AccessToken).
SetBody(body).
SetResult(out).
Post(path)
if err != nil {
return err
}
}
if resp.IsError() {
return fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode(), resp.String())
}
return nil
}
func (d *GuangYaPan) setTempStatus(status string) {
if d.statusTimer != nil {
d.statusTimer.Stop()
}
// initStorage sets status to WORK after Init returns, so we update it shortly after.
d.statusTimer = time.AfterFunc(200*time.Millisecond, func() {
d.GetStorage().SetStatus(status)
op.MustSaveDriverStorage(d)
})
}
// --- Task polling helpers ---
func (d *GuangYaPan) waitTaskDone(ctx context.Context, taskID string) error {
const (
maxTry = 30
interval = 300 * time.Millisecond
)
for i := 0; i < maxTry; i++ {
var out taskStatusResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_task_status", map[string]any{
"taskId": taskID,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("get task status failed: %s", strings.TrimSpace(out.Msg))
}
switch out.Data.Status {
case 2:
return nil
case -1, 3:
return fmt.Errorf("task %s failed with status=%d", taskID, out.Data.Status)
}
if i == maxTry-1 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
return fmt.Errorf("task %s timeout", taskID)
}
// --- Upload helpers ---
// fileMD5 returns the MD5 of the file content, computing it from the stream when
// the caller did not provide it (e.g. local uploads). The computed hash is used
// to attempt instant upload (秒传) before falling back to the real OSS upload.
// For force-stream uploads the whole file would have to be buffered just to hash
// it, which defeats the purpose of streaming, so we skip 秒传 in that case.
func (d *GuangYaPan) fileMD5(file model.FileStreamer, up driver.UpdateProgress) (string, error) {
if md5sum := file.GetHash().GetHash(utils.MD5); len(md5sum) == utils.MD5.Width {
return md5sum, nil
}
if file.IsForceStreamUpload() {
return "", nil
}
_, md5sum, err := streamPkg.CacheFullAndHash(file, &up, utils.MD5)
if err != nil {
return "", err
}
return md5sum, nil
}
func (d *GuangYaPan) getUploadToken(ctx context.Context, parentID, name string, size int64, md5sum string) (*uploadTokenData, int, error) {
var out uploadTokenResp
res := map[string]any{
"fileSize": size,
}
if md5sum != "" {
// 秒传:后端根据 md5 判断文件是否已存在,命中则返回 code 156 秒传完成。
res["md5"] = md5sum
}
err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_center_token", map[string]any{
"capacity": 2,
"name": name,
"parentId": parentID,
"res": res,
}, &out)
if err != nil {
return nil, 0, err
}
msg := strings.TrimSpace(out.Msg)
if !isSuccessMsg(msg) && !isUploadAlreadyDone(msg) {
return nil, out.Code, fmt.Errorf("get upload token failed: %s", msg)
}
if out.Data.TaskID == "" {
return nil, out.Code, errors.New("get upload token failed: empty task id")
}
// When the backend reports the file is already uploaded/instant-uploaded,
// it returns a valid TaskID without OSS credentials.
// Mark it so the caller can skip the real upload and just wait for the task.
if out.Code == 156 || isUploadAlreadyDone(msg) {
out.Data.AlreadyDone = true
}
if out.Data.AccessKeyID == "" {
out.Data.AccessKeyID = out.Data.Creds.AccessKeyID
}
if out.Data.SecretAccessKey == "" {
out.Data.SecretAccessKey = out.Data.Creds.SecretAccessKey
}
if out.Data.SessionToken == "" {
out.Data.SessionToken = out.Data.Creds.SessionToken
}
if strings.TrimSpace(out.Data.EndPoint) == "" {
out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint)
}
if strings.TrimSpace(out.Data.EndPoint) != "" && !strings.HasPrefix(out.Data.EndPoint, "http://") && !strings.HasPrefix(out.Data.EndPoint, "https://") {
if strings.TrimSpace(out.Data.FullEndPoint) != "" {
out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint)
} else if strings.TrimSpace(out.Data.BucketName) != "" {
host := strings.TrimSpace(out.Data.EndPoint)
prefix := strings.TrimSpace(out.Data.BucketName) + "."
if strings.HasPrefix(host, prefix) {
out.Data.EndPoint = "https://" + host
} else {
out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.BucketName) + "." + host
}
} else {
out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.EndPoint)
}
}
return &out.Data, out.Code, nil
}
func (d *GuangYaPan) waitUploadTaskInfo(ctx context.Context, taskID string) error {
const (
maxTry = 300
interval = 1 * time.Second
)
for i := 0; i < maxTry; i++ {
var out taskInfoResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_info_by_task_id", map[string]any{
"taskId": taskID,
}, &out); err != nil {
return err
}
if out.Data.FileID != "" {
return nil
}
switch out.Code {
case 145, 146, 147, 155, 163, 0:
// uploading/verifying/processing
default:
if strings.TrimSpace(out.Msg) != "" {
return fmt.Errorf("upload task failed: code=%d msg=%s", out.Code, strings.TrimSpace(out.Msg))
}
}
if i == maxTry-1 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
return fmt.Errorf("upload task %s timeout", taskID)
}
func (d *GuangYaPan) multipartUploadToOSS(ctx context.Context, bucket *oss.Bucket, objectPath string, file model.FileStreamer, up driver.UpdateProgress) error {
partSize := calcUploadPartSize(file.GetSize())
imur, err := bucket.InitiateMultipartUpload(objectPath, oss.Sequential())
if err != nil {
return err
}
total := file.GetSize()
partCount := int((total + partSize - 1) / partSize)
// Use StreamSectionReader for seekable, retryable chunk reads (hybrid cache).
ss, err := streamPkg.NewStreamSectionReader(file, int(partSize), &up)
if err != nil {
return err
}
parts := make([]oss.UploadPart, 0, partCount)
var uploaded int64
for i := 0; i < partCount; i++ {
if utils.IsCanceled(ctx) {
return ctx.Err()
}
offset := int64(i) * partSize
length := partSize
if remain := total - offset; length > remain {
length = remain
}
rd, err := ss.GetSectionReader(offset, length)
if err != nil {
return err
}
var part oss.UploadPart
err = retry.Do(func() error {
rd.Seek(0, io.SeekStart)
var uploadErr error
part, uploadErr = bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), length, i+1)
return uploadErr
},
retry.Context(ctx),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Second))
ss.FreeSectionReader(rd)
if err != nil {
return fmt.Errorf("failed to upload part %d: %w", i+1, err)
}
parts = append(parts, part)
uploaded += length
if total > 0 {
up(100 * float64(uploaded) / float64(total))
}
}
_, err = bucket.CompleteMultipartUpload(imur, parts)
return err
}
// --- Normalization helpers ---
func normalizeOSSEndpoint(endpoint, bucket string) string {
ep := strings.TrimSpace(endpoint)
if ep == "" {
return ep
}
if !strings.HasPrefix(ep, "http://") && !strings.HasPrefix(ep, "https://") {
ep = "https://" + ep
}
u, err := url.Parse(ep)
if err != nil || u.Host == "" {
return ep
}
host := u.Host
prefix := strings.TrimSpace(bucket)
if prefix != "" && strings.HasPrefix(host, prefix+".") {
host = strings.TrimPrefix(host, prefix+".")
}
u.Host = host
return u.String()
}
func normalizeDeviceID(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
v = strings.ReplaceAll(v, "-", "")
if len(v) != 32 {
return ""
}
for _, ch := range v {
if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') {
return ""
}
}
return v
}
func randomDeviceID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "0123456789abcdef0123456789abcdef"
}
return hex.EncodeToString(b)
}
func normalizeCaptchaUsername(phone string) string {
p := strings.TrimSpace(phone)
p = strings.ReplaceAll(p, " ", "")
p = strings.TrimPrefix(p, "+")
// Keep only digits.
b := make([]rune, 0, len(p))
for _, ch := range p {
if ch >= '0' && ch <= '9' {
b = append(b, ch)
}
}
digits := string(b)
// Mainland number normalization: +86xxxxxxxxxxx -> xxxxxxxxxxx
if strings.HasPrefix(digits, "86") && len(digits) > 11 {
digits = digits[2:]
}
return digits
}
func normalizePhoneE164(phone string) string {
p := strings.TrimSpace(phone)
if p == "" {
return ""
}
p = strings.ReplaceAll(p, " ", "")
if strings.HasPrefix(p, "+") {
// Format as "+86 1xxxxxxxxxx" to match browser payload expectations.
if strings.HasPrefix(p, "+86") && len(p) > 3 {
rest := strings.TrimPrefix(p, "+86")
return "+86 " + rest
}
return p
}
// If raw mainland number is provided, normalize with +86 prefix.
digits := normalizeCaptchaUsername(p)
if len(digits) == 11 {
return "+86 " + digits
}
return p
}
func calcUploadPartSize(size int64) int64 {
const (
mb = int64(1024 * 1024)
gb = int64(1024 * 1024 * 1024)
)
switch {
case size <= 100*mb:
return 1 * mb
case size <= 16*gb:
return 2 * mb
case size <= 160*gb:
return 4 * mb
default:
return 8 * mb
}
}
// isUploadAlreadyDone reports whether the upload-token response indicates the
// file was already uploaded (instant upload). In that case the backend returns
// a valid TaskID but no OSS credentials, and we should just wait for the task
// instead of starting a real upload.
func isUploadAlreadyDone(msg string) bool {
msg = strings.TrimSpace(msg)
if msg == "" {
return false
}
if strings.EqualFold(msg, "上传已完成") {
return true
}
if strings.EqualFold(msg, "upload completed") {
return true
}
if strings.EqualFold(msg, "already uploaded") {
return true
}
if strings.EqualFold(msg, "秒传成功") {
return true
}
return false
}
+97 -151
View File
@@ -1,4 +1,4 @@
package template
package ilanzou
import (
"context"
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
@@ -27,11 +28,13 @@ type ILanZou struct {
model.Storage
Addition
userID string
account string
upClient *resty.Client
conf Conf
config driver.Config
userID string
account string
apiClient *resty.Client
linkClient *resty.Client
upClient *resty.Client
conf Conf
config driver.Config
}
func (d *ILanZou) Config() driver.Config {
@@ -43,6 +46,18 @@ func (d *ILanZou) GetAddition() driver.Additional {
}
func (d *ILanZou) Init(ctx context.Context) error {
jar, err := cookiejar.New(nil)
if err != nil {
return err
}
// Keep state isolated per storage. The console and CDN issue cookies that
// must survive a retry but must never leak to another configured account.
d.apiClient = base.NewRestyClient().SetCookieJar(jar)
d.linkClient = base.NewRestyClient().SetCookieJar(jar).SetRedirectPolicy(
resty.RedirectPolicyFunc(func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}),
)
d.upClient = base.NewRestyClient().SetTimeout(time.Minute * 10)
if d.UUID == "" {
res, err := d.unproved("/getUuid", http.MethodGet, nil)
@@ -77,8 +92,7 @@ func (d *ILanZou) List(ctx context.Context, dir model.Obj, args model.ListArgs)
"folderId=" + dir.GetID(),
"type=0",
}
queryString := strings.Join(params, "&")
req.SetQueryString(queryString).SetResult(&resp)
req.SetQueryString(strings.Join(params, "&")).SetResult(&resp)
})
if err != nil {
return nil, err
@@ -96,14 +110,12 @@ func (d *ILanZou) List(ctx context.Context, dir model.Obj, args model.ListArgs)
return nil, err
}
obj := model.Object{
ID: strconv.FormatInt(f.FileId, 10),
// Path: "",
ID: strconv.FormatInt(f.FileId, 10),
Name: f.FileName,
Size: f.FileSize * 1024,
Modified: updTime,
Ctime: updTime,
IsFolder: false,
// HashInfo: utils.HashInfo{},
}
if f.FileType == 2 {
obj.IsFolder = true
@@ -133,26 +145,23 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
"devVersion=" + url.QueryEscape(d.conf.devVersion),
"appVersion=",
"timestamp=" + tsStr,
"appToken=" + url.QueryEscape(d.Token),
"appToken=" + appTokenQueryValue(d.Token),
"enable=1",
}
downloadId, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%s", file.GetID(), d.userID)), d.conf.secret)
downloadID, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%s", file.GetID(), d.userID)), d.conf.secret)
if err != nil {
return nil, err
}
params = append(params, "downloadId="+url.QueryEscape(hex.EncodeToString(downloadId)))
params = append(params, "downloadId="+url.QueryEscape(hex.EncodeToString(downloadID)))
auth, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%d", file.GetID(), ts)), d.conf.secret)
if err != nil {
return nil, err
}
params = append(params, "auth="+url.QueryEscape(hex.EncodeToString(auth)))
u.RawQuery = strings.Join(params, "&")
realURL := u.String()
// get the url after redirect
req := base.NoRedirectClient.R().SetContext(ctx)
req := d.linkClient.R().SetContext(ctx)
req.SetHeaders(map[string]string{
"Origin": d.conf.site,
"Referer": d.conf.site + "/",
@@ -162,7 +171,6 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
if d.Addition.Ip != "" {
req.SetHeader("X-Forwarded-For", d.Addition.Ip)
}
res, err := req.Get(realURL)
if err != nil {
return nil, err
@@ -172,49 +180,50 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
realURL = location
} else if res.StatusCode() == http.StatusOK && location != "" {
realURL = location
} else if res.StatusCode() == http.StatusOK {
// Some file types return a 200 JSON resolver response instead of a 3xx.
realURL = utils.Json.Get(res.Body(), "url").ToString()
if realURL == "" {
realURL = utils.Json.Get(res.Body(), "data", "url").ToString()
}
if realURL == "" {
return nil, fmt.Errorf("download resolver returned no URL: %s", utils.Json.Get(res.Body(), "msg").ToString())
}
} else {
return nil, fmt.Errorf("redirect failed, status: %d, location: %s, msg: %s", res.StatusCode(), location, utils.Json.Get(res.Body(), "msg").ToString())
}
link := model.Link{URL: realURL}
return &link, nil
link := &model.Link{URL: realURL}
// Probe the CDN for the actual object size; API metadata can differ from
// the bytes served by the final URL. The timeout bounds Link latency.
headCtx, cancel := context.WithTimeout(ctx, linkHeadTimeout)
defer cancel()
if response, err := d.apiClient.R().SetContext(headCtx).Head(realURL); err == nil && response.StatusCode() >= http.StatusOK && response.StatusCode() < http.StatusMultipleChoices {
if size, parseErr := strconv.ParseInt(response.Header().Get("Content-Length"), 10, 64); parseErr == nil && size > 0 {
link.ContentLength = size
}
}
return link, nil
}
func (d *ILanZou) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
res, err := d.proved("/file/folder/save", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"folderDesc": "",
"folderId": parentDir.GetID(),
"folderName": dirName,
})
req.SetBody(base.Json{"folderDesc": "", "folderId": parentDir.GetID(), "folderName": dirName})
})
if err != nil {
return nil, err
}
return &model.Object{
ID: utils.Json.Get(res, "list", 0, "id").ToString(),
// Path: "",
Name: dirName,
Size: 0,
Modified: time.Now(),
Ctime: time.Now(),
IsFolder: true,
// HashInfo: utils.HashInfo{},
}, nil
return &model.Object{ID: utils.Json.Get(res, "list", 0, "id").ToString(), Name: dirName, Modified: time.Now(), Ctime: time.Now(), IsFolder: true}, nil
}
func (d *ILanZou) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
var fileIds, folderIds []string
var fileIDs, folderIDs []string
if srcObj.IsDir() {
folderIds = []string{srcObj.GetID()}
folderIDs = []string{srcObj.GetID()}
} else {
fileIds = []string{srcObj.GetID()}
fileIDs = []string{srcObj.GetID()}
}
_, err := d.proved("/file/folder/move", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"folderIds": strings.Join(folderIds, ","),
"fileIds": strings.Join(fileIds, ","),
"targetId": dstDir.GetID(),
})
req.SetBody(base.Json{"folderIds": strings.Join(folderIDs, ","), "fileIds": strings.Join(fileIDs, ","), "targetId": dstDir.GetID()})
})
if err != nil {
return nil, err
@@ -226,58 +235,47 @@ func (d *ILanZou) Rename(ctx context.Context, srcObj model.Obj, newName string)
var err error
if srcObj.IsDir() {
_, err = d.proved("/file/folder/edit", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"folderDesc": "",
"folderId": srcObj.GetID(),
"folderName": newName,
})
req.SetBody(base.Json{"folderDesc": "", "folderId": srcObj.GetID(), "folderName": newName})
})
} else {
_, err = d.proved("/file/edit", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"fileDesc": "",
"fileId": srcObj.GetID(),
"fileName": newName,
})
req.SetBody(base.Json{"fileDesc": "", "fileId": srcObj.GetID(), "fileName": newName})
})
}
if err != nil {
return nil, err
}
return &model.Object{
ID: srcObj.GetID(),
// Path: "",
Name: newName,
Size: srcObj.GetSize(),
Modified: time.Now(),
Ctime: srcObj.CreateTime(),
IsFolder: srcObj.IsDir(),
}, nil
return &model.Object{ID: srcObj.GetID(), Name: newName, Size: srcObj.GetSize(), Modified: time.Now(), Ctime: srcObj.CreateTime(), IsFolder: srcObj.IsDir()}, nil
}
// iLanzou has no server-side copy primitive. Returning NotImplement delegates
// to OpenList's persistent CopyTaskManager instead of blocking the HTTP request.
func (d *ILanZou) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
// TODO copy obj, optional
return nil, errs.NotImplement
}
func (d *ILanZou) Remove(ctx context.Context, obj model.Obj) error {
var fileIds, folderIds []string
var fileIDs, folderIDs []string
if obj.IsDir() {
folderIds = []string{obj.GetID()}
folderIDs = []string{obj.GetID()}
} else {
fileIds = []string{obj.GetID()}
fileIDs = []string{obj.GetID()}
}
_, err := d.proved("/file/delete", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"folderIds": strings.Join(folderIds, ","),
"fileIds": strings.Join(fileIds, ","),
"status": 0,
})
req.SetBody(base.Json{"folderIds": strings.Join(folderIDs, ","), "fileIds": strings.Join(fileIDs, ","), "status": 0})
})
return err
}
const DefaultPartSize = 1024 * 1024 * 8
const (
DefaultPartSize = 1024 * 1024 * 8
// The results endpoint is eventually consistent after the upload commit.
maxUploadCommitRetries = 10
uploadCommitRetryDelay = time.Second
linkHeadTimeout = 5 * time.Second
)
func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
etag := s.GetHash().GetHash(utils.MD5)
@@ -288,55 +286,31 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
return nil, err
}
}
// get upToken
fileSizeKiB := (s.GetSize() + 1023) / 1024
if fileSizeKiB < 1 {
fileSizeKiB = 1
}
res, err := d.proved("/7n/getUpToken", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"fileId": "",
"fileName": s.GetName(),
"fileSize": s.GetSize()/1024 + 1,
"folderId": dstDir.GetID(),
"md5": etag,
"type": 1,
})
req.SetBody(base.Json{"fileId": "", "fileName": s.GetName(), "fileSize": fileSizeKiB, "folderId": dstDir.GetID(), "md5": etag, "type": 1})
})
if err != nil {
return nil, err
}
upToken := utils.Json.Get(res, "upToken").ToString()
if upToken == "-1" {
// 支持秒传
var resp UploadTokenRapidResp
err := utils.Json.Unmarshal(res, &resp)
if err != nil {
if err := utils.Json.Unmarshal(res, &resp); err != nil {
return nil, err
}
return &model.Object{
ID: strconv.FormatInt(resp.Map.FileID, 10),
Name: resp.Map.FileName,
Size: s.GetSize(),
Modified: s.ModTime(),
Ctime: s.CreateTime(),
IsFolder: false,
HashInfo: utils.NewHashInfo(utils.MD5, etag),
}, nil
return &model.Object{ID: strconv.FormatInt(resp.Map.FileID, 10), Name: resp.Map.FileName, Size: s.GetSize(), Modified: s.ModTime(), Ctime: s.CreateTime(), IsFolder: false, HashInfo: utils.NewHashInfo(utils.MD5, etag)}, nil
}
now := time.Now()
key := fmt.Sprintf("disk/%d/%d/%d/%s/%016d", now.Year(), now.Month(), now.Day(), d.account, now.UnixMilli())
reader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
Reader: &driver.SimpleReaderWithSize{
Reader: s,
Size: s.GetSize(),
},
UpdateProgress: up,
})
// Match the current console's generated Qiniu object key.
key := fmt.Sprintf("disk/%04d/%02d/%02d/%s/%d.rar", now.Year(), now.Month(), now.Day(), d.account, now.UnixMilli())
reader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{Reader: &driver.SimpleReaderWithSize{Reader: s, Size: s.GetSize()}, UpdateProgress: up})
var token string
if s.GetSize() <= DefaultPartSize {
res, err := d.upClient.R().SetContext(ctx).SetMultipartFormData(map[string]string{
"token": upToken,
"key": key,
"fname": s.GetName(),
}).SetMultipartField("file", s.GetName(), s.GetMimetype(), reader).
Post("https://upload.qiniup.com/")
res, err := d.upClient.R().SetContext(ctx).SetMultipartFormData(map[string]string{"token": upToken, "key": key, "fname": s.GetName()}).SetMultipartField("file", s.GetName(), s.GetMimetype(), reader).Post("https://upload.qiniup.com/")
if err != nil {
return nil, err
}
@@ -347,40 +321,27 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
if err != nil {
return nil, err
}
uploadId := utils.Json.Get(res.Body(), "uploadId").ToString()
uploadID := utils.Json.Get(res.Body(), "uploadId").ToString()
parts := make([]Part, 0)
partNum := (s.GetSize() + DefaultPartSize - 1) / DefaultPartSize
for i := 1; i <= int(partNum); i++ {
u := fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s/%d", d.conf.bucket, keyBase64, uploadId, i)
u := fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s/%d", d.conf.bucket, keyBase64, uploadID, i)
res, err = d.upClient.R().SetContext(ctx).SetHeader("Authorization", "UpToken "+upToken).SetBody(io.LimitReader(reader, DefaultPartSize)).Put(u)
if err != nil {
return nil, err
}
etag := utils.Json.Get(res.Body(), "etag").ToString()
parts = append(parts, Part{
PartNumber: i,
ETag: etag,
})
parts = append(parts, Part{PartNumber: i, ETag: utils.Json.Get(res.Body(), "etag").ToString()})
}
res, err = d.upClient.R().SetHeader("Authorization", "UpToken "+upToken).SetBody(base.Json{
"fnmae": s.GetName(),
"parts": parts,
}).Post(fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s", d.conf.bucket, keyBase64, uploadId))
res, err = d.upClient.R().SetHeader("Authorization", "UpToken "+upToken).SetBody(base.Json{"fnmae": s.GetName(), "parts": parts}).Post(fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s", d.conf.bucket, keyBase64, uploadID))
if err != nil {
return nil, err
}
token = utils.Json.Get(res.Body(), "token").ToString()
}
// commit upload
var resp UploadResultResp
for i := 0; i < 10; i++ {
for i := 0; i < maxUploadCommitRetries; i++ {
_, err = d.unproved("/7n/results", http.MethodPost, func(req *resty.Request) {
params := []string{
"tokenList=" + token,
"tokenTime=" + time.Now().Format("Mon Jan 02 2006 15:04:05 GMT-0700 (MST)"),
}
queryString := strings.Join(params, "&")
req.SetQueryString(queryString).SetResult(&resp)
req.SetQueryString("tokenList=" + token + "&tokenTime=" + time.Now().Format("Mon Jan 02 2006 15:04:05 GMT-0700 (MST)")).SetResult(&resp)
})
if err != nil {
return nil, err
@@ -391,22 +352,17 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
if resp.List[0].Status == 1 {
break
}
time.Sleep(time.Second * 1)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(uploadCommitRetryDelay):
}
}
file := resp.List[0]
if file.Status != 1 {
return nil, fmt.Errorf("upload failed, status: %d", resp.List[0].Status)
return nil, fmt.Errorf("upload failed, status: %d", file.Status)
}
return &model.Object{
ID: strconv.FormatInt(file.FileId, 10),
// Path: ,
Name: file.FileName,
Size: s.GetSize(),
Modified: s.ModTime(),
Ctime: s.CreateTime(),
IsFolder: false,
HashInfo: utils.NewHashInfo(utils.MD5, etag),
}, nil
return &model.Object{ID: strconv.FormatInt(file.FileId, 10), Name: file.FileName, Size: s.GetSize(), Modified: s.ModTime(), Ctime: s.CreateTime(), IsFolder: false, HashInfo: utils.NewHashInfo(utils.MD5, etag)}, nil
}
func (d *ILanZou) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
@@ -419,18 +375,8 @@ func (d *ILanZou) GetDetails(ctx context.Context) (*model.StorageDetails, error)
vipSize := utils.Json.Get(res, "map", "vipSize").ToInt64() * 1024
totalSize := utils.Json.Get(res, "map", "totalSize").ToInt64() * 1024
rewardSize := utils.Json.Get(res, "map", "rewardSize").ToInt64() * 1024
total := totalSize + rewardSize + vipSize
used := utils.Json.Get(res, "map", "usedSize").ToInt64() * 1024
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
},
}, nil
return &model.StorageDetails{DiskUsage: model.DiskUsage{TotalSpace: totalSize + rewardSize + vipSize, UsedSpace: used}}, nil
}
//func (d *ILanZou) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
// return nil, errs.NotSupport
//}
var _ driver.Driver = (*ILanZou)(nil)
+1 -1
View File
@@ -1,4 +1,4 @@
package template
package ilanzou
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
+1 -1
View File
@@ -1,4 +1,4 @@
package template
package ilanzou
type ListResp struct {
Msg string `json:"msg"`
+36 -23
View File
@@ -1,4 +1,4 @@
package template
package ilanzou
import (
"encoding/hex"
@@ -18,10 +18,7 @@ import (
func (d *ILanZou) login() error {
res, err := d.unproved("/login", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{
"loginName": d.Username,
"loginPwd": d.Password,
})
req.SetBody(base.Json{"loginName": d.Username, "loginPwd": d.Password})
})
if err != nil {
return err
@@ -35,20 +32,33 @@ func (d *ILanZou) login() error {
func getTimestamp(secret []byte) (int64, string, error) {
ts := time.Now().UnixMilli()
tsStr := strconv.FormatInt(ts, 10)
res, err := mopan.AesEncrypt([]byte(tsStr), secret)
res, err := mopan.AesEncrypt([]byte(strconv.FormatInt(ts, 10)), secret)
if err != nil {
return 0, "", err
}
return ts, hex.EncodeToString(res), nil
}
// isCDNChallenge detects a transient CDN challenge (409 Conflict + HTML 403).
// A retry with the session cookie jar can resolve transient challenges.
func isCDNChallenge(res *resty.Response) bool {
if res == nil || res.StatusCode() != http.StatusConflict {
return false
}
return strings.Contains(res.Header().Get("Content-Type"), "text/html") && strings.Contains(string(res.Body()), "403")
}
func appTokenQueryValue(token string) string {
// iLanzou requires a literal colon, while other reserved token characters
// must remain query-escaped.
return strings.ReplaceAll(url.QueryEscape(token), "%3A", ":")
}
func (d *ILanZou) request(pathname, method string, callback base.ReqCallback, proved bool, retry ...bool) ([]byte, error) {
_, ts_str, err := getTimestamp(d.conf.secret)
_, timestamp, err := getTimestamp(d.conf.secret)
if err != nil {
return nil, err
}
params := []string{
"uuid=" + url.QueryEscape(d.UUID),
"devType=6",
@@ -56,48 +66,51 @@ func (d *ILanZou) request(pathname, method string, callback base.ReqCallback, pr
"devModel=chrome",
"devVersion=" + url.QueryEscape(d.conf.devVersion),
"appVersion=",
"timestamp=" + ts_str,
"timestamp=" + timestamp,
}
if proved {
params = append(params, "appToken="+url.QueryEscape(d.Token))
params = append(params, "appToken="+appTokenQueryValue(d.Token))
}
params = append(params, "extra=2")
queryString := strings.Join(params, "&")
req := base.RestyClient.R()
if d.apiClient == nil {
return nil, fmt.Errorf("iLanzou driver is not initialized")
}
req := d.apiClient.R()
req.SetHeaders(map[string]string{
"Origin": d.conf.site,
"Referer": d.conf.site + "/",
"Accept-Encoding": "gzip",
"Accept-Language": "zh-CN,zh;q=0.9,en-US,en;q=0.8",
})
if d.Addition.Ip != "" {
req.SetHeader("X-Forwarded-For", d.Addition.Ip)
}
if callback != nil {
callback(req)
}
res, err := req.Execute(method, d.conf.base+pathname+"?"+queryString)
res, err := req.Execute(method, d.conf.base+pathname+"?"+strings.Join(params, "&"))
if err != nil {
if res != nil {
log.Errorf("[iLanZou] request error: %s", res.String())
log.Errorf("[ilanzou] request error: %s", res.String())
}
return nil, err
}
isRetry := len(retry) > 0 && retry[0]
if isCDNChallenge(res) {
// Resty's cookie jar records the challenge cookie from the first response.
// A second request proves whether it is a transient challenge or hard block.
if !isRetry {
return d.request(pathname, method, callback, proved, true)
}
return nil, fmt.Errorf("iLanzou CDN rejected %s with HTML 403; retry later or delete from the console", pathname)
}
body := res.Body()
code := utils.Json.Get(body, "code").ToInt()
msg := utils.Json.Get(body, "msg").ToString()
if code != 200 {
if !isRetry && proved && (utils.SliceContains([]int{-1, -2}, code) || d.Token == "") {
err = d.login()
if err != nil {
if err := d.login(); err != nil {
return nil, err
}
return d.request(pathname, method, callback, proved, true)
+11
View File
@@ -0,0 +1,11 @@
package ilanzou
import "testing"
func TestAppTokenQueryValue(t *testing.T) {
got := appTokenQueryValue("alpha:beta+gamma&delta#percent%")
want := "alpha:beta%2Bgamma%26delta%23percent%25"
if got != want {
t.Fatalf("appTokenQueryValue() = %q, want %q", got, want)
}
}
+10 -16
View File
@@ -170,23 +170,17 @@ var findKVReg = regexp.MustCompile(`'(.+?)':('?([^' },]*)'?)`) // 拆分kv
// 根据key查询js变量
func findJSVarFunc(key, data string) string {
var values []string
if key != "sasign" {
values = regexp.MustCompile(`var ` + key + `\s*=\s*['"]?(.+?)['"]?;`).FindStringSubmatch(data)
} else {
matches := regexp.MustCompile(`var `+key+`\s*=\s*['"]?(.+?)['"]?;`).FindAllStringSubmatch(data, -1)
if len(matches) == 3 {
values = matches[1]
} else {
if len(matches) > 0 {
values = matches[0]
}
}
}
if len(values) == 0 {
re := regexp.MustCompile(`var\s+` + regexp.QuoteMeta(key) + `\s*=\s*['"]?([^'"]*)['"]?\s*;`)
matches := re.FindAllStringSubmatch(data, -1)
if len(matches) == 0 {
return ""
}
return values[1]
for i := len(matches) - 1; i >= 0; i-- {
if matches[i][1] != "" {
return matches[i][1]
}
}
return matches[len(matches)-1][1]
}
var findFunction = regexp.MustCompile(`(?ims)^function[^{]+`)
@@ -304,7 +298,7 @@ var findFromReg = regexp.MustCompile(`data : '(.+?)'`) // 查找from字符串
func htmlFormToMap(html string) (map[string]string, error) {
forms := findFromReg.FindStringSubmatch(html)
if len(forms) != 2 {
return nil, fmt.Errorf("not find file sgin")
return nil, fmt.Errorf("not find file sign")
}
return formToMap(forms[1]), nil
}
+2 -2
View File
@@ -132,7 +132,7 @@ type FileOrFolderByShareUrl struct {
//T int `json:"t"`
// 文件夹特有
IsFloder bool `json:"-"`
IsFolder bool `json:"-"`
//
Url string `json:"-"`
@@ -162,7 +162,7 @@ func (f *FileOrFolderByShareUrl) GetSize() int64 {
}
return *f.size
}
func (f *FileOrFolderByShareUrl) IsDir() bool { return f.IsFloder }
func (f *FileOrFolderByShareUrl) IsDir() bool { return f.IsFolder }
func (f *FileOrFolderByShareUrl) ModTime() time.Time {
if f.time == nil {
time := MustParseTime(f.Time)
+90 -41
View File
@@ -157,25 +157,43 @@ func (d *LanZou) request(url string, method string, callback base.ReqCallback, u
}
func (d *LanZou) Login() ([]*http.Cookie, error) {
resp, err := base.NewRestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()).
R().SetFormData(map[string]string{
"task": "3",
"uid": d.Account,
"pwd": d.Password,
"setSessionId": "",
"setSig": "",
"setScene": "",
"setTocen": "",
"formhash": "",
}).Post("https://up.woozooo.com/mlogin.php")
if err != nil {
return nil, err
var vs string
for retry := 0; retry < 3; retry++ {
req := base.NewRestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()).R()
// 如果已计算出 acw_sc__v2,通过 cookie 携带
if vs != "" {
req.SetHeader("cookie", "acw_sc__v2="+vs)
}
resp, err := req.SetFormData(map[string]string{
"task": "3",
"uid": d.Account,
"pwd": d.Password,
"setSessionId": "",
"setSig": "",
"setScene": "",
"setTocen": "",
"formhash": "",
}).Post("https://up.woozooo.com/mlogin.php")
if err != nil {
return nil, err
}
bodyStr := resp.String()
if strings.Contains(bodyStr, "acw_sc__v2") {
vs, err = CalcAcwScV2(bodyStr)
if err != nil {
return nil, err
}
continue
}
if utils.Json.Get(resp.Body(), "zt").ToInt() != 1 {
return nil, fmt.Errorf("login err: %s", resp.Body())
}
d.Cookie = CookieToString(resp.Cookies())
return resp.Cookies(), nil
}
if utils.Json.Get(resp.Body(), "zt").ToInt() != 1 {
return nil, fmt.Errorf("login err: %s", resp.Body())
}
d.Cookie = CookieToString(resp.Cookies())
return resp.Cookies(), nil
return nil, errors.New("acw_sc__v2 validation error")
}
/*
@@ -295,7 +313,37 @@ var findSubFolderReg = regexp.MustCompile(`(?i)(?:folderlink|mbxfolder).+href="/
var findDownPageParamReg = regexp.MustCompile(`<iframe.*?src="(.+?)"`)
// 获取文件ID
var findFileIDReg = regexp.MustCompile(`'/ajaxm\.php\?file=(\d+)'`)
var findFileIDReg = regexp.MustCompile(`'/ajax(?:file|m)\.php\?file=(\d+)'`)
// 2026-10 改版:文件页将下载参数移入 /fn? 内页,接口变为 apifile 绝对地址并携带签名
var (
fnDomainReg = regexp.MustCompile(`var\s+domain[12]\s*=\s*'([^']*(?:ajaxfile|ajaxm)\.php\?file=(\d+)[^']*)'`)
fnSignReg = regexp.MustCompile(`var\s+wp_sign\s*=\s*'([^']*)'`)
fnAjaxDataReg = regexp.MustCompile(`var\s+ajaxdata\s*=\s*'([^']*)'`)
)
// parseFnPage 从改版后的 /fn? 内页提取下载接口地址与签名表单
// 对应页面 JS:POST domain1 {'action':'downprocess','websignkey':ajaxdata,'signs':ajaxdata,'sign':wp_sign,'websign':'2','kd':kdns,'ves':1}
func parseFnPage(pageData string) (string, map[string]string, error) {
matches := fnDomainReg.FindStringSubmatch(pageData)
if len(matches) < 3 {
return "", nil, fmt.Errorf("not find fn ajax url")
}
sign := fnSignReg.FindStringSubmatch(pageData)
ajaxdata := fnAjaxDataReg.FindStringSubmatch(pageData)
if len(sign) < 2 || len(ajaxdata) < 2 {
return "", nil, fmt.Errorf("not find fn sign")
}
return matches[1], map[string]string{
"action": "downprocess",
"websignkey": ajaxdata[1],
"signs": ajaxdata[1],
"sign": sign[1],
"websign": "2",
"kd": "1",
"ves": "1",
}, nil
}
// 获取分享链接主界面
func (d *LanZou) getShareUrlHtml(shareID string) (string, error) {
@@ -394,15 +442,13 @@ func (d *LanZou) getFilesByShareUrl(shareID, pwd string, sharePageData string) (
}
param["p"] = pwd
fileIDs := findFileIDReg.FindStringSubmatch(sharePageData)
var fileID string
if len(fileIDs) > 1 {
fileID = fileIDs[1]
} else {
matches := findFileIDReg.FindStringSubmatch(sharePageData)
if len(matches) < 2 {
return nil, fmt.Errorf("not find file id")
}
ajaxUrl := d.ShareUrl + matches[0][1:len(matches[0])-1]
var resp FileShareInfoAndUrlResp[string]
_, err = d.post(d.ShareUrl+"/ajaxm.php?file="+fileID, func(req *resty.Request) { req.SetFormData(param) }, &resp)
_, err = d.post(ajaxUrl, func(req *resty.Request) { req.SetFormData(param) }, &resp)
if err != nil {
return nil, err
}
@@ -421,20 +467,23 @@ func (d *LanZou) getFilesByShareUrl(shareID, pwd string, sharePageData string) (
return nil, err
}
nextPageData := RemoveNotes(string(data))
param, err = htmlJsonToMap(nextPageData)
if err != nil {
return nil, err
}
fileIDs := findFileIDReg.FindStringSubmatch(nextPageData)
var fileID string
if len(fileIDs) > 1 {
fileID = fileIDs[1]
var resp FileShareInfoAndUrlResp[int]
matches := findFileIDReg.FindStringSubmatch(nextPageData)
if len(matches) >= 2 {
// 旧版结构:相对路径 /ajaxm.php?file=N
param, err = htmlJsonToMap(nextPageData)
if err != nil {
return nil, err
}
ajaxUrl := d.ShareUrl + matches[0][1:len(matches[0])-1]
_, err = d.post(ajaxUrl, func(req *resty.Request) { req.SetFormData(param) }, &resp)
} else if fnUrl, fnForm, ferr := parseFnPage(nextPageData); ferr == nil {
// 2026-10 改版结构:/fn? 内页携带 apifile 绝对地址与签名参数
_, err = d.post(fnUrl, func(req *resty.Request) { req.SetFormData(fnForm) }, &resp)
} else {
return nil, fmt.Errorf("not find file id")
}
var resp FileShareInfoAndUrlResp[int]
_, err = d.post(d.ShareUrl+"/ajaxm.php?file="+fileID, func(req *resty.Request) { req.SetFormData(param) }, &resp)
if err != nil {
return nil, err
}
@@ -572,14 +621,14 @@ func (d *LanZou) getFolderByShareUrl(pwd string, sharePageData string) ([]FileOr
files := make([]FileOrFolderByShareUrl, 0)
// vip获取文件夹
floders := findSubFolderReg.FindAllStringSubmatch(sharePageData, -1)
for _, floder := range floders {
if len(floder) == 3 {
folders := findSubFolderReg.FindAllStringSubmatch(sharePageData, -1)
for _, folder := range folders {
if len(folder) == 3 {
files = append(files, FileOrFolderByShareUrl{
// Pwd: pwd, // 子文件夹不加密
ID: floder[1],
NameAll: floder[2],
IsFloder: true,
ID: folder[1],
NameAll: folder[2],
IsFolder: true,
})
}
}
+2 -4
View File
@@ -14,7 +14,6 @@ import (
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
@@ -153,8 +152,7 @@ func (d *Local) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([
func (d *Local) FileInfoToObj(ctx context.Context, f fs.FileInfo, reqPath string, fullPath string) model.Obj {
thumb := ""
if d.Thumbnail {
typeName := utils.GetFileType(f.Name())
if typeName == conf.IMAGE || typeName == conf.VIDEO {
if d.supportsThumbnail(f.Name()) {
thumb = common.GetApiUrl(ctx) + stdpath.Join("/d", reqPath, f.Name())
thumb = utils.EncodePath(thumb, true)
thumb += "?type=thumb&sign=" + sign.Sign(stdpath.Join(reqPath, f.Name()))
@@ -240,7 +238,7 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
var thumbPath *string
err := d.thumbTokenBucket.Do(ctx, func() error {
var err error
buf, thumbPath, err = d.getThumb(file)
buf, thumbPath, err = d.getThumb(ctx, file)
return err
})
if err != nil {
+1
View File
@@ -9,6 +9,7 @@ type Addition struct {
driver.RootPath
DirectorySize bool `json:"directory_size" default:"false" help:"This might impact host performance"`
Thumbnail bool `json:"thumbnail" required:"true" help:"enable thumbnail"`
PDFThumbnail bool `json:"pdf_thumbnail" default:"false" required:"false" help:"Generate PDF first-page thumbnails with Quick Look on macOS"`
ThumbCacheFolder string `json:"thumb_cache_folder"`
ThumbConcurrency string `json:"thumb_concurrency" default:"16" required:"false" help:"Number of concurrent thumbnail generation goroutines. This controls how many thumbnails can be generated in parallel."`
VideoThumbPos string `json:"video_thumb_pos" default:"20%" required:"false" help:"The position of the video thumbnail. If the value is a number (integer ot floating point), it represents the time in seconds. If the value ends with '%', it represents the percentage of the video duration."`
+45
View File
@@ -0,0 +1,45 @@
//go:build darwin
package local
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
)
func pdfThumbnailSupported() bool {
return true
}
func renderPDFThumbnail(ctx context.Context, fullPath string) (*bytes.Buffer, error) {
tempDir, err := os.MkdirTemp("", "openlist-pdf-thumb-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tempDir)
renderCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(renderCtx, "/usr/bin/qlmanage", "-t", "-s", "512", "-o", tempDir, fullPath)
if output, err := cmd.CombinedOutput(); err != nil {
if renderCtx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("render PDF thumbnail timed out: %w", renderCtx.Err())
}
if renderCtx.Err() != nil {
return nil, fmt.Errorf("render PDF thumbnail canceled: %w", renderCtx.Err())
}
return nil, fmt.Errorf("render PDF thumbnail: %w: %s", err, bytes.TrimSpace(output))
}
thumbPath := filepath.Join(tempDir, filepath.Base(fullPath)+".png")
data, err := os.ReadFile(thumbPath)
if err != nil {
return nil, fmt.Errorf("read rendered PDF thumbnail: %w", err)
}
return bytes.NewBuffer(data), nil
}
+53
View File
@@ -0,0 +1,53 @@
//go:build darwin
package local
import (
"bytes"
"context"
"errors"
"image/png"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestRenderPDFThumbnailDarwin(t *testing.T) {
tempDir := t.TempDir()
textPath := filepath.Join(tempDir, "source.txt")
pdfPath := filepath.Join(tempDir, "source 文件.pdf")
if err := os.WriteFile(textPath, []byte("OpenList PDF thumbnail integration test\n"), 0o600); err != nil {
t.Fatal(err)
}
cmd := exec.Command("/usr/sbin/cupsfilter", textPath)
pdfData, err := cmd.Output()
if err != nil {
t.Fatalf("create fixture PDF: %v", err)
}
if err := os.WriteFile(pdfPath, pdfData, 0o600); err != nil {
t.Fatal(err)
}
thumb, err := renderPDFThumbnail(context.Background(), pdfPath)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(thumb.Bytes(), []byte("\x89PNG\r\n\x1a\n")) {
t.Fatal("rendered thumbnail is not PNG")
}
cfg, err := png.DecodeConfig(bytes.NewReader(thumb.Bytes()))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
t.Fatalf("invalid thumbnail dimensions: %dx%d", cfg.Width, cfg.Height)
}
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := renderPDFThumbnail(canceledCtx, pdfPath); !errors.Is(err, context.Canceled) {
t.Fatalf("renderPDFThumbnail with canceled context returned %v, want context.Canceled", err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package local
import (
"testing"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
)
func TestSupportsThumbnail(t *testing.T) {
oldImages := conf.SlicesMap[conf.ImageTypes]
oldVideos := conf.SlicesMap[conf.VideoTypes]
conf.SlicesMap[conf.ImageTypes] = []string{"jpg"}
conf.SlicesMap[conf.VideoTypes] = []string{"mp4"}
t.Cleanup(func() {
conf.SlicesMap[conf.ImageTypes] = oldImages
conf.SlicesMap[conf.VideoTypes] = oldVideos
})
tests := []struct {
name string
fileName string
pdfThumbnail bool
want bool
}{
{name: "image", fileName: "cover.jpg", want: true},
{name: "video", fileName: "movie.mp4", want: true},
{name: "PDF disabled by default", fileName: "document.pdf", want: false},
{name: "unrelated document", fileName: "document.txt", pdfThumbnail: true, want: false},
{name: "PDF enabled when renderer is available", fileName: "document.PDF", pdfThumbnail: true, want: pdfThumbnailSupported()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := &Local{Addition: Addition{PDFThumbnail: tt.pdfThumbnail}}
if got := d.supportsThumbnail(tt.fileName); got != tt.want {
t.Fatalf("supportsThumbnail(%q) = %v, want %v", tt.fileName, got, tt.want)
}
})
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !darwin
package local
import (
"bytes"
"context"
"errors"
)
func pdfThumbnailSupported() bool {
return false
}
func renderPDFThumbnail(context.Context, string) (*bytes.Buffer, error) {
return nil, errors.New("PDF thumbnails are not supported on this platform")
}
+22 -2
View File
@@ -2,6 +2,7 @@ package local
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -127,7 +128,19 @@ func (d *Local) removeThumbCache(fullPath string) {
_ = os.Remove(thumbPath)
}
func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
func (d *Local) supportsThumbnail(name string) bool {
typeName := utils.GetFileType(name)
if typeName == conf.IMAGE || typeName == conf.VIDEO {
return true
}
return d.supportsPDFThumbnail(name)
}
func (d *Local) supportsPDFThumbnail(name string) bool {
return d.PDFThumbnail && pdfThumbnailSupported() && strings.EqualFold(filepath.Ext(name), ".pdf")
}
func (d *Local) getThumb(ctx context.Context, file model.Obj) (*bytes.Buffer, *string, error) {
fullPath := file.GetPath()
if d.ThumbCacheFolder != "" {
// skip if the file is a thumbnail
@@ -140,12 +153,19 @@ func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
}
}
var srcBuf *bytes.Buffer
if utils.GetFileType(file.GetName()) == conf.VIDEO {
typeName := utils.GetFileType(file.GetName())
if typeName == conf.VIDEO {
videoBuf, err := d.GetSnapshot(fullPath)
if err != nil {
return nil, nil, err
}
srcBuf = videoBuf
} else if d.supportsPDFThumbnail(file.GetName()) {
pdfBuf, err := renderPDFThumbnail(ctx, fullPath)
if err != nil {
return nil, nil, err
}
srcBuf = pdfBuf
} else {
imgData, err := os.ReadFile(fullPath)
if err != nil {
+29 -24
View File
@@ -2,7 +2,6 @@ package onedrive_sharelink
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
@@ -13,30 +12,28 @@ import (
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
internalNet "github.com/OpenListTeam/OpenList/v4/internal/net"
log "github.com/sirupsen/logrus"
"golang.org/x/net/html"
)
// NewNoRedirectClient creates an HTTP client that doesn't follow redirects
func NewNoRedirectCLient() *http.Client {
return &http.Client{
Timeout: time.Hour * 48,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.Conf.TlsInsecureSkipVerify},
},
// Prevent following redirects
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
client := internalNet.NewHttpClient()
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return client
}
// getCookiesWithPassword fetches cookies required for authenticated access using the provided password
func getCookiesWithPassword(link, password string) (string, error) {
func getCookiesWithPassword(ctx context.Context, link, password string) (string, error) {
// Send GET request
resp, err := http.Get(link)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
return "", err
}
resp, err := base.HttpClient.Do(req)
if err != nil {
return "", err
}
@@ -95,16 +92,18 @@ func getCookiesWithPassword(link, password string) (string, error) {
"__VIEWSTATEENCRYPTED": []string{""},
}
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
client := NewNoRedirectCLient()
// Send the POST request, preventing redirects
resp, err = client.PostForm(newURL, data)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, newURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Extract the desired cookie value
cookie := resp.Cookies()
@@ -153,6 +152,7 @@ func (d *OnedriveSharelink) getHeaders(ctx context.Context) (http.Header, error)
if err != nil {
return nil, err
}
defer answerNoRedirect.Body.Close()
redirectUrl := answerNoRedirect.Header.Get("Location")
log.Debugln("redirectUrl:", redirectUrl)
if redirectUrl == "" {
@@ -169,7 +169,7 @@ func (d *OnedriveSharelink) getHeaders(ctx context.Context) (http.Header, error)
header.Set("authority", u.Host)
return header, nil
} else {
cookie, err := getCookiesWithPassword(d.ShareLinkURL, d.ShareLinkPassword)
cookie, err := getCookiesWithPassword(ctx, d.ShareLinkURL, d.ShareLinkPassword)
if err != nil {
return nil, err
}
@@ -197,6 +197,7 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
if err != nil {
return nil, err
}
_ = answerNoRedirect.Body.Close()
redirectUrl = answerNoRedirect.Header.Get("Location")
} else {
header = d.Headers
@@ -205,6 +206,7 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
if err != nil {
return nil, err
}
_ = answerNoRedirect.Body.Close()
redirectUrl = answerNoRedirect.Header.Get("Location")
}
redirectSplitURL := strings.Split(redirectUrl, "/")
@@ -289,9 +291,9 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
}
tempHeader["Content-Type"] = []string{"application/json;odata=verbose"}
client := &http.Client{}
client := base.HttpClient
postUrl := strings.Join(redirectSplitURL[:len(redirectSplitURL)-3], "/") + "/_api/v2.1/graphql"
req, err = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(graphqlVar))
req, err = http.NewRequestWithContext(ctx, http.MethodPost, postUrl, strings.NewReader(graphqlVar))
if err != nil {
return nil, err
}
@@ -326,7 +328,10 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
log.Debugln("nextHref:", nextHref)
graphqlReqNEW := GraphQLNEWRequest{}
postUrl = strings.Join(redirectSplitURL[:len(redirectSplitURL)-3], "/") + "/_api/web/GetListUsingPath(DecodedUrl=@a1)/RenderListDataAsStream" + nextHref
req, _ = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar))
req, err = http.NewRequestWithContext(ctx, http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar))
if err != nil {
return nil, err
}
req.Header = tempHeader
resp, err := client.Do(req)
+46
View File
@@ -0,0 +1,46 @@
package onedrive_sharelink
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
internalNet "github.com/OpenListTeam/OpenList/v4/internal/net"
)
func TestNoRedirectClientUsesSharedSettings(t *testing.T) {
client := NewNoRedirectCLient()
sharedClient := internalNet.NewHttpClient()
if client.Timeout != sharedClient.Timeout {
t.Fatalf("expected shared timeout %s, got %s", sharedClient.Timeout, client.Timeout)
}
if reflect.TypeOf(client.Transport) != reflect.TypeOf(sharedClient.Transport) {
t.Fatalf("expected shared transport type %T, got %T", sharedClient.Transport, client.Transport)
}
}
func TestNoRedirectClientStopsRedirects(t *testing.T) {
targetRequested := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/target" {
targetRequested = true
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, "/target", http.StatusFound)
}))
defer server.Close()
resp, err := NewNoRedirectCLient().Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected redirect response, got %d", resp.StatusCode)
}
if targetRequested {
t.Fatal("redirect target was requested")
}
}
+2 -1
View File
@@ -10,10 +10,11 @@ type Addition struct {
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true"`
Platform string `json:"platform" required:"true" default:"web" type:"select" options:"android,web,pc"`
RefreshToken string `json:"refresh_token" required:"true" default:""`
RefreshToken string `json:"refresh_token" required:"false" default:""`
CaptchaToken string `json:"captcha_token" default:""`
DeviceID string `json:"device_id" required:"false" default:""`
DisableMediaLink bool `json:"disable_media_link" default:"true"`
SkipVerification bool `json:"skip_verification" default:"false" help:"ignore the human verification URL returned by the captcha API instead of failing; enabling this may trigger PikPak risk control"`
}
var config = driver.Config{
+34 -12
View File
@@ -100,12 +100,13 @@ func (d *PikPak) login() error {
return errors.New("username or password is empty")
}
// Clear expired access token so captcha requests don't carry a stale bearer
d.AccessToken = ""
url := "https://user.mypikpak.net/v1/auth/signin"
// 使用 用户填写的 CaptchaToken —————— (验证后的captcha_token)
if d.GetCaptchaToken() == "" {
if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil {
return err
}
// Always refresh captcha token before signin (it may be expired)
if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil {
return err
}
var e ErrResp
@@ -125,7 +126,12 @@ func (d *PikPak) login() error {
data := res.Body()
d.RefreshToken = jsoniter.Get(data, "refresh_token").ToString()
d.AccessToken = jsoniter.Get(data, "access_token").ToString()
if d.AccessToken == "" || d.RefreshToken == "" {
return errors.New("login failed: server returned empty tokens")
}
d.Common.SetUserID(jsoniter.Get(data, "sub").ToString())
d.Addition.RefreshToken = d.RefreshToken
op.MustSaveDriverStorage(d)
return nil
}
@@ -159,9 +165,14 @@ func (d *PikPak) refreshToken(refreshToken string) error {
return errors.New(e.Error())
}
data := res.Body()
newAccessToken := jsoniter.Get(data, "access_token").ToString()
newRefreshToken := jsoniter.Get(data, "refresh_token").ToString()
if newAccessToken == "" || newRefreshToken == "" {
return errors.New("refresh failed: server returned empty tokens")
}
d.Status = "work"
d.RefreshToken = jsoniter.Get(data, "refresh_token").ToString()
d.AccessToken = jsoniter.Get(data, "access_token").ToString()
d.RefreshToken = newRefreshToken
d.AccessToken = newAccessToken
d.Common.SetUserID(jsoniter.Get(data, "sub").ToString())
d.Addition.RefreshToken = d.RefreshToken
op.MustSaveDriverStorage(d)
@@ -197,12 +208,18 @@ func (d *PikPak) request(url string, method string, callback base.ReqCallback, r
case 0:
return res.Body(), nil
case 4122, 4121, 16:
// access_token 过期
if strings.Contains(url, "/v1/auth/") || strings.Contains(url, "/v1/shield/captcha/") {
return nil, errors.New(e.Error())
}
// access_token expired, refresh and retry
if err1 := d.refreshToken(d.RefreshToken); err1 != nil {
return nil, err1
}
return d.request(url, method, callback, resp)
case 9: // 验证码token过期
case 9: // captcha token expired
if strings.Contains(url, "/v1/shield/captcha/") {
return nil, errors.New(e.Error())
}
if err = d.RefreshCaptchaTokenAtLogin(GetAction(method, url), d.GetUserID()); err != nil {
return nil, err
}
@@ -369,6 +386,9 @@ func (d *PikPak) RefreshCaptchaTokenInLogin(action, username string) error {
} else {
metas["username"] = username
}
metas["client_version"] = d.ClientVersion
metas["package_name"] = d.PackageName
metas["timestamp"], metas["captcha_sign"] = d.Common.GetCaptchaSign()
return d.refreshCaptchaToken(action, metas)
}
@@ -407,7 +427,7 @@ func (d *PikPak) refreshCaptchaToken(action string, metas map[string]string) err
return errors.New(e.Error())
}
if resp.Url != "" {
if resp.Url != "" && !d.Addition.SkipVerification {
return fmt.Errorf(`need verify: <a target="_blank" href="%s">Click Here</a>`, resp.Url)
}
@@ -503,9 +523,11 @@ func (d *PikPak) UploadByMultipart(ctx context.Context, params *S3Params, fileSi
for chunk := range chunksCh {
var part oss.UploadPart // 出现错误就继续尝试,共尝试3次
for retry := 0; retry < 3; retry++ {
select {
case <-ctx.Done():
if utils.IsCanceled(ctx) {
err = ctx.Err()
break
}
select {
case <-ticker.C:
errCh <- errors.Wrap(err, "ossToken 过期")
default:

Some files were not shown because too many files have changed in this diff Show More