Files
MeBox/internal/service/cloud/quark.go
T
soldosluka857 74271081f4 feat(cloud): 115/夸克网盘 providers with cookie + QR login and 302 playback
Add a pluggable cloud-disk subsystem (internal/service/cloud) exposing remote
files as playable media via HTTP 302 redirects, so the host never transcodes
nor (by default) streams the bytes:

- quark (夸克网盘): cookie auth, directory listing + download_url resolution.
- cloud115 (115 网盘): cookie auth + QR-code login flow (token/poll/exchange);
  pickcode → CDN URL resolved for 302 offload.
- StorageConfigService gains cloud types, Ping() probes, List/Resolve/Import.
- New endpoints: admin /cloud/:type/{list,import,qr/start,qr/poll} and authed
  /cloud/play/:type (302 redirect, or reverse-proxy when the link needs auth
  headers). Imported files become Media rows with STRMURL → the play endpoint.
- Frontend: 115网盘/夸克网盘 tabs with cookie input, 115 QR-code login, a cloud
  file browser and one-click 302 import.

Providers are exercised against httptest mock servers (list/resolve/QR state
machine). Live login + playback require a real cloud account.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 21:04:23 +08:00

161 lines
4.4 KiB
Go

package cloud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// quarkProvider implements the 夸克网盘 cloud disk using cookie auth.
//
// Quark's web API is plain JSON over HTTPS keyed by a session cookie; no
// request-body encryption is required (unlike 115). The resolved download_url
// is tied to the session, so playback runs in proxy mode by default.
type quarkProvider struct {
cookie string
ua string
base string // override for tests; defaults to quarkBase
client *http.Client
proxy bool
}
const quarkBase = "https://drive-pc.quark.cn/1/clouddrive"
func newQuark(cfg map[string]any, client *http.Client) *quarkProvider {
base := str(cfg["base"])
if base == "" {
base = quarkBase
}
ua := str(cfg["ua"])
if ua == "" {
ua = defaultUA
}
// Quark download links require the session cookie + UA, so the host must
// reverse-proxy unless the admin explicitly opts into raw 302.
proxy := true
if _, ok := cfg["force_302"]; ok && boolish(cfg["force_302"]) {
proxy = false
}
return &quarkProvider{
cookie: str(cfg["cookie"]),
ua: ua,
base: strings.TrimRight(base, "/"),
client: client,
proxy: proxy,
}
}
func (q *quarkProvider) Type() string { return TypeQuark }
func (q *quarkProvider) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, q.base+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Cookie", q.cookie)
req.Header.Set("User-Agent", q.ua)
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Referer", "https://pan.quark.cn/")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return q.client.Do(req)
}
type quarkResp struct {
Status int `json:"status"`
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func (q *quarkProvider) Ping(ctx context.Context) error {
if q.cookie == "" {
return fmt.Errorf("quark: missing cookie")
}
_, err := q.List(ctx, "0")
return err
}
func (q *quarkProvider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
if dirID == "" {
dirID = "0"
}
path := fmt.Sprintf("/file/sort?pr=ucpro&fr=pc&uc_param_str=&pdir_fid=%s&_page=1&_size=100&_fetch_total=1&_sort=file_type:asc,updated_at:desc", dirID)
resp, err := q.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r quarkResp
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("quark: decode list: %w", err)
}
if r.Code != 0 && r.Status != 200 {
return nil, fmt.Errorf("quark: list failed: %s", r.Message)
}
var data struct {
List []struct {
Fid string `json:"fid"`
FileName string `json:"file_name"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
} `json:"list"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
return nil, fmt.Errorf("quark: decode list data: %w", err)
}
out := make([]FileEntry, 0, len(data.List))
for _, it := range data.List {
out = append(out, FileEntry{
ID: it.Fid,
Name: it.FileName,
IsDir: it.Dir,
Size: it.Size,
})
}
return out, nil
}
func (q *quarkProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
if fileRef == "" {
return nil, fmt.Errorf("quark: empty file id")
}
payload, _ := json.Marshal(map[string]any{"fids": []string{fileRef}})
resp, err := q.do(ctx, http.MethodPost, "/file/download?pr=ucpro&fr=pc&uc_param_str=", bytes.NewReader(payload))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r quarkResp
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("quark: decode download: %w", err)
}
if r.Code != 0 && r.Status != 200 {
return nil, fmt.Errorf("quark: download failed: %s", r.Message)
}
var data []struct {
DownloadURL string `json:"download_url"`
Fid string `json:"fid"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
return nil, fmt.Errorf("quark: decode download data: %w", err)
}
if len(data) == 0 || data[0].DownloadURL == "" {
return nil, fmt.Errorf("quark: no download url returned")
}
return &DirectLink{
URL: data[0].DownloadURL,
Headers: map[string]string{
"Cookie": q.cookie,
"User-Agent": q.ua,
"Referer": "https://pan.quark.cn/",
},
Proxy: q.proxy,
}, nil
}