mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
Initial commit: video aggregator with quark/115/wopan drivers
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 第三方源码参考,可选,详见 vendor-refs/README.md
|
||||
vendor-refs/
|
||||
|
||||
# 后端数据目录(SQLite + teaser/封面)
|
||||
backend/data/
|
||||
backend/config.yaml
|
||||
|
||||
# 工具链
|
||||
tools/
|
||||
|
||||
# 注意:backend/vendor/ 是 Go modules vendor 目录,故意入库
|
||||
# 目的是让任何人 clone 后断网也能直接 go build
|
||||
# 不要加 `vendor/` 或 `backend/vendor/` 到这里
|
||||
@@ -0,0 +1,111 @@
|
||||
# 视频聚合站
|
||||
|
||||
把夸克 / 115 / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
|
||||
|
||||
- 前端:React 18 + Vite + TypeScript
|
||||
- 后端:Go 1.23,SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面
|
||||
- 三家网盘接入:夸克自研 + 115driver SDK + wopan-sdk-go SDK
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+ 和 npm
|
||||
- Go 1.23+
|
||||
- ffmpeg 和 ffprobe(用于生成预览 teaser 和抽封面)
|
||||
|
||||
Windows 用户可以把 Go 和 ffmpeg 解压到 `%USERPROFILE%\tools\`,然后把 `\tools\go\bin` 和 `\tools\ffmpeg\bin` 加到 PATH 即可,不需要管理员权限。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
npm run dev # 监听 http://127.0.0.1:5173
|
||||
|
||||
# 后端(另开终端)
|
||||
cd backend
|
||||
go run ./cmd/server # 监听 :8080,依赖已 vendor 入库,无需 go mod tidy
|
||||
```
|
||||
|
||||
首次启动后端会自动生成:
|
||||
|
||||
- `backend/config.yaml`(从 `config.example.yaml` 复制)
|
||||
- `backend/data/video-site.db`(SQLite)
|
||||
- `backend/data/previews/`(teaser 和封面本地目录)
|
||||
|
||||
Vite dev server 已配置把 `/api`、`/p`、`/admin/api` 反代到 `:8080`。浏览器访问 `http://127.0.0.1:5173/` 进入前台,`/admin` 进入管理后台(默认 `admin` / `admin123`,请在 `config.yaml` 里改)。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
.
|
||||
├─ src/ React 前端
|
||||
├─ backend/ Go 后端(单体服务)
|
||||
│ └─ vendor/ Go 依赖全量源码,入库,支持完全离线构建
|
||||
├─ vendor-refs/ 可选的阅读资料,.gitignore 忽略
|
||||
│ └─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考
|
||||
├─ video-site-implementation-plan.md 完整的设计和实现记录
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
### 依赖管理
|
||||
|
||||
所有 Go 依赖都已通过 `go mod vendor` 打包进 `backend/vendor/` 并入库。别人 clone 仓库后,**无需联网**,直接 `go run ./cmd/server` 就能编译运行。
|
||||
|
||||
升级依赖的流程:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go get github.com/SheltonZhu/115driver@<新版本>
|
||||
go mod tidy
|
||||
go mod vendor # 把新依赖同步到 vendor 目录
|
||||
git add vendor/ # 入库
|
||||
```
|
||||
|
||||
### `vendor-refs/` 要不要在意?
|
||||
|
||||
不需要。它只存 OpenList 源码作协议参考,删除或保留都不影响项目编译。
|
||||
|
||||
## 加一个网盘
|
||||
|
||||
1. 登录 `/admin` → 网盘管理 → 新建
|
||||
2. 选类型(夸克 / 115 / 沃盘),填名称 + 凭证
|
||||
3. 保存后会自动触发一次扫描
|
||||
4. 在 `/admin/videos` 里看扫到了多少视频
|
||||
5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和 10 秒 teaser
|
||||
|
||||
三家盘的凭证字段:
|
||||
|
||||
| 类型 | 凭证字段 | 获取方式 |
|
||||
|---|---|---|
|
||||
| 夸克 | `cookie` | pan.quark.cn 登录后 F12 拷 Cookie |
|
||||
| 115 | `cookie` | 115.com 登录后拷 Cookie(`UID=...; CID=...; SEID=...; KID=...`) |
|
||||
| 沃盘 | `access_token`、`refresh_token`、可选 `family_id` | 第一版只能手动粘贴 token;后续会加扫码/短信登录 |
|
||||
|
||||
## Teaser 和封面生成策略
|
||||
|
||||
- 封面:根据视频时长从 20% 或 30% 位置抽一帧 jpg
|
||||
- Teaser:3 段拼接(`20% / 50% / 80%` 位置各 3 秒,带 0.2s fade-in/out),总长约 9 秒,目标体积 500 KB - 1.5 MB
|
||||
- 短视频 (< 30s) 自动降级为单段
|
||||
- 首次失败的任务标 `preview_status = failed`,不再自动重试;管理后台可手动重生
|
||||
- 详见 plan 15.12 节
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
# 本机交叉编译
|
||||
cd backend
|
||||
GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server
|
||||
|
||||
# 目标服务器
|
||||
sudo apt install ffmpeg
|
||||
scp video-server user@host:/opt/video-site/
|
||||
# 配 systemd + nginx 反代到 /、/api、/p、/admin
|
||||
```
|
||||
|
||||
完整部署方式见 plan 15.10 节。
|
||||
|
||||
## 贡献
|
||||
|
||||
任何代码改动请保持和 `video-site-implementation-plan.md` 同步;重要的设计决策追加到第 14 节(实现备注)或第 15 节(后端)。
|
||||
@@ -0,0 +1,133 @@
|
||||
# backend
|
||||
|
||||
视频聚合站的 Go 后端。提供三件事:
|
||||
|
||||
1. 三家网盘统一抽象(夸克 / 115 / 联通沃盘)
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成
|
||||
3. REST API(前台)+ 管理后台 + 直链代理
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
cmd/server/main.go 入口
|
||||
internal/
|
||||
config/ YAML 配置
|
||||
catalog/ SQLite 元数据
|
||||
drives/
|
||||
iface.go Drive 接口
|
||||
quark/ 夸克(自己实现,参考 OpenList quark_uc)
|
||||
p115/ 115(壳子 + SheltonZhu/115driver)
|
||||
wopan/ 联通沃盘(壳子 + OpenListTeam/wopan-sdk-go)
|
||||
scanner/ 扫目录 → 落库
|
||||
preview/ ffmpeg 抽 10s teaser
|
||||
proxy/ /p/stream/*、/p/preview/* 代理
|
||||
auth/ 管理员 session
|
||||
api/ REST 路由
|
||||
config.example.yaml 配置模板
|
||||
```
|
||||
|
||||
## 开发环境(Windows)
|
||||
|
||||
本仓库假设工具都装在用户目录,不需要管理员权限。
|
||||
|
||||
```
|
||||
C:\Users\<you>\tools\
|
||||
go\bin\go.exe Go 1.23+
|
||||
ffmpeg\bin\ffmpeg.exe 任意 ≥ 4.x 版本
|
||||
```
|
||||
|
||||
并加到 `PATH`。
|
||||
|
||||
### 第一次启动
|
||||
|
||||
```powershell
|
||||
cd F:\VideoProject\backend
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
首次启动会在当前目录创建:
|
||||
|
||||
- `config.yaml`(从 `config.example.yaml` 复制)
|
||||
- `data/video-site.db`
|
||||
- `data/previews/`
|
||||
|
||||
默认监听 `:8080`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。
|
||||
|
||||
### 连接前端
|
||||
|
||||
`vite.config.ts` 已经把 `/api`、`/p`、`/admin` 代理到 `8080`。
|
||||
|
||||
```
|
||||
npm run dev 前端 5173
|
||||
go run ./cmd/server 后端 8080
|
||||
```
|
||||
|
||||
## 添加一个盘
|
||||
|
||||
1. 登录管理后台:`POST /admin/api/login` body `{"username":"admin","password":"admin123"}`
|
||||
2. 新建盘:`POST /admin/api/drives`
|
||||
```json
|
||||
{
|
||||
"id": "my-quark",
|
||||
"kind": "quark",
|
||||
"name": "我的夸克盘",
|
||||
"rootId": "0",
|
||||
"scanRootId": "0",
|
||||
"credentials": {
|
||||
"cookie": "粘贴浏览器 F12 复制的 pan.quark.cn Cookie"
|
||||
}
|
||||
}
|
||||
```
|
||||
3. 手动触发扫描:`POST /admin/api/drives/my-quark/rescan`
|
||||
|
||||
三家盘的凭证字段:
|
||||
|
||||
| kind | credentials 字段 |
|
||||
|--------|---------------------------------------------------------------|
|
||||
| quark | `cookie` |
|
||||
| p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) |
|
||||
| wopan | `access_token`、`refresh_token`,可选 `family_id` |
|
||||
|
||||
## 文件名约定
|
||||
|
||||
扫描器按以下顺序解析文件名:
|
||||
|
||||
1. `[tag1,tag2] 标题 - 作者.mp4`
|
||||
2. `[tag1,tag2] 标题.mp4`
|
||||
3. `标题 - 作者.mp4`
|
||||
4. `标题.mp4`
|
||||
|
||||
标签分隔符支持 `, , 、` 和空格。解析结果可在管理后台覆盖。
|
||||
|
||||
## Teaser 生成
|
||||
|
||||
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列,调用:
|
||||
|
||||
```
|
||||
ffmpeg -ss 10 -headers "UA/Cookie/Referer" -i <直链> \
|
||||
-t 10 -an -vf scale=480:-2 -c:v libx264 -preset veryfast -crf 28 \
|
||||
-movflags +faststart -y <local>.mp4
|
||||
```
|
||||
|
||||
优先把 teaser 上传回网盘的 `previews/` 目录;失败时保留本地 `data/previews/<videoID>.mp4` 作为兜底。
|
||||
|
||||
前端卡片的 `previewSrc` 统一指向 `/p/preview/<videoID>`,后端自动选择网盘代理或本地文件。
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
# 交叉编译
|
||||
GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server
|
||||
|
||||
# 目标机
|
||||
sudo apt install ffmpeg
|
||||
scp video-server user@host:/opt/video-site/
|
||||
ssh user@host
|
||||
cd /opt/video-site
|
||||
cp config.example.yaml config.yaml
|
||||
# 改密码、监听地址
|
||||
./video-server
|
||||
```
|
||||
|
||||
配 systemd + nginx 反代到 `/` 和 `/api`、`/p`、`/admin`。
|
||||
@@ -0,0 +1,425 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/video-site/backend/internal/api"
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/config"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
"github.com/video-site/backend/internal/drives/p115"
|
||||
"github.com/video-site/backend/internal/drives/quark"
|
||||
"github.com/video-site/backend/internal/drives/wopan"
|
||||
"github.com/video-site/backend/internal/preview"
|
||||
"github.com/video-site/backend/internal/proxy"
|
||||
"github.com/video-site/backend/internal/scanner"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := "./config.yaml"
|
||||
if v := os.Getenv("VIDEO_CONFIG"); v != "" {
|
||||
cfgPath = v
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.Storage.DBPath), 0o755); err != nil {
|
||||
log.Fatalf("mkdir db dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.Storage.LocalPreviewDir, 0o755); err != nil {
|
||||
log.Fatalf("mkdir preview dir: %v", err)
|
||||
}
|
||||
|
||||
cat, err := catalog.Open(cfg.Storage.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
cat: cat,
|
||||
registry: proxy.NewRegistry(),
|
||||
workers: make(map[string]*preview.Worker),
|
||||
}
|
||||
app.proxy = proxy.New(app.registry)
|
||||
|
||||
// 初始化现有 drives
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app.loadPreviewEnabled(ctx)
|
||||
|
||||
existing, err := cat.ListDrives(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("list drives: %v", err)
|
||||
}
|
||||
for _, d := range existing {
|
||||
if err := app.attachDrive(ctx, d); err != nil {
|
||||
log.Printf("[drive %s] attach failed: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
authr := &auth.Authenticator{
|
||||
Username: cfg.Server.Admin.Username,
|
||||
Password: cfg.Server.Admin.Password,
|
||||
Catalog: cat,
|
||||
}
|
||||
|
||||
apiServer := &api.Server{
|
||||
Catalog: cat,
|
||||
Proxy: app.proxy,
|
||||
LocalDir: cfg.Storage.LocalPreviewDir,
|
||||
}
|
||||
|
||||
adminServer := &api.AdminServer{
|
||||
Catalog: cat,
|
||||
Auth: authr,
|
||||
OnDriveSaved: func(driveID string) error {
|
||||
d, err := cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return app.attachDrive(ctx, d)
|
||||
},
|
||||
OnDriveRemoved: func(driveID string) {
|
||||
app.detachDrive(driveID)
|
||||
},
|
||||
OnScanRequested: func(driveID string) {
|
||||
go app.runScan(ctx, driveID)
|
||||
},
|
||||
OnRegenPreview: func(videoID string) {
|
||||
go app.regenPreview(ctx, videoID)
|
||||
},
|
||||
GetPreviewEnabled: func() bool { return app.PreviewEnabled() },
|
||||
SetPreviewEnabled: func(enabled bool) error {
|
||||
return app.SetPreviewEnabled(ctx, enabled)
|
||||
},
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(corsMiddleware)
|
||||
|
||||
apiServer.RegisterRoutes(r, authr)
|
||||
adminServer.Register(r)
|
||||
|
||||
// 启动定时扫描
|
||||
go app.scanLoop(ctx)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("video-site backend listening on %s", cfg.Server.Listen)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待退出信号
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
log.Println("shutting down...")
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutCancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
}
|
||||
|
||||
// ---------- App ----------
|
||||
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
cat *catalog.Catalog
|
||||
registry *proxy.Registry
|
||||
proxy *proxy.Proxy
|
||||
|
||||
mu sync.Mutex
|
||||
workers map[string]*preview.Worker
|
||||
cancels map[string]context.CancelFunc
|
||||
|
||||
// 运行时 preview 开关(从 DB 读)
|
||||
previewEnabled bool
|
||||
}
|
||||
|
||||
// PreviewEnabled 线程安全读
|
||||
func (a *App) PreviewEnabled() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.previewEnabled
|
||||
}
|
||||
|
||||
// SetPreviewEnabled 切换开关,写库 + 若开启则立刻补扫 pending
|
||||
func (a *App) SetPreviewEnabled(ctx context.Context, enabled bool) error {
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = enabled
|
||||
a.mu.Unlock()
|
||||
|
||||
val := "0"
|
||||
if enabled {
|
||||
val = "1"
|
||||
}
|
||||
if err := a.cat.SetSetting(ctx, "preview.enabled", val); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if enabled {
|
||||
// 异步补扫所有盘
|
||||
go func() {
|
||||
for _, d := range a.registry.All() {
|
||||
a.mu.Lock()
|
||||
w := a.workers[d.ID()]
|
||||
a.mu.Unlock()
|
||||
if w != nil {
|
||||
a.enqueuePending(ctx, d.ID(), w)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPreviewEnabled 从 DB 读运行时开关,首次启动取 config 默认值
|
||||
func (a *App) loadPreviewEnabled(ctx context.Context) {
|
||||
def := "0"
|
||||
if a.cfg.Preview.Enabled {
|
||||
def = "1"
|
||||
}
|
||||
v, err := a.cat.GetSetting(ctx, "preview.enabled", def)
|
||||
if err != nil {
|
||||
log.Printf("[preview] load setting: %v (fallback to config)", err)
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = a.cfg.Preview.Enabled
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = v == "1"
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
var drv drives.Drive
|
||||
switch d.Kind {
|
||||
case "quark":
|
||||
drv = quark.New(quark.Config{
|
||||
ID: d.ID,
|
||||
Cookie: d.Credentials["cookie"],
|
||||
RootID: d.RootID,
|
||||
OnCookieUpdate: func(cookie string) {
|
||||
d.Credentials["cookie"] = cookie
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
case "p115":
|
||||
drv = p115.New(p115.Config{
|
||||
ID: d.ID,
|
||||
Cookie: d.Credentials["cookie"],
|
||||
RootID: d.RootID,
|
||||
})
|
||||
case "wopan":
|
||||
drv = wopan.New(wopan.Config{
|
||||
ID: d.ID,
|
||||
AccessToken: d.Credentials["access_token"],
|
||||
RefreshToken: d.Credentials["refresh_token"],
|
||||
FamilyID: d.Credentials["family_id"],
|
||||
RootID: d.RootID,
|
||||
OnTokenUpdate: func(access, refresh string) {
|
||||
d.Credentials["access_token"] = access
|
||||
d.Credentials["refresh_token"] = refresh
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unknown drive kind: %s", d.Kind)
|
||||
}
|
||||
|
||||
if err := drv.Init(ctx); err != nil {
|
||||
d.Status = "error"
|
||||
d.LastError = err.Error()
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
return err
|
||||
}
|
||||
|
||||
d.Status = "ok"
|
||||
d.LastError = ""
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
|
||||
a.registry.Set(d.ID, drv)
|
||||
|
||||
// preview worker
|
||||
gen := preview.New(preview.Config{
|
||||
FFmpegPath: a.cfg.Preview.FFmpegPath,
|
||||
FFprobePath: a.cfg.Preview.FFprobePath,
|
||||
DurationSeconds: a.cfg.Preview.DurationSeconds,
|
||||
Width: a.cfg.Preview.Width,
|
||||
Segments: a.cfg.Preview.Segments,
|
||||
LocalDir: a.cfg.Storage.LocalPreviewDir,
|
||||
RemoteDir: a.cfg.Preview.RemoteDir,
|
||||
})
|
||||
worker := preview.NewWorker(gen, a.cat, drv, a.cfg.Preview.RemoteDir)
|
||||
|
||||
workerCtx, cancel := context.WithCancel(ctx)
|
||||
go worker.Run(workerCtx)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.cancels == nil {
|
||||
a.cancels = make(map[string]context.CancelFunc)
|
||||
}
|
||||
if old, ok := a.cancels[d.ID]; ok {
|
||||
old()
|
||||
}
|
||||
a.workers[d.ID] = worker
|
||||
a.cancels[d.ID] = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
// 启动补扫:把这个盘下所有 pending 的视频塞进 worker 队列
|
||||
// 使用 goroutine 因为队列可能比预期的小,Enqueue 直接丢弃,调用方也无需等待
|
||||
if a.PreviewEnabled() {
|
||||
go a.enqueuePending(workerCtx, d.ID, worker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Worker) {
|
||||
pending, err := a.cat.ListVideosByPreviewStatus(ctx, driveID, "pending", 0)
|
||||
if err != nil {
|
||||
log.Printf("[preview] list pending %s: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("[preview] enqueue %d pending videos for drive=%s", len(pending), driveID)
|
||||
for _, v := range pending {
|
||||
w.Enqueue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) detachDrive(id string) {
|
||||
a.registry.Remove(id)
|
||||
a.mu.Lock()
|
||||
if cancel, ok := a.cancels[id]; ok {
|
||||
cancel()
|
||||
delete(a.cancels, id)
|
||||
}
|
||||
delete(a.workers, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) runScan(ctx context.Context, driveID string) {
|
||||
drv, ok := a.registry.Get(driveID)
|
||||
if !ok {
|
||||
log.Printf("[scan] drive %s not attached", driveID)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
worker := a.workers[driveID]
|
||||
a.mu.Unlock()
|
||||
|
||||
var onNew func(v *catalog.Video)
|
||||
if a.PreviewEnabled() && worker != nil {
|
||||
onNew = worker.Enqueue
|
||||
}
|
||||
|
||||
sc := scanner.New(a.cat, drv, a.cfg.Scanner.VideoExtensions, a.cfg.Scanner.MaxDepth, onNew)
|
||||
|
||||
// 使用 drive 的 scan_root_id,否则 root_id
|
||||
d, err := a.cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
log.Printf("[scan] get drive %s: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
startID := d.ScanRootID
|
||||
if startID == "" {
|
||||
startID = d.RootID
|
||||
}
|
||||
|
||||
log.Printf("[scan] drive=%s start=%s", driveID, startID)
|
||||
stats, err := sc.Run(ctx, startID)
|
||||
if err != nil {
|
||||
log.Printf("[scan] drive=%s error: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[scan] drive=%s done scanned=%d added=%d", driveID, stats.Scanned, stats.Added)
|
||||
}
|
||||
|
||||
func (a *App) regenPreview(ctx context.Context, videoID string) {
|
||||
v, err := a.cat.GetVideo(ctx, videoID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
worker := a.workers[v.DriveID]
|
||||
a.mu.Unlock()
|
||||
if worker != nil {
|
||||
worker.Enqueue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) scanLoop(ctx context.Context) {
|
||||
// 启动后立刻扫一次
|
||||
a.scanAllOnce(ctx)
|
||||
|
||||
if a.cfg.Scanner.IntervalSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(a.cfg.Scanner.IntervalSeconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.scanAllOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) scanAllOnce(ctx context.Context) {
|
||||
for _, d := range a.registry.All() {
|
||||
a.runScan(ctx, d.ID())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- middleware ----------
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", originOr(r, "*"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func originOr(r *http.Request, fallback string) string {
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
return o
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# backend 配置示例。首次启动若未发现 config.yaml,会基于此文件自动创建。
|
||||
server:
|
||||
listen: ":8080"
|
||||
# 管理后台用户,生产环境请务必修改
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "admin123"
|
||||
# 用于签发 admin session cookie,生产请改成随机字符串
|
||||
session_secret: "change-me-to-a-random-string"
|
||||
|
||||
storage:
|
||||
# SQLite 数据库文件路径
|
||||
db_path: "./data/video-site.db"
|
||||
# 本地 teaser 兜底目录(网盘写入失败时使用)
|
||||
local_preview_dir: "./data/previews"
|
||||
|
||||
scanner:
|
||||
# 扫描间隔(秒),0 表示只启动时扫一次
|
||||
interval_seconds: 21600
|
||||
# 单次扫描每家网盘目录递归层数上限
|
||||
max_depth: 5
|
||||
# 被扫描的扩展名
|
||||
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
|
||||
|
||||
preview:
|
||||
# 是否启用 ffmpeg 抽帧生成 teaser
|
||||
enabled: true
|
||||
# ffmpeg / ffprobe 可执行文件名或绝对路径
|
||||
ffmpeg_path: "ffmpeg"
|
||||
ffprobe_path: "ffprobe"
|
||||
# teaser 总时长(秒)。多段模式下会均分给每段
|
||||
duration_seconds: 9
|
||||
# teaser 段数。1=从视频 25% 位置取单段;>=2 按时长自适应切段并拼接
|
||||
segments: 3
|
||||
# teaser 视频宽度
|
||||
width: 480
|
||||
# teaser 上传到网盘的目录(相对网盘根)。空值则写本地
|
||||
remote_dir: "/previews"
|
||||
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空
|
||||
drives: []
|
||||
@@ -0,0 +1,46 @@
|
||||
module github.com/video-site/backend
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.4
|
||||
|
||||
require (
|
||||
github.com/OpenListTeam/wopan-sdk-go v0.2.0
|
||||
github.com/SheltonZhu/115driver v1.3.2
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-resty/resty/v2 v2.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aead/ecdh v0.2.0 // indirect
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
github.com/andreburgaud/crypt2go v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.17 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
golang.org/x/crypto v0.25.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
// 依赖已通过 go mod vendor 打包进 backend/vendor/ 并入库,支持离线构建。
|
||||
// 升级 SDK 请使用标准流程:
|
||||
// go get github.com/SheltonZhu/115driver@<版本>
|
||||
// go mod tidy
|
||||
// go mod vendor
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
github.com/OpenListTeam/wopan-sdk-go v0.2.0 h1:i7nxcdDTIWEbMDlmZ4bFAjX4Dd3mWmf8SEfcateicSk=
|
||||
github.com/OpenListTeam/wopan-sdk-go v0.2.0/go.mod h1:otynv0CgSNUClPpUgZ44qCZGcMRe0dc83Pkk65xAunI=
|
||||
github.com/SheltonZhu/115driver v1.3.2 h1:GmIYqivI1Y647FHGwiAoQce1tDaY8YWNjk7hGLhbsqs=
|
||||
github.com/SheltonZhu/115driver v1.3.2/go.mod h1:OujS7azslg1/bn85sPSHnNsp4/WBI9/TiijtZL9kuSQ=
|
||||
github.com/aead/ecdh v0.2.0 h1:pYop54xVaq/CEREFEcukHRZfTdjiWvYIsZDXXrBapQQ=
|
||||
github.com/aead/ecdh v0.2.0/go.mod h1:a9HHtXuSo8J1Js1MwLQx2mBhkXMT6YwUmVVEY4tTB8U=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/andreburgaud/crypt2go v1.1.0 h1:eitZxTPY1krUsxinsng3Qvt/Ud7q/aQmmYRh8p4hyPw=
|
||||
github.com/andreburgaud/crypt2go v1.1.0/go.mod h1:4qhZPzarj1dCIRmCkpdgCklwp+hBq9yEt0zPe9Ayuhc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-resty/resty/v2 v2.14.0 h1:/rhkzsAqGQkozwfKS5aFAbb6TyKd3zyFRWcdRXLPCAU=
|
||||
github.com/go-resty/resty/v2 v2.14.0/go.mod h1:IW6mekUOsElt9C7oWr0XRt9BNSD6D5rr9mhk6NjmNHg=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
|
||||
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,284 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
type AdminServer struct {
|
||||
Catalog *catalog.Catalog
|
||||
Auth *auth.Authenticator
|
||||
// Hooks:外层注入实际执行者
|
||||
OnDriveSaved func(driveID string) error
|
||||
OnDriveRemoved func(driveID string)
|
||||
OnScanRequested func(driveID string)
|
||||
OnRegenPreview func(videoID string)
|
||||
// Preview 开关读写
|
||||
GetPreviewEnabled func() bool
|
||||
SetPreviewEnabled func(enabled bool) error
|
||||
}
|
||||
|
||||
func (a *AdminServer) Register(r chi.Router) {
|
||||
r.Route("/admin/api", func(r chi.Router) {
|
||||
// 登录、登出不需要鉴权
|
||||
r.Post("/login", a.handleLogin)
|
||||
r.Post("/logout", a.handleLogout)
|
||||
r.Get("/me", a.handleMe)
|
||||
|
||||
// 其余路由需鉴权
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Required)
|
||||
|
||||
// 网盘
|
||||
r.Get("/drives", a.handleListDrives)
|
||||
r.Post("/drives", a.handleUpsertDrive)
|
||||
r.Delete("/drives/{id}", a.handleDeleteDrive)
|
||||
r.Post("/drives/{id}/rescan", a.handleRescan)
|
||||
|
||||
// 视频
|
||||
r.Get("/videos", a.handleAdminListVideos)
|
||||
r.Put("/videos/{id}", a.handleUpdateVideo)
|
||||
r.Post("/videos/{id}/regen-preview", a.handleRegenPreview)
|
||||
|
||||
// 运行时设置
|
||||
r.Get("/settings", a.handleGetSettings)
|
||||
r.Put("/settings", a.handlePutSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body loginReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
ok, err := a.Auth.Login(w, r, body.Username, body.Password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
a.Auth.Logout(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("vs_admin")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
|
||||
return
|
||||
}
|
||||
ok, _ := a.Catalog.ValidateSession(r.Context(), c.Value)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": ok})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
drives, err := a.Catalog.ListDrives(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
// 出参不返回凭证明文,只告诉前端是否已配置
|
||||
type out struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RootID string `json:"rootId"`
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
HasCredential bool `json:"hasCredential"`
|
||||
}
|
||||
list := make([]out, 0, len(drives))
|
||||
for _, d := range drives {
|
||||
list = append(list, out{
|
||||
ID: d.ID, Kind: d.Kind, Name: d.Name,
|
||||
RootID: d.RootID, ScanRootID: d.ScanRootID,
|
||||
Status: d.Status, LastError: d.LastError,
|
||||
HasCredential: len(d.Credentials) > 0,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
type upsertDriveReq struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RootID string `json:"rootId"`
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) {
|
||||
var body upsertDriveReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if body.ID == "" || body.Kind == "" {
|
||||
http.Error(w, "id and kind are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d := &catalog.Drive{
|
||||
ID: body.ID, Kind: body.Kind, Name: body.Name,
|
||||
RootID: body.RootID, ScanRootID: body.ScanRootID,
|
||||
Credentials: body.Credentials,
|
||||
Status: "disconnected",
|
||||
}
|
||||
if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if a.OnDriveSaved != nil {
|
||||
if err := a.OnDriveSaved(body.ID); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "warning": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleDeleteDrive(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := a.Catalog.DeleteDrive(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if a.OnDriveRemoved != nil {
|
||||
a.OnDriveRemoved(id)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnScanRequested != nil {
|
||||
a.OnScanRequested(id)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Request) {
|
||||
items, total, err := a.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Page: 1, PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
|
||||
}
|
||||
|
||||
type updateVideoReq struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Tags []string `json:"tags"`
|
||||
Category string `json:"category"`
|
||||
Badges []string `json:"badges"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Quality string `json:"quality"`
|
||||
DurationSec int `json:"durationSeconds"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body updateVideoReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
v, err := a.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if body.Title != "" {
|
||||
v.Title = body.Title
|
||||
}
|
||||
if body.Author != "" {
|
||||
v.Author = body.Author
|
||||
}
|
||||
if body.Tags != nil {
|
||||
v.Tags = body.Tags
|
||||
}
|
||||
if body.Category != "" {
|
||||
v.Category = body.Category
|
||||
}
|
||||
if body.Badges != nil {
|
||||
v.Badges = body.Badges
|
||||
}
|
||||
if body.Description != "" {
|
||||
v.Description = body.Description
|
||||
}
|
||||
if body.Thumbnail != "" {
|
||||
v.ThumbnailURL = body.Thumbnail
|
||||
}
|
||||
if body.Quality != "" {
|
||||
v.Quality = body.Quality
|
||||
}
|
||||
if body.DurationSec > 0 {
|
||||
v.DurationSeconds = body.DurationSec
|
||||
}
|
||||
if err := a.Catalog.UpsertVideo(r.Context(), v); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnRegenPreview != nil {
|
||||
a.OnRegenPreview(id)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
type settingsDTO struct {
|
||||
PreviewEnabled bool `json:"previewEnabled"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
enabled := false
|
||||
if a.GetPreviewEnabled != nil {
|
||||
enabled = a.GetPreviewEnabled()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: enabled})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var body settingsDTO
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if a.SetPreviewEnabled != nil {
|
||||
if err := a.SetPreviewEnabled(body.PreviewEnabled); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: body.PreviewEnabled})
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/proxy"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Catalog *catalog.Catalog
|
||||
Proxy *proxy.Proxy
|
||||
LocalDir string
|
||||
}
|
||||
|
||||
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
|
||||
type VideoDTO struct {
|
||||
ID string `json:"id"`
|
||||
Href string `json:"href"`
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
PreviewSrc string `json:"previewSrc"`
|
||||
PreviewDuration int `json:"previewDuration"`
|
||||
PreviewStrategy string `json:"previewStrategy"`
|
||||
Duration string `json:"duration"`
|
||||
Badges []string `json:"badges"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Author string `json:"author"`
|
||||
Views int `json:"views"`
|
||||
Favorites int `json:"favorites"`
|
||||
Comments int `json:"comments"`
|
||||
Likes int `json:"likes"`
|
||||
Dislikes int `json:"dislikes"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
type VideoDetailDTO struct {
|
||||
VideoDTO
|
||||
VideoSrc string `json:"videoSrc"`
|
||||
Poster string `json:"poster"`
|
||||
Description string `json:"description"`
|
||||
EmbedURL string `json:"embedUrl"`
|
||||
Points int `json:"points,omitempty"`
|
||||
AuthorProfile AuthorProfile `json:"authorProfile"`
|
||||
RelatedVideos []VideoDTO `json:"relatedVideos"`
|
||||
CommentsList []Comment `json:"commentsList"`
|
||||
}
|
||||
|
||||
type AuthorProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Badges []string `json:"badges"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Likes int `json:"likes,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterRoutes 挂载前台 REST 路由。前台接口需要登录态。
|
||||
func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Required)
|
||||
r.Get("/api/home", s.handleHome)
|
||||
r.Get("/api/list", s.handleList)
|
||||
r.Get("/api/video/{id}", s.handleVideoDetail)
|
||||
r.Post("/api/video/{id}/like", s.handleLike)
|
||||
r.Get("/api/tags", s.handleTags)
|
||||
|
||||
// 代理路由同样需要鉴权,防止绕过
|
||||
r.Get("/p/stream/{driveID}/{fileID}", s.handleStream)
|
||||
r.Get("/p/preview/{videoID}", s.handlePreview)
|
||||
r.Get("/p/thumb/{videoID}", s.handleThumb)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "hot", Page: 1, PageSize: 24,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mapVideos(items))
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
size, _ := strconv.Atoi(q.Get("size"))
|
||||
if size <= 0 {
|
||||
size = 24
|
||||
}
|
||||
params := catalog.ListParams{
|
||||
Keyword: q.Get("q"),
|
||||
Tag: q.Get("tag"),
|
||||
Category: q.Get("cat"),
|
||||
Sort: q.Get("sort"),
|
||||
Page: page,
|
||||
PageSize: size,
|
||||
}
|
||||
items, total, err := s.Catalog.ListVideos(r.Context(), params)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": mapVideos(items),
|
||||
"total": total,
|
||||
"page": params.Page,
|
||||
"size": params.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
related, _, _ := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "hot", Page: 1, PageSize: 8,
|
||||
})
|
||||
|
||||
detail := VideoDetailDTO{
|
||||
VideoDTO: mapVideo(v),
|
||||
VideoSrc: fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID),
|
||||
Poster: v.ThumbnailURL,
|
||||
Description: v.Description,
|
||||
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, v.ID),
|
||||
AuthorProfile: AuthorProfile{
|
||||
ID: "author-" + v.Author,
|
||||
Name: v.Author,
|
||||
Href: "/author/" + v.Author,
|
||||
Badges: []string{},
|
||||
},
|
||||
RelatedVideos: filterVideos(mapVideos(related), v.ID),
|
||||
CommentsList: []Comment{},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
cats, err := s.Catalog.ListCategories(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
type tag struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
out := make([]tag, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
out = append(out, tag{ID: c.Category, Label: c.Category, Count: c.Count})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
likes, err := s.Catalog.IncrementLike(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"likes": likes})
|
||||
}
|
||||
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
driveID := chi.URLParam(r, "driveID")
|
||||
fileID := chi.URLParam(r, "fileID")
|
||||
s.Proxy.ServeStream(w, r, driveID, fileID)
|
||||
}
|
||||
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if v.PreviewStatus != "ready" {
|
||||
http.Error(w, "preview not ready", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if v.PreviewFileID != "" {
|
||||
s.Proxy.ServeStream(w, r, v.DriveID, v.PreviewFileID)
|
||||
return
|
||||
}
|
||||
if v.PreviewLocal != "" {
|
||||
if !strings.HasPrefix(filepath.Clean(v.PreviewLocal), filepath.Clean(s.LocalDir)) {
|
||||
http.Error(w, "invalid local path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.Proxy.ServeLocal(w, r, v.PreviewLocal)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
// 直接读本地 thumbs 目录中 <videoID>.jpg
|
||||
path := filepath.Join(s.LocalDir, "thumbs", videoID+".jpg")
|
||||
clean := filepath.Clean(path)
|
||||
if !strings.HasPrefix(clean, filepath.Clean(s.LocalDir)) {
|
||||
http.Error(w, "invalid path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(clean); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.Proxy.ServeLocal(w, r, clean)
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func mapVideo(v *catalog.Video) VideoDTO {
|
||||
badges := v.Badges
|
||||
if badges == nil {
|
||||
badges = []string{}
|
||||
}
|
||||
tags := v.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return VideoDTO{
|
||||
ID: v.ID,
|
||||
Href: "/video/" + v.ID,
|
||||
Title: v.Title,
|
||||
Thumbnail: v.ThumbnailURL,
|
||||
PreviewSrc: "/p/preview/" + v.ID,
|
||||
PreviewDuration: 10,
|
||||
PreviewStrategy: "teaser-file",
|
||||
Duration: formatDuration(v.DurationSeconds),
|
||||
Badges: badges,
|
||||
Quality: v.Quality,
|
||||
Author: v.Author,
|
||||
Views: v.Views,
|
||||
Favorites: v.Favorites,
|
||||
Comments: v.Comments,
|
||||
Likes: v.Likes,
|
||||
Dislikes: v.Dislikes,
|
||||
PublishedAt: v.PublishedAt.Format("2006-01-02"),
|
||||
Tags: tags,
|
||||
Category: v.Category,
|
||||
}
|
||||
}
|
||||
|
||||
func mapVideos(vs []*catalog.Video) []VideoDTO {
|
||||
out := make([]VideoDTO, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
out = append(out, mapVideo(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterVideos(vs []VideoDTO, exclude string) []VideoDTO {
|
||||
out := make([]VideoDTO, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
if v.ID != exclude {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatDuration(sec int) string {
|
||||
if sec <= 0 {
|
||||
return "00:00"
|
||||
}
|
||||
m := sec / 60
|
||||
s := sec % 60
|
||||
return fmt.Sprintf("%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, err error) {
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "vs_admin"
|
||||
sessionTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Authenticator struct {
|
||||
Username string
|
||||
Password string
|
||||
Catalog *catalog.Catalog
|
||||
}
|
||||
|
||||
func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass string) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(user), []byte(a.Username)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(pass), []byte(a.Password)) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := a.Catalog.CreateSession(r.Context(), token, sessionTTL); err != nil {
|
||||
return false, err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(sessionTTL),
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
_ = a.Catalog.DeleteSession(r.Context(), c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) Required(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ok, err := a.Catalog.ValidateSession(r.Context(), c.Value)
|
||||
if err != nil || !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
type Catalog struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*Catalog, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(schemaSQL); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
return &Catalog{db: db}, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) Close() error { return c.db.Close() }
|
||||
|
||||
// ---------- Video ----------
|
||||
|
||||
type Video struct {
|
||||
ID string `json:"id"`
|
||||
DriveID string `json:"driveId"`
|
||||
FileID string `json:"fileId"`
|
||||
ParentID string `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Tags []string `json:"tags"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Size int64 `json:"size"`
|
||||
Ext string `json:"ext"`
|
||||
Quality string `json:"quality"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
PreviewFileID string `json:"previewFileId"`
|
||||
PreviewLocal string `json:"previewLocal"`
|
||||
PreviewStatus string `json:"previewStatus"`
|
||||
Views int `json:"views"`
|
||||
Favorites int `json:"favorites"`
|
||||
Comments int `json:"comments"`
|
||||
Likes int `json:"likes"`
|
||||
Dislikes int `json:"dislikes"`
|
||||
Category string `json:"category"`
|
||||
Badges []string `json:"badges"`
|
||||
Description string `json:"description"`
|
||||
PublishedAt time.Time `json:"publishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
|
||||
tagsJSON, _ := json.Marshal(v.Tags)
|
||||
badgesJSON, _ := json.Marshal(v.Badges)
|
||||
now := time.Now().UnixMilli()
|
||||
if v.CreatedAt.IsZero() {
|
||||
v.CreatedAt = time.UnixMilli(now)
|
||||
}
|
||||
v.UpdatedAt = time.UnixMilli(now)
|
||||
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO videos (
|
||||
id, drive_id, file_id, parent_id, title, author, tags,
|
||||
duration_seconds, size_bytes, ext, quality, thumbnail_url,
|
||||
preview_file_id, preview_local, preview_status,
|
||||
views, favorites, comments, likes, dislikes,
|
||||
category, badges, description, published_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
author = excluded.author,
|
||||
tags = excluded.tags,
|
||||
duration_seconds= excluded.duration_seconds,
|
||||
size_bytes = excluded.size_bytes,
|
||||
ext = excluded.ext,
|
||||
quality = excluded.quality,
|
||||
thumbnail_url = excluded.thumbnail_url,
|
||||
category = excluded.category,
|
||||
badges = excluded.badges,
|
||||
description = excluded.description,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
v.ID, v.DriveID, v.FileID, v.ParentID, v.Title, v.Author, string(tagsJSON),
|
||||
v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL,
|
||||
v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus),
|
||||
v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes,
|
||||
v.Category, string(badgesJSON), v.Description,
|
||||
v.PublishedAt.UnixMilli(), v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableStatus(s string) string {
|
||||
if s == "" {
|
||||
return "pending"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Catalog) UpdatePreview(ctx context.Context, id, previewFileID, previewLocal, status string) error {
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`UPDATE videos SET preview_file_id = ?, preview_local = ?, preview_status = ?, updated_at = ? WHERE id = ?`,
|
||||
previewFileID, previewLocal, status, time.Now().UnixMilli(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// IncrementLike 原子 +1,返回最新点赞数
|
||||
func (c *Catalog) IncrementLike(ctx context.Context, id string) (int, error) {
|
||||
tx, err := c.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE videos SET likes = likes + 1, updated_at = ? WHERE id = ?`,
|
||||
time.Now().UnixMilli(), id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var likes int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT likes FROM videos WHERE id = ?`, id).Scan(&likes); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return likes, nil
|
||||
}
|
||||
|
||||
// VideoMetaPatch 轻量更新视频元数据(仅非零值字段会被写入)
|
||||
type VideoMetaPatch struct {
|
||||
ThumbnailURL string
|
||||
DurationSeconds int
|
||||
Category string
|
||||
}
|
||||
|
||||
func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPatch) error {
|
||||
parts := []string{}
|
||||
args := []any{}
|
||||
if p.ThumbnailURL != "" {
|
||||
parts = append(parts, "thumbnail_url = ?")
|
||||
args = append(args, p.ThumbnailURL)
|
||||
}
|
||||
if p.DurationSeconds > 0 {
|
||||
parts = append(parts, "duration_seconds = ?")
|
||||
args = append(args, p.DurationSeconds)
|
||||
}
|
||||
if p.Category != "" {
|
||||
parts = append(parts, "category = ?")
|
||||
args = append(args, p.Category)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
parts = append(parts, "updated_at = ?")
|
||||
args = append(args, time.Now().UnixMilli())
|
||||
args = append(args, id)
|
||||
q := `UPDATE videos SET ` + strings.Join(parts, ", ") + ` WHERE id = ?`
|
||||
_, err := c.db.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListCategories 聚合所有 category,按视频数降序
|
||||
type CategoryStat struct {
|
||||
Category string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (c *Catalog) ListCategories(ctx context.Context) ([]CategoryStat, error) {
|
||||
rows, err := c.db.QueryContext(ctx,
|
||||
`SELECT COALESCE(category, '') AS c, COUNT(*) AS cnt
|
||||
FROM videos
|
||||
WHERE category IS NOT NULL AND category != ''
|
||||
GROUP BY c
|
||||
ORDER BY cnt DESC, c ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CategoryStat
|
||||
for rows.Next() {
|
||||
var s CategoryStat
|
||||
if err := rows.Scan(&s.Category, &s.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListVideosByPreviewStatus 按预览状态列出全部视频,通常用于启动补扫
|
||||
func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status string, limit int) ([]*Video, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10000
|
||||
}
|
||||
rows, err := c.db.QueryContext(ctx,
|
||||
`SELECT `+allVideoCols+` FROM videos WHERE drive_id = ? AND preview_status = ? ORDER BY created_at ASC LIMIT ?`,
|
||||
driveID, status, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Video
|
||||
for rows.Next() {
|
||||
v, err := scanVideo(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) GetVideo(ctx context.Context, id string) (*Video, error) {
|
||||
row := c.db.QueryRowContext(ctx, `SELECT `+allVideoCols+` FROM videos WHERE id = ?`, id)
|
||||
return scanVideo(row)
|
||||
}
|
||||
|
||||
type ListParams struct {
|
||||
Keyword string
|
||||
Tag string
|
||||
Category string
|
||||
Sort string // latest | hot | week | long
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) { if p.PageSize <= 0 {
|
||||
p.PageSize = 24
|
||||
}
|
||||
if p.Page <= 0 {
|
||||
p.Page = 1
|
||||
}
|
||||
|
||||
var where []string
|
||||
var args []any
|
||||
if p.Keyword != "" {
|
||||
where = append(where, "(title LIKE ? OR author LIKE ?)")
|
||||
like := "%" + p.Keyword + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if p.Tag != "" {
|
||||
where = append(where, "tags LIKE ?")
|
||||
args = append(args, "%\""+p.Tag+"\"%")
|
||||
}
|
||||
if p.Category != "" && p.Category != "all" {
|
||||
where = append(where, "category = ?")
|
||||
args = append(args, p.Category)
|
||||
}
|
||||
|
||||
whereSQL := ""
|
||||
if len(where) > 0 {
|
||||
whereSQL = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
orderBy := " ORDER BY published_at DESC"
|
||||
switch p.Sort {
|
||||
case "hot":
|
||||
// 热度 = 点赞数,点赞相同按最新
|
||||
orderBy = " ORDER BY likes DESC, published_at DESC"
|
||||
case "week":
|
||||
orderBy = " ORDER BY likes DESC"
|
||||
case "long":
|
||||
orderBy = " ORDER BY duration_seconds DESC"
|
||||
}
|
||||
|
||||
// count
|
||||
var total int
|
||||
if err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM videos"+whereSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// list
|
||||
offset := (p.Page - 1) * p.PageSize
|
||||
rows, err := c.db.QueryContext(ctx,
|
||||
"SELECT "+allVideoCols+" FROM videos"+whereSQL+orderBy+" LIMIT ? OFFSET ?",
|
||||
append(args, p.PageSize, offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Video
|
||||
for rows.Next() {
|
||||
v, err := scanVideo(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// ---------- Drive ----------
|
||||
|
||||
type Drive struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RootID string `json:"rootId"`
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Credentials map[string]string `json:"credentials,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (c *Catalog) UpsertDrive(ctx context.Context, d *Drive) error {
|
||||
cred, _ := json.Marshal(d.Credentials)
|
||||
now := time.Now().UnixMilli()
|
||||
if d.CreatedAt.IsZero() {
|
||||
d.CreatedAt = time.UnixMilli(now)
|
||||
}
|
||||
d.UpdatedAt = time.UnixMilli(now)
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO drives (id, kind, name, root_id, scan_root_id, credentials, status, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
name = excluded.name,
|
||||
root_id = excluded.root_id,
|
||||
scan_root_id = excluded.scan_root_id,
|
||||
credentials = excluded.credentials,
|
||||
status = excluded.status,
|
||||
last_error = excluded.last_error,
|
||||
updated_at = excluded.updated_at
|
||||
`, d.ID, d.Kind, d.Name, d.RootID, d.ScanRootID, string(cred), d.Status, d.LastError,
|
||||
d.CreatedAt.UnixMilli(), d.UpdatedAt.UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Catalog) ListDrives(ctx context.Context) ([]*Drive, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Drive
|
||||
for rows.Next() {
|
||||
d := &Drive{}
|
||||
var credsStr string
|
||||
var createdAt, updatedAt int64
|
||||
if err := rows.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
|
||||
d.CreatedAt = time.UnixMilli(createdAt)
|
||||
d.UpdatedAt = time.UnixMilli(updatedAt)
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) GetDrive(ctx context.Context, id string) (*Drive, error) {
|
||||
row := c.db.QueryRowContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives WHERE id = ?`, id)
|
||||
d := &Drive{}
|
||||
var credsStr string
|
||||
var createdAt, updatedAt int64
|
||||
if err := row.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
|
||||
d.CreatedAt = time.UnixMilli(createdAt)
|
||||
d.UpdatedAt = time.UnixMilli(updatedAt)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
|
||||
_, err := c.db.ExecContext(ctx, `DELETE FROM drives WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Admin session ----------
|
||||
|
||||
func (c *Catalog) CreateSession(ctx context.Context, token string, ttl time.Duration) error {
|
||||
now := time.Now()
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`INSERT INTO admin_sessions (token, created_at, expires_at) VALUES (?, ?, ?)`,
|
||||
token, now.UnixMilli(), now.Add(ttl).UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Catalog) ValidateSession(ctx context.Context, token string) (bool, error) {
|
||||
var expires int64
|
||||
err := c.db.QueryRowContext(ctx, `SELECT expires_at FROM admin_sessions WHERE token = ?`, token).Scan(&expires)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return time.Now().UnixMilli() < expires, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := c.db.ExecContext(ctx, `DELETE FROM admin_sessions WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
func (c *Catalog) GetSetting(ctx context.Context, key, defaultValue string) (string, error) {
|
||||
var v string
|
||||
err := c.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return defaultValue, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, key, value, time.Now().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
const allVideoCols = `
|
||||
id, drive_id, file_id, COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'),
|
||||
duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''),
|
||||
COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'),
|
||||
views, favorites, comments, likes, dislikes,
|
||||
COALESCE(category, ''), COALESCE(badges, '[]'), COALESCE(description, ''),
|
||||
published_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanVideo(row rowScanner) (*Video, error) {
|
||||
v := &Video{}
|
||||
var tagsJSON, badgesJSON string
|
||||
var publishedAt, createdAt, updatedAt int64
|
||||
err := row.Scan(
|
||||
&v.ID, &v.DriveID, &v.FileID, &v.ParentID, &v.Title, &v.Author, &tagsJSON,
|
||||
&v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL,
|
||||
&v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus,
|
||||
&v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes,
|
||||
&v.Category, &badgesJSON, &v.Description,
|
||||
&publishedAt, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &v.Tags)
|
||||
_ = json.Unmarshal([]byte(badgesJSON), &v.Badges)
|
||||
v.PublishedAt = time.UnixMilli(publishedAt)
|
||||
v.CreatedAt = time.UnixMilli(createdAt)
|
||||
v.UpdatedAt = time.UnixMilli(updatedAt)
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
-- 视频元数据主表
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY, -- <drive>-<fileID> 拼接的稳定 ID
|
||||
drive_id TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
tags TEXT, -- JSON array
|
||||
duration_seconds INTEGER DEFAULT 0,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
ext TEXT,
|
||||
quality TEXT, -- HD / SD
|
||||
thumbnail_url TEXT,
|
||||
preview_file_id TEXT, -- 回写网盘后的 teaser file id
|
||||
preview_local TEXT, -- 本地 teaser 路径(兜底)
|
||||
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed
|
||||
views INTEGER DEFAULT 0,
|
||||
favorites INTEGER DEFAULT 0,
|
||||
comments INTEGER DEFAULT 0,
|
||||
likes INTEGER DEFAULT 0,
|
||||
dislikes INTEGER DEFAULT 0,
|
||||
category TEXT,
|
||||
badges TEXT, -- JSON array
|
||||
description TEXT,
|
||||
published_at INTEGER NOT NULL, -- unix ms
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_drive ON videos(drive_id, file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_pub ON videos(published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_views ON videos(views DESC);
|
||||
|
||||
-- 网盘账户
|
||||
CREATE TABLE IF NOT EXISTS drives (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- quark / p115 / wopan
|
||||
name TEXT NOT NULL,
|
||||
root_id TEXT NOT NULL DEFAULT '0',
|
||||
scan_root_id TEXT, -- 扫描起点(默认 root_id)
|
||||
credentials TEXT, -- JSON: cookie / refresh_token 等
|
||||
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 扫描任务状态
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drive_id TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
scanned INTEGER DEFAULT 0,
|
||||
added INTEGER DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- 管理后台 session(简单 token 存储)
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 全局 key-value 设置(preview 开关等)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
Scanner Scanner `yaml:"scanner"`
|
||||
Preview Preview `yaml:"preview"`
|
||||
Drives []Drive `yaml:"drives"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Admin Admin `yaml:"admin"`
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
DBPath string `yaml:"db_path"`
|
||||
LocalPreviewDir string `yaml:"local_preview_dir"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
IntervalSeconds int `yaml:"interval_seconds"`
|
||||
MaxDepth int `yaml:"max_depth"`
|
||||
VideoExtensions []string `yaml:"video_extensions"`
|
||||
}
|
||||
|
||||
type Preview struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
FFprobePath string `yaml:"ffprobe_path"`
|
||||
DurationSeconds int `yaml:"duration_seconds"`
|
||||
Width int `yaml:"width"`
|
||||
Segments int `yaml:"segments"`
|
||||
RemoteDir string `yaml:"remote_dir"`
|
||||
}
|
||||
|
||||
// Drive 配置项中的敏感字段(Cookie / RefreshToken 等)最终由管理后台写入 DB
|
||||
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
|
||||
type Drive struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // quark / p115 / wopan
|
||||
Name string `yaml:"name"`
|
||||
RootID string `yaml:"root_id"`
|
||||
Params map[string]string `yaml:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Load 读取配置;若不存在则从 config.example.yaml 复制一份并返回
|
||||
func Load(path string) (*Config, error) {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
example := filepath.Join(filepath.Dir(path), "config.example.yaml")
|
||||
data, err := os.ReadFile(example)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config not found and example missing: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write default config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
c.applyDefaults()
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Server.Listen == "" {
|
||||
c.Server.Listen = ":8080"
|
||||
}
|
||||
if c.Storage.DBPath == "" {
|
||||
c.Storage.DBPath = "./data/video-site.db"
|
||||
}
|
||||
if c.Storage.LocalPreviewDir == "" {
|
||||
c.Storage.LocalPreviewDir = "./data/previews"
|
||||
}
|
||||
if c.Scanner.MaxDepth == 0 {
|
||||
c.Scanner.MaxDepth = 5
|
||||
}
|
||||
if len(c.Scanner.VideoExtensions) == 0 {
|
||||
c.Scanner.VideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
|
||||
}
|
||||
if c.Preview.FFmpegPath == "" {
|
||||
c.Preview.FFmpegPath = "ffmpeg"
|
||||
}
|
||||
if c.Preview.FFprobePath == "" {
|
||||
c.Preview.FFprobePath = "ffprobe"
|
||||
}
|
||||
if c.Preview.DurationSeconds == 0 {
|
||||
c.Preview.DurationSeconds = 9
|
||||
}
|
||||
if c.Preview.Width == 0 {
|
||||
c.Preview.Width = 480
|
||||
}
|
||||
if c.Preview.Segments == 0 {
|
||||
c.Preview.Segments = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package drives
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Drive 是三家网盘统一抽象。上层不区分盘,只区分 Kind。
|
||||
type Drive interface {
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "wopan"
|
||||
Kind() string
|
||||
|
||||
// ID 返回该盘在 catalog 中的唯一标识
|
||||
ID() string
|
||||
|
||||
// Init 完成登录态校验;登录态由 Authenticator 另行获取后注入
|
||||
Init(ctx context.Context) error
|
||||
|
||||
// List 列指定目录下的直接子项
|
||||
List(ctx context.Context, dirID string) ([]Entry, error)
|
||||
|
||||
// Stat 拿到单个文件的元数据
|
||||
Stat(ctx context.Context, fileID string) (*Entry, error)
|
||||
|
||||
// StreamURL 返回一次性直链 + 必须的请求头
|
||||
// 代理层据此回源,透传 Range
|
||||
StreamURL(ctx context.Context, fileID string) (*StreamLink, error)
|
||||
|
||||
// Upload 把本地流写入指定目录,返回新文件 fileID
|
||||
// 用于 scanner 把 teaser 写回网盘
|
||||
Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error)
|
||||
|
||||
// EnsureDir 保证指定路径存在(相对根目录),返回最终目录 fileID
|
||||
// 例如传 "/previews" 会保证根下有一个 previews 目录
|
||||
EnsureDir(ctx context.Context, pathFromRoot string) (string, error)
|
||||
|
||||
// RootID 返回根目录 fileID
|
||||
RootID() string
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Name string
|
||||
Size int64
|
||||
IsDir bool
|
||||
ParentID string
|
||||
MimeType string
|
||||
ModTime time.Time
|
||||
|
||||
// 部分网盘额外信息
|
||||
Category int // 1=视频 (quark)
|
||||
}
|
||||
|
||||
type StreamLink struct {
|
||||
URL string
|
||||
Headers http.Header
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// ErrNotSupported 代表某家盘不支持某操作
|
||||
var ErrNotSupported = errors.New("operation not supported by this drive")
|
||||
@@ -0,0 +1,217 @@
|
||||
package p115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
cookie string
|
||||
rootID string
|
||||
client *sdk.Pan115Client
|
||||
ua string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
Cookie string // 形如 "UID=xxx; CID=xxx; SEID=xxx; KID=xxx"
|
||||
RootID string // 默认 "0"
|
||||
UA string // 默认 UA115Browser
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
ua := c.UA
|
||||
if ua == "" {
|
||||
ua = sdk.UA115Browser
|
||||
}
|
||||
return &Driver{
|
||||
id: c.ID,
|
||||
cookie: c.Cookie,
|
||||
rootID: rootID,
|
||||
ua: ua,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "p115" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
cr := &sdk.Credential{}
|
||||
if err := cr.FromCookie(d.cookie); err != nil {
|
||||
return fmt.Errorf("parse cookie: %w", err)
|
||||
}
|
||||
d.client = sdk.New(sdk.UA(d.ua)).ImportCredential(cr)
|
||||
return d.client.LoginCheck()
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
files, err := d.client.ListWithLimit(dirID, sdk.FileListLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 list: %w", err)
|
||||
}
|
||||
if files == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]drives.Entry, 0, len(*files))
|
||||
for _, f := range *files {
|
||||
out = append(out, fileToEntry(&f, dirID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
f, err := d.client.GetFile(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 stat: %w", err)
|
||||
}
|
||||
if f == nil {
|
||||
return nil, errors.New("115 stat: not found")
|
||||
}
|
||||
e := fileToEntry(f, f.ParentID)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
// 需要先拿到 pickCode
|
||||
f, err := d.client.GetFile(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 get file: %w", err)
|
||||
}
|
||||
info, err := d.client.DownloadWithUA(f.PickCode, d.ua)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 download url: %w", err)
|
||||
}
|
||||
if info == nil || info.Url.Url == "" {
|
||||
return nil, errors.New("115 download url: empty")
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("User-Agent", d.ua)
|
||||
// 115 直链会返回一组 Cookie / Referer,info.Header 里带了
|
||||
for k, vs := range info.Header {
|
||||
for _, v := range vs {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return &drives.StreamLink{
|
||||
URL: info.Url.Url,
|
||||
Headers: headers,
|
||||
Expires: time.Now().Add(25 * time.Minute), // 115 直链 30 分钟过期,留余量
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
// 115 上传流程比较复杂:RapidUpload -> OSS 分片
|
||||
// 第一版 teaser 文件小(<2MB),直接读全量写 seeker,走 RapidUploadOrByOSS
|
||||
buf, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rs := strings.NewReader(string(buf))
|
||||
if err := d.client.RapidUploadOrByOSS(parentID, name, size, rs); err != nil {
|
||||
return "", fmt.Errorf("115 upload: %w", err)
|
||||
}
|
||||
// RapidUploadOrByOSS 目前没返回 fileID,需要回查
|
||||
files, err := d.client.ListWithLimit(parentID, sdk.FileListLimit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("115 upload verify: %w", err)
|
||||
}
|
||||
if files != nil {
|
||||
for _, f := range *files {
|
||||
if !f.IsDirectory && f.Name == name {
|
||||
return f.FileID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("115 upload: file not found after upload")
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
id, err := d.client.Mkdir(currentID, name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("115 mkdir %s: %w", name, err)
|
||||
}
|
||||
childID = id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func fileToEntry(f *sdk.File, parentID string) drives.Entry {
|
||||
return drives.Entry{
|
||||
ID: f.FileID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
IsDir: f.IsDirectory,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.Name),
|
||||
ModTime: f.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func guessMime(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mkv":
|
||||
return "video/x-matroska"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
@@ -0,0 +1,345 @@
|
||||
package quark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
|
||||
defaultReferer = "https://pan.quark.cn"
|
||||
defaultAPI = "https://drive.quark.cn/1/clouddrive"
|
||||
defaultPR = "ucpro"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
cookie string
|
||||
rootID string
|
||||
ua string
|
||||
referer string
|
||||
apiBase string
|
||||
pr string
|
||||
client *resty.Client
|
||||
onCookieUpdate func(string)
|
||||
useTranscodingAddress bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
Cookie string
|
||||
RootID string
|
||||
UseTranscodingAddress bool // 开启后对视频文件返回转码直链(支持 302),但可能画质不一致
|
||||
OnCookieUpdate func(cookie string)
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
d := &Driver{
|
||||
id: c.ID,
|
||||
cookie: c.Cookie,
|
||||
rootID: rootID,
|
||||
ua: defaultUA,
|
||||
referer: defaultReferer,
|
||||
apiBase: defaultAPI,
|
||||
pr: defaultPR,
|
||||
useTranscodingAddress: c.UseTranscodingAddress,
|
||||
onCookieUpdate: c.OnCookieUpdate,
|
||||
}
|
||||
d.client = resty.New().
|
||||
SetTimeout(30 * time.Second).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Referer", d.referer).
|
||||
SetHeader("User-Agent", d.ua)
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "quark" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
// ---------- 公共请求 ----------
|
||||
|
||||
type resp struct {
|
||||
Status int `json:"status"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (d *Driver) request(ctx context.Context, path, method string, query map[string]string, body any, out any) error {
|
||||
req := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Cookie", d.cookie).
|
||||
SetQueryParam("pr", d.pr).
|
||||
SetQueryParam("fr", "pc")
|
||||
if query != nil {
|
||||
req.SetQueryParams(query)
|
||||
}
|
||||
if body != nil {
|
||||
req.SetBody(body)
|
||||
}
|
||||
if out != nil {
|
||||
req.SetResult(out)
|
||||
}
|
||||
var e resp
|
||||
req.SetError(&e)
|
||||
|
||||
res, err := req.Execute(method, d.apiBase+path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理 cookie 刷新(__puus)
|
||||
for _, ck := range res.Cookies() {
|
||||
if ck.Name == "__puus" {
|
||||
d.cookie = setCookieValue(d.cookie, "__puus", ck.Value)
|
||||
if d.onCookieUpdate != nil {
|
||||
d.onCookieUpdate(d.cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if e.Status >= 400 || e.Code != 0 {
|
||||
if e.Message == "" {
|
||||
return fmt.Errorf("quark api error: status=%d code=%d", e.Status, e.Code)
|
||||
}
|
||||
return errors.New(e.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
return d.request(ctx, "/config", http.MethodGet, nil, nil, nil)
|
||||
}
|
||||
|
||||
// ---------- 列目录 ----------
|
||||
|
||||
type file struct {
|
||||
Fid string `json:"fid"`
|
||||
FileName string `json:"file_name"`
|
||||
Size int64 `json:"size"`
|
||||
Category int `json:"category"`
|
||||
File bool `json:"file"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type sortResp struct {
|
||||
Data struct {
|
||||
List []file `json:"list"`
|
||||
} `json:"data"`
|
||||
Metadata struct {
|
||||
Total int `json:"_total"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
var out []drives.Entry
|
||||
page := 1
|
||||
size := 100
|
||||
for {
|
||||
q := map[string]string{
|
||||
"pdir_fid": dirID,
|
||||
"_size": strconv.Itoa(size),
|
||||
"_page": strconv.Itoa(page),
|
||||
"_fetch_total": "1",
|
||||
"fetch_all_file": "1",
|
||||
"fetch_risk_file_name": "1",
|
||||
}
|
||||
var r sortResp
|
||||
if err := d.request(ctx, "/file/sort", http.MethodGet, q, nil, &r); err != nil {
|
||||
return nil, fmt.Errorf("quark list: %w", err)
|
||||
}
|
||||
for _, f := range r.Data.List {
|
||||
out = append(out, fileToEntry(&f, dirID))
|
||||
}
|
||||
if page*size >= r.Metadata.Total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
// 夸克没提供单文件查询接口,回退到父目录遍历需要额外信息
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
|
||||
// ---------- 下载直链 ----------
|
||||
|
||||
type downResp struct {
|
||||
Data []struct {
|
||||
DownloadUrl string `json:"download_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
body := map[string]any{"fids": []string{fileID}}
|
||||
var r downResp
|
||||
if err := d.request(ctx, "/file/download", http.MethodPost, nil, body, &r); err != nil {
|
||||
return nil, fmt.Errorf("quark download: %w", err)
|
||||
}
|
||||
if len(r.Data) == 0 || r.Data[0].DownloadUrl == "" {
|
||||
return nil, errors.New("quark download: empty url")
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("User-Agent", d.ua)
|
||||
headers.Set("Referer", d.referer)
|
||||
headers.Set("Cookie", d.cookie)
|
||||
|
||||
return &drives.StreamLink{
|
||||
URL: r.Data[0].DownloadUrl,
|
||||
Headers: headers,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- 创建目录 ----------
|
||||
|
||||
type mkdirResp struct {
|
||||
Data struct {
|
||||
Fid string `json:"fid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (d *Driver) MakeDir(ctx context.Context, parentID, name string) (string, error) {
|
||||
body := map[string]any{
|
||||
"dir_init_lock": false,
|
||||
"dir_path": "",
|
||||
"file_name": name,
|
||||
"pdir_fid": parentID,
|
||||
}
|
||||
var r mkdirResp
|
||||
if err := d.request(ctx, "/file", http.MethodPost, nil, body, &r); err != nil {
|
||||
return "", fmt.Errorf("quark mkdir: %w", err)
|
||||
}
|
||||
return r.Data.Fid, nil
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
id, err := d.MakeDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
childID = id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ---------- 上传(第一版不实现,走本地 teaser 兜底) ----------
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func fileToEntry(f *file, parentID string) drives.Entry {
|
||||
return drives.Entry{
|
||||
ID: f.Fid,
|
||||
Name: f.FileName,
|
||||
Size: f.Size,
|
||||
IsDir: !f.File,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.FileName),
|
||||
ModTime: time.UnixMilli(f.UpdatedAt),
|
||||
Category: f.Category,
|
||||
}
|
||||
}
|
||||
|
||||
func guessMime(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mkv":
|
||||
return "video/x-matroska"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
// setCookieValue 替换 cookie 字符串中某个 key 的值,不存在则追加
|
||||
func setCookieValue(cookie, key, value string) string {
|
||||
if cookie == "" {
|
||||
return key + "=" + value
|
||||
}
|
||||
parts := strings.Split(cookie, ";")
|
||||
var out []string
|
||||
found := false
|
||||
for _, p := range parts {
|
||||
kv := strings.TrimSpace(p)
|
||||
if kv == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq < 0 {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
if kv[:eq] == key {
|
||||
out = append(out, key+"="+value)
|
||||
found = true
|
||||
} else {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, key+"="+value)
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
@@ -0,0 +1,231 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/OpenListTeam/wopan-sdk-go"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
// Driver 封装联通沃盘
|
||||
type Driver struct {
|
||||
id string
|
||||
rootID string
|
||||
familyID string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
client *sdk.WoClient
|
||||
onTokenUpdate func(access, refresh string)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
FamilyID string // 空则走个人空间,有值则走家庭空间
|
||||
RootID string // 根目录 ID,默认 "0"
|
||||
// 当 SDK 刷新 token 时回调,便于持久化
|
||||
OnTokenUpdate func(access, refresh string)
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
return &Driver{
|
||||
id: c.ID,
|
||||
rootID: rootID,
|
||||
familyID: c.FamilyID,
|
||||
accessToken: c.AccessToken,
|
||||
refreshToken: c.RefreshToken,
|
||||
onTokenUpdate: c.OnTokenUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "wopan" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string {
|
||||
return d.rootID
|
||||
}
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
d.client = sdk.DefaultWithRefreshToken(d.refreshToken)
|
||||
d.client.SetAccessToken(d.accessToken)
|
||||
d.client.OnRefreshToken(func(access, refresh string) {
|
||||
d.accessToken = access
|
||||
d.refreshToken = refresh
|
||||
if d.onTokenUpdate != nil {
|
||||
d.onTokenUpdate(access, refresh)
|
||||
}
|
||||
})
|
||||
// InitData 会触发一次 token 校验
|
||||
return d.client.InitData()
|
||||
}
|
||||
|
||||
func (d *Driver) spaceType() string {
|
||||
if d.familyID != "" {
|
||||
return sdk.SpaceTypeFamily
|
||||
}
|
||||
return sdk.SpaceTypePersonal
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
var result []drives.Entry
|
||||
pageNum := 0
|
||||
pageSize := 100
|
||||
for {
|
||||
data, err := d.client.QueryAllFiles(d.spaceType(), dirID, pageNum, pageSize, 0, d.familyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wopan list: %w", err)
|
||||
}
|
||||
for _, f := range data.Files {
|
||||
result = append(result, fileToEntry(f, dirID))
|
||||
}
|
||||
if len(data.Files) < pageSize {
|
||||
break
|
||||
}
|
||||
pageNum++
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
// 沃盘 SDK 没有单文件查询,退化为遍历父目录 —— 这里第一版只在 scanner 路径使用 List,Stat 保留 stub
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
data, err := d.client.GetDownloadUrlV2([]string{fileID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wopan download url: %w", err)
|
||||
}
|
||||
if len(data.List) == 0 {
|
||||
return nil, fmt.Errorf("wopan download url: empty response")
|
||||
}
|
||||
return &drives.StreamLink{
|
||||
URL: data.List[0].DownloadUrl,
|
||||
Headers: http.Header{},
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
// wopan SDK 要求 *os.File,先把流落到临时文件再上传
|
||||
tmp, err := os.CreateTemp("", "wopan-upload-*.tmp")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tmp.Seek(0, 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fid, err := d.client.Upload2C(d.spaceType(), sdk.Upload2CFile{
|
||||
Name: name,
|
||||
Size: size,
|
||||
Content: tmp,
|
||||
ContentType: "application/octet-stream",
|
||||
}, parentID, d.familyID, sdk.Upload2COption{Ctx: ctx})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wopan upload: %w", err)
|
||||
}
|
||||
return fid, nil
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wopan mkdir %s: %w", name, err)
|
||||
}
|
||||
childID = resp.Id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func fileToEntry(f *sdk.File, parentID string) drives.Entry {
|
||||
mod, _ := time.Parse("2006-01-02 15:04:05", f.CreateTime)
|
||||
name := f.Name
|
||||
isDir := f.Type == 0
|
||||
id := f.Fid
|
||||
if id == "" {
|
||||
id = f.Id
|
||||
}
|
||||
if isDir && !strings.HasSuffix(name, "/") {
|
||||
// 不改 name,只标志
|
||||
}
|
||||
return drives.Entry{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Size: f.Size,
|
||||
IsDir: isDir,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(name),
|
||||
ModTime: mod,
|
||||
}
|
||||
}
|
||||
|
||||
func guessMime(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mkv":
|
||||
return "video/x-matroska"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// 确保实现接口
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
@@ -0,0 +1,493 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
FFmpegPath string
|
||||
FFprobePath string
|
||||
DurationSeconds int // 单段时长(秒),用于单段 fallback;拼接模式下每段 = DurationSeconds / 段数
|
||||
Width int
|
||||
Segments int // teaser 段数,1=单段,推荐 3
|
||||
LocalDir string // 本地兜底
|
||||
RemoteDir string // 远端目录路径(相对盘根)
|
||||
}
|
||||
|
||||
type Generator struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New(cfg Config) *Generator {
|
||||
if cfg.FFmpegPath == "" {
|
||||
cfg.FFmpegPath = "ffmpeg"
|
||||
}
|
||||
if cfg.FFprobePath == "" {
|
||||
cfg.FFprobePath = "ffprobe"
|
||||
}
|
||||
if cfg.DurationSeconds == 0 {
|
||||
cfg.DurationSeconds = 9 // 3 段 × 3 秒
|
||||
}
|
||||
if cfg.Width == 0 {
|
||||
cfg.Width = 480
|
||||
}
|
||||
if cfg.Segments <= 0 {
|
||||
cfg.Segments = 3
|
||||
}
|
||||
return &Generator{cfg: cfg}
|
||||
}
|
||||
|
||||
// --- 选段策略 ---
|
||||
|
||||
// pickSegmentStarts 根据视频总时长选出 N 段起点秒数(按时间升序)
|
||||
//
|
||||
// 规则:
|
||||
// - duration < 30s → 单段从 max(2, duration*0.1) 起
|
||||
// - 30s ≤ duration < 10min → N 段:前段跳过片头、末段避开片尾
|
||||
// - duration ≥ 10min → 20% / 50% / 80%(或按 N 等距分布)
|
||||
func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 {
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
}
|
||||
if duration <= 0 {
|
||||
// 未知时长,用保守默认
|
||||
return []float64{10}
|
||||
}
|
||||
// 余量:保证最后一段结束前留 1 秒,避免切到文件末尾
|
||||
usable := duration - eachSec - 1
|
||||
if usable < 0 {
|
||||
usable = 0
|
||||
}
|
||||
|
||||
if duration < 30 {
|
||||
start := math.Max(2, duration*0.1)
|
||||
if start > usable {
|
||||
start = math.Max(0, usable)
|
||||
}
|
||||
return []float64{start}
|
||||
}
|
||||
|
||||
if duration < 600 {
|
||||
// 30s ~ 10min:20% 起,均匀分段
|
||||
starts := make([]float64, 0, n)
|
||||
// 保证第一段跳过片头(>= 5% 或 3s)
|
||||
firstMin := math.Max(3, duration*0.05)
|
||||
// 最后一段结束 <= 85%,避开结尾
|
||||
lastMax := duration * 0.85
|
||||
if lastMax < firstMin {
|
||||
lastMax = firstMin
|
||||
}
|
||||
if n == 1 {
|
||||
return []float64{duration * 0.25}
|
||||
}
|
||||
step := (lastMax - firstMin) / float64(n-1)
|
||||
for i := 0; i < n; i++ {
|
||||
s := firstMin + step*float64(i)
|
||||
if s > usable {
|
||||
s = usable
|
||||
}
|
||||
starts = append(starts, s)
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// 长视频:按 20% / 50% / 80% 布置
|
||||
if n == 1 {
|
||||
return []float64{duration * 0.3}
|
||||
}
|
||||
starts := make([]float64, 0, n)
|
||||
pct := make([]float64, 0, n)
|
||||
// 均匀在 [0.2, 0.8] 区间取 N 个点
|
||||
lo, hi := 0.2, 0.8
|
||||
if n == 1 {
|
||||
pct = append(pct, 0.3)
|
||||
} else {
|
||||
step := (hi - lo) / float64(n-1)
|
||||
for i := 0; i < n; i++ {
|
||||
pct = append(pct, lo+step*float64(i))
|
||||
}
|
||||
}
|
||||
for _, p := range pct {
|
||||
s := duration * p
|
||||
if s > usable {
|
||||
s = usable
|
||||
}
|
||||
starts = append(starts, s)
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// pickThumbnailOffset 选封面抽帧的时间点(秒)。独立于 teaser。
|
||||
func pickThumbnailOffset(duration float64) float64 {
|
||||
if duration <= 0 {
|
||||
return 5
|
||||
}
|
||||
// 短视频从 30% 抽;长视频从 20% 抽,避开片头
|
||||
if duration < 60 {
|
||||
return math.Max(1, duration*0.3)
|
||||
}
|
||||
return math.Max(5, math.Min(duration*0.2, 120))
|
||||
}
|
||||
|
||||
// --- 封面 ---
|
||||
|
||||
// GenerateThumbnail 抽一张 jpg 封面。偏移点由 duration 决定(独立于 teaser)。
|
||||
func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) {
|
||||
dir := filepath.Join(g.cfg.LocalDir, "thumbs")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst := filepath.Join(dir, videoID+".jpg")
|
||||
offset := pickThumbnailOffset(duration)
|
||||
|
||||
ctx2, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-ss", fmt.Sprintf("%.2f", offset),
|
||||
}
|
||||
if h := buildHeaders(link.Headers); h != "" {
|
||||
args = append(args, "-headers", h)
|
||||
}
|
||||
args = append(args,
|
||||
"-i", link.URL,
|
||||
"-frames:v", "1",
|
||||
"-vf", fmt.Sprintf("scale=%d:-2", g.cfg.Width),
|
||||
"-q:v", "3",
|
||||
"-y", dst,
|
||||
)
|
||||
|
||||
cmd := exec.CommandContext(ctx2, g.cfg.FFmpegPath, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
os.Remove(dst)
|
||||
return "", fmt.Errorf("ffmpeg thumb: %w, stderr: %s", err, string(out))
|
||||
}
|
||||
if info, statErr := os.Stat(dst); statErr != nil || info.Size() == 0 {
|
||||
os.Remove(dst)
|
||||
return "", fmt.Errorf("ffmpeg thumb produced empty file, stderr: %s", string(out))
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// --- 时长 ---
|
||||
|
||||
// Probe 用 ffprobe 拿视频时长(秒,浮点)
|
||||
func (g *Generator) Probe(ctx context.Context, link *drives.StreamLink) (float64, error) {
|
||||
ctx2, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
}
|
||||
if h := buildHeaders(link.Headers); h != "" {
|
||||
args = append(args, "-headers", h)
|
||||
}
|
||||
args = append(args, link.URL)
|
||||
|
||||
cmd := exec.CommandContext(ctx2, g.cfg.FFprobePath, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe: %w", err)
|
||||
}
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if raw == "" || raw == "N/A" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.ParseFloat(raw, 64)
|
||||
}
|
||||
|
||||
// --- Teaser ---
|
||||
|
||||
// Generate 拉取 teaser 到本地临时文件,返回路径。
|
||||
// 根据 Config.Segments 和视频时长决定是单段还是多段拼接。
|
||||
func (g *Generator) Generate(ctx context.Context, link *drives.StreamLink, duration float64) (string, error) {
|
||||
if err := os.MkdirAll(g.cfg.LocalDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
segs := g.cfg.Segments
|
||||
// 视频太短直接单段
|
||||
if duration > 0 && duration < 30 {
|
||||
segs = 1
|
||||
}
|
||||
|
||||
eachSec := float64(g.cfg.DurationSeconds)
|
||||
if segs > 1 {
|
||||
eachSec = float64(g.cfg.DurationSeconds) / float64(segs)
|
||||
if eachSec < 2 {
|
||||
eachSec = 2
|
||||
}
|
||||
}
|
||||
|
||||
starts := pickSegmentStarts(duration, segs, eachSec)
|
||||
|
||||
ctx2, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// 用 ffmpeg 的 concat 滤镜一次输出:多个 -ss input 再 concat + fade
|
||||
tmp, err := os.CreateTemp(g.cfg.LocalDir, "teaser-*.mp4")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
tmp.Close()
|
||||
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
}
|
||||
headers := buildHeaders(link.Headers)
|
||||
|
||||
// 每段独立 -ss + -i,精确 seek 重新解码保证拼接帧准
|
||||
for _, s := range starts {
|
||||
if headers != "" {
|
||||
args = append(args, "-headers", headers)
|
||||
}
|
||||
args = append(args,
|
||||
"-ss", fmt.Sprintf("%.2f", s),
|
||||
"-t", fmt.Sprintf("%.2f", eachSec),
|
||||
"-i", link.URL,
|
||||
)
|
||||
}
|
||||
|
||||
if len(starts) == 1 {
|
||||
// 单段:无需 concat,直接缩放 + 无音
|
||||
args = append(args,
|
||||
"-an",
|
||||
"-vf", fmt.Sprintf("scale=%d:-2", g.cfg.Width),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "28",
|
||||
"-movflags", "+faststart",
|
||||
"-y", tmpPath,
|
||||
)
|
||||
} else {
|
||||
// 多段:各段缩放 + 0.2s 黑场淡入淡出,concat 拼接
|
||||
// filter_complex: [0:v]scale,fade=in:0:5,fade=out:start=eachSec-0.2:d=0.2[v0]; ...; [v0][v1][v2]concat=n=3:v=1:a=0[v]
|
||||
fadeIn := 0.2
|
||||
fadeOutStart := eachSec - 0.2
|
||||
if fadeOutStart < 0 {
|
||||
fadeOutStart = 0
|
||||
}
|
||||
var filter strings.Builder
|
||||
for i := range starts {
|
||||
if i > 0 {
|
||||
filter.WriteString(";")
|
||||
}
|
||||
fmt.Fprintf(&filter,
|
||||
"[%d:v]scale=%d:-2,fade=t=in:st=0:d=%.2f,fade=t=out:st=%.2f:d=0.2[v%d]",
|
||||
i, g.cfg.Width, fadeIn, fadeOutStart, i)
|
||||
}
|
||||
filter.WriteString(";")
|
||||
for i := range starts {
|
||||
fmt.Fprintf(&filter, "[v%d]", i)
|
||||
}
|
||||
fmt.Fprintf(&filter, "concat=n=%d:v=1:a=0[v]", len(starts))
|
||||
|
||||
args = append(args,
|
||||
"-filter_complex", filter.String(),
|
||||
"-map", "[v]",
|
||||
"-an",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "28",
|
||||
"-movflags", "+faststart",
|
||||
"-y", tmpPath,
|
||||
)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx2, g.cfg.FFmpegPath, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("ffmpeg: %w, stderr: %s", err, string(out))
|
||||
}
|
||||
|
||||
if info, statErr := os.Stat(tmpPath); statErr != nil || info.Size() == 0 {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("ffmpeg produced empty file, stderr: %s", string(out))
|
||||
}
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
// --- 本地落盘 ---
|
||||
|
||||
// MoveToLocal 把临时文件改名到稳定位置,返回最终路径
|
||||
func (g *Generator) MoveToLocal(tmpPath, videoID string) (string, error) {
|
||||
dst := filepath.Join(g.cfg.LocalDir, videoID+".mp4")
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
// 跨盘 rename 可能失败,fallback 到 copy
|
||||
if cerr := copyFile(tmpPath, dst); cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Worker ---
|
||||
|
||||
type Worker struct {
|
||||
Gen *Generator
|
||||
Catalog *catalog.Catalog
|
||||
Drive drives.Drive
|
||||
RemoteDir string
|
||||
ch chan *catalog.Video
|
||||
}
|
||||
|
||||
func NewWorker(gen *Generator, cat *catalog.Catalog, drv drives.Drive, remoteDir string) *Worker {
|
||||
return &Worker{
|
||||
Gen: gen,
|
||||
Catalog: cat,
|
||||
Drive: drv,
|
||||
RemoteDir: remoteDir,
|
||||
ch: make(chan *catalog.Video, 4096),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Enqueue(v *catalog.Video) {
|
||||
select {
|
||||
case w.ch <- v:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run 阻塞运行直到 ctx 取消
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case v := <-w.ch:
|
||||
w.process(ctx, v)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, v *catalog.Video) {
|
||||
link, err := w.Drive.StreamURL(ctx, v.FileID)
|
||||
if err != nil {
|
||||
log.Printf("[preview] streamURL %s: %v", v.Title, err)
|
||||
w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed")
|
||||
return
|
||||
}
|
||||
|
||||
// 1) 探时长(失败用 0 继续)
|
||||
duration := float64(v.DurationSeconds)
|
||||
if duration <= 0 {
|
||||
if dur, err := w.Gen.Probe(ctx, link); err == nil && dur > 0 {
|
||||
duration = dur
|
||||
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
|
||||
DurationSeconds: int(dur),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 封面(独立时间点,失败不致命)
|
||||
if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, duration); err != nil {
|
||||
log.Printf("[preview] thumbnail %s: %v", v.Title, err)
|
||||
} else {
|
||||
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
|
||||
ThumbnailURL: "/p/thumb/" + v.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// 3) teaser
|
||||
tmp, err := w.Gen.Generate(ctx, link, duration)
|
||||
if err != nil {
|
||||
log.Printf("[preview] generate %s: %v", v.Title, err)
|
||||
w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed")
|
||||
return
|
||||
}
|
||||
local, err := w.Gen.MoveToLocal(tmp, v.ID)
|
||||
if err != nil {
|
||||
log.Printf("[preview] move %s: %v", v.Title, err)
|
||||
w.Catalog.UpdatePreview(ctx, v.ID, "", "", "failed")
|
||||
return
|
||||
}
|
||||
|
||||
previewFileID := ""
|
||||
if w.RemoteDir != "" {
|
||||
if fid, uerr := w.uploadToDrive(ctx, v.ID, local); uerr == nil {
|
||||
previewFileID = fid
|
||||
} else {
|
||||
log.Printf("[preview] upload %s: %v (local only)", v.Title, uerr)
|
||||
}
|
||||
}
|
||||
w.Catalog.UpdatePreview(ctx, v.ID, previewFileID, local, "ready")
|
||||
log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration)
|
||||
}
|
||||
|
||||
func (w *Worker) uploadToDrive(ctx context.Context, videoID, localPath string) (string, error) {
|
||||
parentID, err := w.Drive.EnsureDir(ctx, w.RemoteDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return w.Drive.Upload(ctx, parentID, videoID+".mp4", f, stat.Size())
|
||||
}
|
||||
|
||||
// --- utils ---
|
||||
|
||||
func buildHeaders(h map[string][]string) string {
|
||||
if len(h) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for k, vs := range h {
|
||||
for _, v := range vs {
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(v)
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
// Registry 管理多个 Drive 实例
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
drives map[string]drives.Drive
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{drives: make(map[string]drives.Drive)}
|
||||
}
|
||||
|
||||
func (r *Registry) Set(id string, d drives.Drive) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.drives[id] = d
|
||||
}
|
||||
|
||||
func (r *Registry) Get(id string) (drives.Drive, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
d, ok := r.drives[id]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
func (r *Registry) All() []drives.Drive {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]drives.Drive, 0, len(r.drives))
|
||||
for _, d := range r.drives {
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registry) Remove(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.drives, id)
|
||||
}
|
||||
|
||||
// Proxy 根据 driveID + fileID 反向代理到真实网盘直链
|
||||
type Proxy struct {
|
||||
Registry *Registry
|
||||
// linkCache key: driveID + "/" + fileID,value: cachedLink
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]cachedLink
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type cachedLink struct {
|
||||
link *drives.StreamLink
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
func New(r *Registry) *Proxy {
|
||||
return &Proxy{
|
||||
Registry: r,
|
||||
cache: make(map[string]cachedLink),
|
||||
http: &http.Client{
|
||||
Timeout: 0, // 流式不设超时
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) {
|
||||
key := driveID + "/" + fileID
|
||||
|
||||
p.cacheMu.Lock()
|
||||
if c, ok := p.cache[key]; ok {
|
||||
// 缓存 30 秒,且不超过 link.Expires
|
||||
if time.Since(c.fetched) < 30*time.Second && time.Now().Before(c.link.Expires) {
|
||||
p.cacheMu.Unlock()
|
||||
return c.link, nil
|
||||
}
|
||||
}
|
||||
p.cacheMu.Unlock()
|
||||
|
||||
d, ok := p.Registry.Get(driveID)
|
||||
if !ok {
|
||||
return nil, errDriveNotFound
|
||||
}
|
||||
link, err := d.StreamURL(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.cacheMu.Lock()
|
||||
p.cache[key] = cachedLink{link: link, fetched: time.Now()}
|
||||
p.cacheMu.Unlock()
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) {
|
||||
link, err := p.getLink(r.Context(), driveID, fileID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
p.serve(w, r, link)
|
||||
}
|
||||
|
||||
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
|
||||
// 构造上游请求
|
||||
u, err := url.Parse(link.URL)
|
||||
if err != nil {
|
||||
http.Error(w, "bad upstream url", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 复制上游请求头
|
||||
for k, vs := range link.Headers {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
// 透传 Range
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
}
|
||||
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 透传响应头
|
||||
for _, k := range []string{
|
||||
"Content-Type", "Content-Length", "Content-Range",
|
||||
"Accept-Ranges", "Last-Modified", "Etag",
|
||||
} {
|
||||
if v := resp.Header.Get(k); v != "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ServeLocal 服务本地 teaser 文件
|
||||
func (p *Proxy) ServeLocal(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
var errDriveNotFound = &httpError{Code: http.StatusNotFound, Msg: "drive not found"}
|
||||
|
||||
type httpError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return e.Msg }
|
||||
@@ -0,0 +1,49 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsedName 从文件名里解析出的视频元数据
|
||||
type ParsedName struct {
|
||||
Title string
|
||||
Author string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var (
|
||||
reTags = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*`) // [tag1,tag2]
|
||||
reAuthor = regexp.MustCompile(`\s*-\s*([^-]+?)\s*$`) // - author
|
||||
)
|
||||
|
||||
// Parse 按约定解析:[tag1,tag2] 标题 - 作者.ext
|
||||
// 任何字段缺失都能降级
|
||||
func Parse(filename string) ParsedName {
|
||||
name := strings.TrimSuffix(filename, path.Ext(filename))
|
||||
|
||||
var out ParsedName
|
||||
|
||||
if m := reTags.FindStringSubmatch(name); m != nil {
|
||||
raw := m[1]
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == '、' || r == ' '
|
||||
})
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out.Tags = append(out.Tags, p)
|
||||
}
|
||||
}
|
||||
name = strings.TrimSpace(name[len(m[0]):])
|
||||
}
|
||||
|
||||
if m := reAuthor.FindStringSubmatch(name); m != nil {
|
||||
out.Author = strings.TrimSpace(m[1])
|
||||
name = strings.TrimSpace(name[:len(name)-len(m[0])])
|
||||
}
|
||||
|
||||
out.Title = strings.TrimSpace(name)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type Scanner struct {
|
||||
Catalog *catalog.Catalog
|
||||
Drive drives.Drive
|
||||
Exts map[string]bool
|
||||
MaxDepth int
|
||||
// 回调:新视频被加入后触发 teaser 生成
|
||||
OnNewVideo func(v *catalog.Video)
|
||||
}
|
||||
|
||||
func New(cat *catalog.Catalog, drv drives.Drive, exts []string, maxDepth int, onNew func(v *catalog.Video)) *Scanner {
|
||||
m := make(map[string]bool, len(exts))
|
||||
for _, e := range exts {
|
||||
m[strings.ToLower(e)] = true
|
||||
}
|
||||
if maxDepth == 0 {
|
||||
maxDepth = 5
|
||||
}
|
||||
return &Scanner{
|
||||
Catalog: cat,
|
||||
Drive: drv,
|
||||
Exts: m,
|
||||
MaxDepth: maxDepth,
|
||||
OnNewVideo: onNew,
|
||||
}
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Scanned int
|
||||
Added int
|
||||
}
|
||||
|
||||
// Run 从 Drive.RootID 开始扫描
|
||||
func (s *Scanner) Run(ctx context.Context, startDirID string) (Stats, error) {
|
||||
if startDirID == "" {
|
||||
startDirID = s.Drive.RootID()
|
||||
}
|
||||
stats := Stats{}
|
||||
if err := s.walk(ctx, startDirID, "", 0, &stats); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, stats *Stats) error {
|
||||
if depth >= s.MaxDepth {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := s.Drive.List(ctx, dirID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list %s: %w", dirID, err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir {
|
||||
// 跳过 previews 目录,避免扫到自己生成的 teaser
|
||||
if strings.EqualFold(e.Name, "previews") {
|
||||
continue
|
||||
}
|
||||
if err := s.walk(ctx, e.ID, e.Name, depth+1, stats); err != nil {
|
||||
log.Printf("[scanner] walk %s error: %v", e.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
stats.Scanned++
|
||||
ext := strings.ToLower(path.Ext(e.Name))
|
||||
if !s.Exts[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
|
||||
existing, _ := s.Catalog.GetVideo(ctx, id)
|
||||
if existing != nil {
|
||||
// 已存在但 category 空缺时,顺便补 category
|
||||
if existing.Category == "" && dirName != "" {
|
||||
_ = s.Catalog.UpdateVideoMeta(ctx, id, catalog.VideoMetaPatch{Category: dirName})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
parsed := Parse(e.Name)
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = strings.TrimSuffix(e.Name, ext)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
v := &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: s.Drive.ID(),
|
||||
FileID: e.ID,
|
||||
ParentID: e.ParentID,
|
||||
Title: parsed.Title,
|
||||
Author: parsed.Author,
|
||||
Tags: parsed.Tags,
|
||||
Ext: strings.TrimPrefix(ext, "."),
|
||||
Quality: "HD",
|
||||
Size: e.Size,
|
||||
PreviewStatus: "pending",
|
||||
Category: dirName,
|
||||
PublishedAt: orDefault(e.ModTime, now),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.Catalog.UpsertVideo(ctx, v); err != nil {
|
||||
log.Printf("[scanner] upsert %s error: %v", v.Title, err)
|
||||
continue
|
||||
}
|
||||
stats.Added++
|
||||
if s.OnNewVideo != nil {
|
||||
s.OnNewVideo(v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orDefault(t time.Time, d time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return d
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,24 @@
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# ide files
|
||||
.idea/
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# wopan-sdk-go
|
||||
Wopan SDK for the Go programming language
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/OpenListTeam/wopan-sdk-go
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/OpenListTeam/wopan-sdk-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
w := wopan.DefaultWithRefreshToken("91d4b946-xxxx-4909-bac1-d9914e45f2de")
|
||||
res, err := w.AppQueryUser()
|
||||
if err != nil {
|
||||
fmt.Printf("AppQueryUser() error = %v", err)
|
||||
} else {
|
||||
fmt.Printf("AppQueryUser() = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// pkcs7Padding 填充
|
||||
func pkcs7Padding(data []byte, blockSize int) []byte {
|
||||
//判断缺少几位长度。最少1,最多 blockSize
|
||||
padding := blockSize - len(data)%blockSize
|
||||
//补足位数。把切片[]byte{byte(padding)}复制padding个
|
||||
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(data, padText...)
|
||||
}
|
||||
|
||||
// pkcs7UnPadding 填充的反向操作
|
||||
func pkcs7UnPadding(data []byte) ([]byte, error) {
|
||||
length := len(data)
|
||||
if length == 0 {
|
||||
return nil, errors.New("加密字符串错误!")
|
||||
}
|
||||
//获取填充的个数
|
||||
unPadding := int(data[length-1])
|
||||
return data[:(length - unPadding)], nil
|
||||
}
|
||||
|
||||
// AesEncrypt 加密
|
||||
func AesEncrypt(data []byte, key []byte, iv []byte) ([]byte, error) {
|
||||
//创建加密实例
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//判断加密快的大小
|
||||
blockSize := block.BlockSize()
|
||||
//填充
|
||||
encryptBytes := pkcs7Padding(data, blockSize)
|
||||
//初始化加密数据接收切片
|
||||
encrypted := make([]byte, len(encryptBytes))
|
||||
//使用cbc加密模式
|
||||
//blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
|
||||
blockMode := cipher.NewCBCEncrypter(block, iv)
|
||||
//执行加密
|
||||
blockMode.CryptBlocks(encrypted, encryptBytes)
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// AesDecrypt 解密
|
||||
func AesDecrypt(data []byte, key []byte, iv []byte) ([]byte, error) {
|
||||
//创建实例
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//获取块的大小
|
||||
//blockSize := block.BlockSize()
|
||||
//使用cbc
|
||||
//blockMode := cipher.NewCBCDecrypter(block, key[:blockSize])
|
||||
blockMode := cipher.NewCBCDecrypter(block, iv)
|
||||
//初始化解密数据接收切片
|
||||
decrypted := make([]byte, len(data))
|
||||
//执行解密
|
||||
blockMode.CryptBlocks(decrypted, data)
|
||||
//去除填充
|
||||
decrypted, err = pkcs7UnPadding(decrypted)
|
||||
//decrypted = pkcs7Padding(decrypted, blockSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package wopan
|
||||
|
||||
type File struct {
|
||||
FamilyId int `json:"familyId"`
|
||||
Fid string `json:"fid"`
|
||||
Creator string `json:"creator"`
|
||||
Size int64 `json:"size"`
|
||||
CreateTime string `json:"createTime"`
|
||||
Name string `json:"name"`
|
||||
ShootingTime string `json:"shootingTime"`
|
||||
Id string `json:"id"`
|
||||
Type int `json:"type"`
|
||||
ThumbUrl string `json:"thumbUrl"`
|
||||
FileType string `json:"fileType"`
|
||||
}
|
||||
|
||||
type QueryAllFilesData struct {
|
||||
Files []*File `json:"files"`
|
||||
}
|
||||
|
||||
func (w *WoClient) QueryAllFiles(spaceType, parentDirectoryId string, pageNum, pageSize int, sortRule int, familyId string, opts ...RestyOption) (*QueryAllFilesData, error) {
|
||||
var resp QueryAllFilesData
|
||||
param := Json{
|
||||
"spaceType": spaceType,
|
||||
"parentDirectoryId": parentDirectoryId,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"sortRule": sortRule,
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
if spaceType == SpaceTypeFamily {
|
||||
param["familyId"] = familyId
|
||||
}
|
||||
if spaceType == SpaceTypePrivate {
|
||||
if w.psToken == "" {
|
||||
return nil, ErrInvalidPsToken
|
||||
}
|
||||
param["psToken"] = w.psToken
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyQueryAllFiles, param, JsonSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (w *WoClient) QueryAllFilesPersonal(parentDirectoryId string, pageNum, pageSize int, sortRule int, opts ...RestyOption) (*QueryAllFilesData, error) {
|
||||
return w.QueryAllFiles(SpaceTypePersonal, parentDirectoryId, pageNum, pageSize, sortRule, "", opts...)
|
||||
}
|
||||
|
||||
func (w *WoClient) QueryAllFilesFamily(parentDirectoryId string, pageNum, pageSize int, sortRule int, familyId string, opts ...RestyOption) (*QueryAllFilesData, error) {
|
||||
return w.QueryAllFiles(SpaceTypeFamily, parentDirectoryId, pageNum, pageSize, sortRule, familyId, opts...)
|
||||
}
|
||||
|
||||
// GetSearchDirectory??
|
||||
|
||||
type GetDownloadUrlV2Data struct {
|
||||
Type int `json:"type"`
|
||||
List []struct {
|
||||
Fid string `json:"fid"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
} `json:"list"`
|
||||
}
|
||||
|
||||
func (w *WoClient) GetDownloadUrlV2(fidList []string, opts ...RestyOption) (*GetDownloadUrlV2Data, error) {
|
||||
var resp GetDownloadUrlV2Data
|
||||
param := Json{
|
||||
"type": "1",
|
||||
"fidList": fidList,
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyGetDownloadUrlV2, param, JsonSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type GetDownloadUrlData struct {
|
||||
Fid string `json:"fid"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
}
|
||||
|
||||
func (w *WoClient) GetDownloadUrl(spaceType string, fidList []string, opts ...RestyOption) ([]GetDownloadUrlData, error) {
|
||||
var resp []GetDownloadUrlData
|
||||
param := Json{
|
||||
"fidList": fidList,
|
||||
"clientId": DefaultClientID, // ???
|
||||
"spaceType": spaceType,
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyGetDownloadUrl, param, JsonSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type CreateDirectoryData struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
func (w *WoClient) CreateDirectory(spaceType, parentDirectoryId string, directoryName, familyId string, opts ...RestyOption) (*CreateDirectoryData, error) {
|
||||
var resp CreateDirectoryData
|
||||
param := Json{
|
||||
"spaceType": spaceType,
|
||||
"familyId": familyId,
|
||||
"parentDirectoryId": parentDirectoryId,
|
||||
"directoryName": directoryName,
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
if spaceType == SpaceTypePrivate {
|
||||
if w.psToken == "" {
|
||||
return nil, ErrInvalidPsToken
|
||||
}
|
||||
param["psToken"] = w.psToken
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyCreateDirectory, param, JsonSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RenameFileOrDirectory
|
||||
// _type: 1: file, 0: directory
|
||||
func (w *WoClient) RenameFileOrDirectory(spaceType string, _type int, id string, name string, familyId string, opts ...RestyOption) error {
|
||||
fileType := "0"
|
||||
if _type != 0 {
|
||||
fileType = w.GetFileType(name)
|
||||
}
|
||||
param := Json{
|
||||
"spaceType": spaceType,
|
||||
"type": _type,
|
||||
"fileType": fileType,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
if spaceType == SpaceTypeFamily {
|
||||
param["familyId"] = familyId
|
||||
}
|
||||
if spaceType == SpaceTypePrivate {
|
||||
if w.psToken == "" {
|
||||
return ErrInvalidPsToken
|
||||
}
|
||||
param["psToken"] = w.psToken
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyRenameFileOrDirectory, param, JsonSecret, nil, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *WoClient) RenameFileOrDirectoryPersonal(_type int, id string, name string, opts ...RestyOption) error {
|
||||
return w.RenameFileOrDirectory(SpaceTypePersonal, _type, id, name, "", opts...)
|
||||
}
|
||||
|
||||
func (w *WoClient) RenameFileOrDirectoryFamily(_type int, id string, name string, familyId string, opts ...RestyOption) error {
|
||||
return w.RenameFileOrDirectory(SpaceTypeFamily, _type, id, name, familyId, opts...)
|
||||
}
|
||||
|
||||
func (w *WoClient) MoveFile(dirList, fileList []string, targetDirId string, sourceType, targetType string, fromFamilyId, targetFamilyId string, opts ...RestyOption) error {
|
||||
param := Json{
|
||||
"targetDirId": targetDirId,
|
||||
"sourceType": sourceType,
|
||||
"targetType": targetType,
|
||||
"dirList": dirList,
|
||||
"fileList": fileList,
|
||||
"secret": false,
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
if sourceType == SpaceTypeFamily {
|
||||
param["fromFamilyId"] = fromFamilyId
|
||||
}
|
||||
if targetType == SpaceTypeFamily {
|
||||
param["familyId"] = targetFamilyId
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyMoveFile, param, JsonSecret, nil, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *WoClient) CopyFile(dirList, fileList []string, targetDirId string, sourceType, targetType string, fromFamilyId, targetFamilyId string, opts ...RestyOption) error {
|
||||
param := Json{
|
||||
"targetDirId": targetDirId,
|
||||
"sourceType": sourceType,
|
||||
"targetType": targetType,
|
||||
"dirList": dirList,
|
||||
"fileList": fileList,
|
||||
"secret": false,
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
if sourceType == SpaceTypeFamily {
|
||||
param["fromFamilyId"] = fromFamilyId
|
||||
}
|
||||
if targetType == SpaceTypeFamily {
|
||||
param["familyId"] = targetFamilyId
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyCopyFile, param, JsonSecret, nil, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *WoClient) DeleteFile(spaceType string, dirList, fileList []string, opts ...RestyOption) error {
|
||||
param := Json{
|
||||
"spaceType": spaceType,
|
||||
"vipLevel": "0",
|
||||
"dirList": dirList,
|
||||
"fileList": fileList,
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyDeleteFile, param, JsonSecret, nil, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *WoClient) EmptyRecycleData(opts ...RestyOption) error {
|
||||
param := Json{
|
||||
"clientId": DefaultClientID,
|
||||
}
|
||||
_, err := w.RequestWoHome(KeyEmptyRecycleData, param, JsonSecret, nil, opts...)
|
||||
return err
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package wopan
|
||||
|
||||
// PcWebLoginData no encrypt
|
||||
type PcWebLoginData struct {
|
||||
NeedSmsCode string `json:"needSmsCode"`
|
||||
}
|
||||
|
||||
func (w *WoClient) PcWebLogin(phone, password string, opts ...RestyOption) (*PcWebLoginData, error) {
|
||||
var resp PcWebLoginData
|
||||
_, err := w.RequestApiUser(KeyPcWebLogin, Json{
|
||||
"phone": phone,
|
||||
"password": password,
|
||||
"uuid": "",
|
||||
"verifyCode": "",
|
||||
"clientSecret": DefaultClientSecret,
|
||||
}, JsonClientIDSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PcLoginVerifyCodeData no encrypt
|
||||
type PcLoginVerifyCodeData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (w *WoClient) PcLoginVerifyCode(phone, password, messageCode string, opts ...RestyOption) (*PcLoginVerifyCodeData, error) {
|
||||
var resp PcLoginVerifyCodeData
|
||||
_, err := w.RequestApiUser(KeyPcLoginVerifyCode, Json{
|
||||
"phone": phone,
|
||||
"messageCode": messageCode,
|
||||
"verifyCode": nil,
|
||||
"uuid": nil,
|
||||
"clientSecret": DefaultClientSecret,
|
||||
"password": password,
|
||||
}, JsonClientIDSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type AppQueryUserData struct {
|
||||
UserId string `json:"userId"`
|
||||
HeadUrl string `json:"headUrl"`
|
||||
UserName string `json:"userName"`
|
||||
Sex string `json:"sex"`
|
||||
Birthday string `json:"birthday"`
|
||||
IsModify string `json:"isModify"`
|
||||
IsHeadModify string `json:"isHeadModify"`
|
||||
IsSetPassword string `json:"isSetPassword"`
|
||||
RegisterTime string `json:"registerTime"`
|
||||
}
|
||||
|
||||
func (w *WoClient) AppQueryUser(opts ...RestyOption) (*AppQueryUserData, error) {
|
||||
var resp AppQueryUserData
|
||||
_, err := w.RequestApiUser(KeyAppQueryUser, Json{
|
||||
"accessToken": w.accessToken,
|
||||
}, JsonClientIDSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// AppRefreshTokenData no encrypt
|
||||
type AppRefreshTokenData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func (w *WoClient) AppRefreshToken(opts ...RestyOption) (*AppRefreshTokenData, error) {
|
||||
var resp AppRefreshTokenData
|
||||
_, err := w.RequestApiUser(KeyAppRefreshToken, Json{
|
||||
"refreshToken": w.refreshToken,
|
||||
"clientSecret": DefaultClientSecret,
|
||||
}, JsonClientIDSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
func (w *WoClient) AppLogout(opts ...RestyOption) error {
|
||||
_, err := w.RequestApiUser(KeyAppLogout, Json{
|
||||
"accessToken": w.accessToken,
|
||||
}, JsonClientIDSecret, nil, opts...)
|
||||
return err
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package wopan
|
||||
|
||||
type FCloudProductOrdListQryCtxData struct {
|
||||
FcloudProductOrds []struct {
|
||||
ActiviteCode string `json:"activiteCode"`
|
||||
AppStorePackageDesc string `json:"appStorePackageDesc"`
|
||||
AppStorePackageFee string `json:"appStorePackageFee"`
|
||||
AppStoreProductId string `json:"appStoreProductId"`
|
||||
ApplyTime string `json:"applyTime"`
|
||||
ApplyTimeFormate string `json:"applyTimeFormate"`
|
||||
CbssOrderId string `json:"cbssOrderId"`
|
||||
City string `json:"city"`
|
||||
ClientId string `json:"clientId"`
|
||||
Days string `json:"days"`
|
||||
DescUrl string `json:"descUrl"`
|
||||
EffectState string `json:"effectState"`
|
||||
EffectiveDays int `json:"effectiveDays"`
|
||||
ExpireTime string `json:"expireTime"`
|
||||
ExpireTimeFormate string `json:"expireTimeFormate"`
|
||||
Fee string `json:"fee"`
|
||||
IsAppStorePay string `json:"isAppStorePay"`
|
||||
IsAutoSub string `json:"isAutoSub"`
|
||||
IsExpire string `json:"isExpire"`
|
||||
IsNewPackage string `json:"isNewPackage"`
|
||||
IsOnline string `json:"isOnline"`
|
||||
IsPlus string `json:"isPlus"`
|
||||
IsShowExpireTips string `json:"isShowExpireTips"`
|
||||
OrderId string `json:"orderId"`
|
||||
OrderState string `json:"orderState"`
|
||||
OrderStatus string `json:"orderStatus"`
|
||||
PackageDesc string `json:"packageDesc"`
|
||||
PackageProductCode string `json:"packageProductCode"`
|
||||
PackageProductId string `json:"packageProductId"`
|
||||
PayMethod string `json:"payMethod"`
|
||||
PayTransactionId string `json:"payTransactionId"`
|
||||
PayType string `json:"payType"`
|
||||
Province string `json:"province"`
|
||||
RemainDays string `json:"remainDays"`
|
||||
SignStatus string `json:"signStatus"`
|
||||
Source string `json:"source"`
|
||||
SubTime string `json:"subTime"`
|
||||
SubTimeFormate string `json:"subTimeFormate"`
|
||||
SubType string `json:"subType"`
|
||||
UserId string `json:"userId"`
|
||||
VipDesc string `json:"vipDesc"`
|
||||
VipDescNew string `json:"vipDescNew"`
|
||||
VipExpireTimeLabel string `json:"vipExpireTimeLabel"`
|
||||
VipLevel string `json:"vipLevel"`
|
||||
} `json:"fcloudProductOrds"`
|
||||
MaxVipLevel string `json:"maxVipLevel"`
|
||||
IsShowInlet string `json:"isShowInlet"`
|
||||
}
|
||||
|
||||
func (w *WoClient) FCloudProductOrdListQry(opts ...RestyOption) (*FCloudProductOrdListQryCtxData, error) {
|
||||
var resp FCloudProductOrdListQryCtxData
|
||||
_, err := w.RequestWoHome(KeyFCloudProductOrdListQry, nil, Json{
|
||||
"qryType": "1",
|
||||
"clientId": DefaultClientID,
|
||||
}, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type QueryCloudUsageInfoData struct {
|
||||
Code string `json:"code"`
|
||||
UsageInfo struct {
|
||||
TotalSize string `json:"totalSize"`
|
||||
UsedSize int64 `json:"usedSize"`
|
||||
ImageSize int64 `json:"imageSize"`
|
||||
VideoSize int64 `json:"videoSize"`
|
||||
AudioSize int64 `json:"audioSize"`
|
||||
TextSize int64 `json:"textSize"`
|
||||
OtherSize int64 `json:"otherSize"`
|
||||
ByteUsedSize int64 `json:"byteUsedSize"`
|
||||
ByteTotalSize string `json:"byteTotalSize"`
|
||||
} `json:"usageInfo"`
|
||||
VipLevel string `json:"vipLevel"`
|
||||
ExpireTime string `json:"expireTime"`
|
||||
ApplyTime string `json:"applyTime"`
|
||||
PayType string `json:"payType"`
|
||||
Source string `json:"source"`
|
||||
OrderState string `json:"orderState"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (w *WoClient) QueryCloudUsageInfo(opts ...RestyOption) (*QueryCloudUsageInfoData, error) {
|
||||
err := w.InitPhone()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp QueryCloudUsageInfoData
|
||||
_, err = w.RequestWoHome(KeyQueryCloudUsageInfo, Json{
|
||||
"phoneNum": w.Phone,
|
||||
"clientId": DefaultClientID,
|
||||
}, JsonSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// FCloudProductPackage is not required
|
||||
|
||||
type ClassifyRuleData struct {
|
||||
FileTypes map[string]struct {
|
||||
SubType string `json:"subType"`
|
||||
Ability string `json:"ability"`
|
||||
Type string `json:"type"`
|
||||
} `json:"fileTypes"`
|
||||
FileIcons struct {
|
||||
App struct {
|
||||
Zip string `json:"zip"`
|
||||
Image string `json:"image"`
|
||||
Other string `json:"other"`
|
||||
Xmind string `json:"xmind"`
|
||||
Gif string `json:"gif"`
|
||||
Csv string `json:"csv"`
|
||||
Video string `json:"video"`
|
||||
Excel string `json:"excel"`
|
||||
Pdf string `json:"pdf"`
|
||||
Ppt string `json:"ppt"`
|
||||
Doc string `json:"doc"`
|
||||
Audio string `json:"audio"`
|
||||
Text string `json:"text"`
|
||||
Word string `json:"word"`
|
||||
} `json:"app"`
|
||||
H5 struct {
|
||||
} `json:"h5"`
|
||||
} `json:"fileIcons"`
|
||||
}
|
||||
|
||||
func (w *WoClient) ClassifyRule(opts ...RestyOption) (*ClassifyRuleData, error) {
|
||||
var resp ClassifyRuleData
|
||||
_, err := w.RequestWoHome(KeyClassifyRule, Json{}, Json{
|
||||
"key": true,
|
||||
}, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type GetZoneInfoData struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func (w *WoClient) GetZoneInfo(opts ...RestyOption) (*GetZoneInfoData, error) {
|
||||
var resp GetZoneInfoData
|
||||
_, err := w.RequestWoHome(KeyGetZoneInfo, Json{
|
||||
"appId": DefaultAppID,
|
||||
}, Json{
|
||||
"key": true,
|
||||
}, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type FamilyUserCurrentEncodeData struct {
|
||||
Count string `json:"count"`
|
||||
DefaultHomeId int `json:"defaultHomeId"`
|
||||
DefaultHomeName string `json:"defaultHomeName"`
|
||||
GroupHeadUrl string `json:"groupHeadUrl"`
|
||||
GroupName string `json:"groupName"`
|
||||
Id int `json:"id"`
|
||||
MemberRole string `json:"memberRole"`
|
||||
OwnerId string `json:"owner Id"`
|
||||
UnreadFlag string `json:"unreadFlag"`
|
||||
}
|
||||
|
||||
func (w *WoClient) FamilyUserCurrentEncode(opts ...RestyOption) (*FamilyUserCurrentEncodeData, error) {
|
||||
var resp FamilyUserCurrentEncodeData
|
||||
_, err := w.RequestWoHome(KeyFamilyUserCurrentEncode, Json{
|
||||
"clientId": DefaultClientID,
|
||||
}, JsonSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type PrivateSpaceLoginData struct {
|
||||
PsToken string `json:"psToken"`
|
||||
IsPass string `json:"isPass"`
|
||||
Desc string `json:"desc"`
|
||||
}
|
||||
|
||||
func (w *WoClient) PrivateSpaceLogin(pwd string, opts ...RestyOption) error {
|
||||
var resp PrivateSpaceLoginData
|
||||
_, err := w.RequestWoHome(KeyPrivateSpaceLogin, Json{
|
||||
"pwd": pwd,
|
||||
"clientId": DefaultClientID,
|
||||
}, JsonSecret, &resp, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.SetPsToken(resp.PsToken)
|
||||
return nil
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type WoClient struct {
|
||||
accessToken string
|
||||
refreshToken string
|
||||
psToken string
|
||||
|
||||
client *resty.Client
|
||||
crypto *Crypto
|
||||
ua string
|
||||
jsonMarshalFunc func(v interface{}) ([]byte, error)
|
||||
jsonUnmarshalFunc func(data []byte, v interface{}) error
|
||||
|
||||
Phone string
|
||||
ZoneURL string
|
||||
zoneURLOnce sync.Once
|
||||
ClassifyRuleData *ClassifyRuleData
|
||||
|
||||
onRefreshToken func(accessToken, refreshToken string)
|
||||
}
|
||||
|
||||
func New(opts ...Option) *WoClient {
|
||||
w := &WoClient{
|
||||
client: resty.New(),
|
||||
crypto: NewCrypto(),
|
||||
jsonMarshalFunc: json.Marshal,
|
||||
jsonUnmarshalFunc: json.Unmarshal,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func DefaultWithAccessToken(accessToken string) *WoClient {
|
||||
w := Default()
|
||||
w.SetAccessToken(accessToken)
|
||||
return w
|
||||
}
|
||||
|
||||
func DefaultWithRefreshToken(refreshToken string) *WoClient {
|
||||
w := Default()
|
||||
w.SetRefreshToken(refreshToken)
|
||||
return w
|
||||
}
|
||||
|
||||
func DefaultWithAccessAndPsToken(refreshToken, psToken string) *WoClient {
|
||||
w := Default()
|
||||
w.SetAccessToken(refreshToken)
|
||||
w.SetPsToken(psToken)
|
||||
return w
|
||||
}
|
||||
|
||||
func Default() *WoClient {
|
||||
return New(WithUA(DefaultUA))
|
||||
}
|
||||
|
||||
func (w *WoClient) SetUA(ua string) {
|
||||
w.ua = ua
|
||||
}
|
||||
|
||||
func (w *WoClient) SetJsonMarshalFunc(f func(v interface{}) ([]byte, error)) {
|
||||
w.jsonMarshalFunc = f
|
||||
}
|
||||
|
||||
func (w *WoClient) SetJsonUnmarshalFunc(f func(data []byte, v interface{}) error) {
|
||||
w.jsonUnmarshalFunc = f
|
||||
}
|
||||
|
||||
func (w *WoClient) SetAccessToken(token string) {
|
||||
w.accessToken = token
|
||||
_ = w.crypto.SetAccessToken(token)
|
||||
}
|
||||
|
||||
func (w *WoClient) SetRefreshToken(token string) {
|
||||
w.refreshToken = token
|
||||
}
|
||||
|
||||
func (w *WoClient) SetPsToken(psToken string) {
|
||||
w.psToken = psToken
|
||||
}
|
||||
|
||||
func (w *WoClient) GetToken() (string, string) {
|
||||
return w.accessToken, w.refreshToken
|
||||
}
|
||||
|
||||
func (w *WoClient) SetHttpClient(httpClient *http.Client) *WoClient {
|
||||
w.client = resty.NewWithClient(httpClient)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *WoClient) SetUserAgent(userAgent string) *WoClient {
|
||||
w.client.SetHeader("User-Agent", userAgent)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *WoClient) SetDebug(d bool) *WoClient {
|
||||
w.client.SetDebug(d)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *WoClient) EnableTrace() *WoClient {
|
||||
w.client.EnableTrace()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *WoClient) SetProxy(proxy string) *WoClient {
|
||||
w.client.SetProxy(proxy)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *WoClient) NewRequest() *resty.Request {
|
||||
return w.client.R()
|
||||
}
|
||||
|
||||
func (w *WoClient) GetFileType(filename string) string {
|
||||
ext := path.Ext(filename)
|
||||
if ext == "" {
|
||||
return "5"
|
||||
}
|
||||
ext = ext[1:]
|
||||
err := w.InitClassifyRule()
|
||||
if err != nil {
|
||||
return "5"
|
||||
}
|
||||
if _type, ok := w.ClassifyRuleData.FileTypes[ext]; ok {
|
||||
return _type.Type
|
||||
}
|
||||
return "5"
|
||||
}
|
||||
|
||||
func (w *WoClient) OnRefreshToken(f func(accessToken, refreshToken string)) {
|
||||
w.onRefreshToken = f
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package wopan
|
||||
|
||||
const (
|
||||
// app:
|
||||
// 1001000035
|
||||
// iELf0UL07o6I8eRK
|
||||
|
||||
DefaultClientID = "1001000021"
|
||||
DefaultClientSecret = "XFmi9GS2hzk98jGX"
|
||||
DefaultAppID = "10000001"
|
||||
DefaultBaseURL = "https://panservice.mail.wo.cn"
|
||||
// DefaultZoneURL old https://gxupload.pan.wo.cn:8443
|
||||
DefaultZoneURL = "https://tjupload.pan.wo.cn"
|
||||
DefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Edg/114.0.1823.37"
|
||||
DefaultPartSize = int64(8 * 1024 * 1024)
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelAPIUser = "api-user"
|
||||
ChannelWoHome = "wohome"
|
||||
ChannelWoCloud = "wocloud"
|
||||
)
|
||||
|
||||
const (
|
||||
SpaceTypePersonal = "0"
|
||||
SpaceTypeFamily = "1"
|
||||
SpaceTypePrivate = "4"
|
||||
)
|
||||
|
||||
// api-user methods
|
||||
const (
|
||||
KeyPcWebLogin = "PcWebLogin"
|
||||
KeyPcLoginVerifyCode = "PcLoginVerifyCode"
|
||||
KeyAppQueryUser = "AppQueryUser"
|
||||
KeyAppRefreshToken = "AppRefreshToken"
|
||||
KeyAppLogout = "AppLogout"
|
||||
)
|
||||
|
||||
// wohome methods
|
||||
const (
|
||||
KeyFCloudProductOrdListQry = "FCloudProductOrdListQry"
|
||||
KeyQueryCloudUsageInfo = "QueryCloudUsageInfo"
|
||||
KeyFCloudProductPackage = "FCloudProductPackage"
|
||||
KeyClassifyRule = "ClassifyRule"
|
||||
KeyGetZoneInfo = "GetZoneInfo"
|
||||
KeyQuerySysConfig = "QuerySysConfig"
|
||||
KeyFamilyUserCurrentEncode = "FamilyUserCurrentEncode"
|
||||
KeyQueryAllFiles = "QueryAllFiles"
|
||||
KeyGetSearchDirectory = "GetSearchDirectory"
|
||||
KeyGetDownloadUrlV2 = "GetDownloadUrlV2"
|
||||
KeyGetDownloadUrl = "GetDownloadUrl"
|
||||
KeyCreateDirectory = "CreateDirectory"
|
||||
KeyRenameFileOrDirectory = "RenameFileOrDirectory"
|
||||
KeyMoveFile = "MoveFile"
|
||||
KeyCopyFile = "CopyFile"
|
||||
KeyDeleteFile = "DeleteFile"
|
||||
KeyEmptyRecycleData = "EmptyRecycleData"
|
||||
KeyUpload2C = "upload2C"
|
||||
KeyPrivateSpaceLogin = "PrivateSpaceLogin"
|
||||
)
|
||||
|
||||
const (
|
||||
SortNameAsc = iota + 1
|
||||
SortNameDesc
|
||||
SortSizeAsc
|
||||
SortSizeDesc
|
||||
SortTimeAsc
|
||||
SortTimeDesc
|
||||
)
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package wopan
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
type Crypto struct {
|
||||
key []byte
|
||||
iv []byte
|
||||
accessKey []byte
|
||||
}
|
||||
|
||||
func NewCrypto() *Crypto {
|
||||
c := &Crypto{
|
||||
key: []byte(DefaultClientSecret),
|
||||
iv: []byte("wNSOYIB1k1DjY5lA"),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Crypto) SetAccessToken(token string) error {
|
||||
if len(token) < 16 {
|
||||
return ErrInvalidAccessToken
|
||||
}
|
||||
c.accessKey = []byte(token[:16])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Crypto) EncryptBytes(bs []byte, channel string) (string, error) {
|
||||
key := c.accessKey
|
||||
if channel == ChannelAPIUser {
|
||||
key = c.key
|
||||
}
|
||||
res, err := AesEncrypt(bs, key, c.iv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(res), nil
|
||||
}
|
||||
|
||||
func (c *Crypto) Encrypt(content string, channel string) (string, error) {
|
||||
return c.EncryptBytes([]byte(content), channel)
|
||||
}
|
||||
|
||||
func (c *Crypto) Decrypt(content string, channel string) (string, error) {
|
||||
bs, err := base64.StdEncoding.DecodeString(content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := c.accessKey
|
||||
if channel == ChannelAPIUser {
|
||||
key = c.key
|
||||
}
|
||||
res, err := AesDecrypt(bs, key, c.iv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(res), nil
|
||||
}
|
||||
|
||||
func (c *Crypto) UserEncrypt(content string) (string, error) {
|
||||
return c.Encrypt(content, ChannelAPIUser)
|
||||
}
|
||||
|
||||
func (c *Crypto) UserDecrypt(content string) (string, error) {
|
||||
return c.Decrypt(content, ChannelAPIUser)
|
||||
}
|
||||
|
||||
func (c *Crypto) WoHomeEncrypt(content string) (string, error) {
|
||||
return c.Encrypt(content, ChannelWoHome)
|
||||
}
|
||||
|
||||
func (c *Crypto) WoHomeDecrypt(content string) (string, error) {
|
||||
return c.Decrypt(content, ChannelWoHome)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
func calHeader(channel, key string) Header {
|
||||
resTime := time.Now().UnixMilli()
|
||||
reqSeq := rand.Int31n(8999) + 1e5
|
||||
version := ""
|
||||
m := md5.New()
|
||||
m.Write([]byte(fmt.Sprintf("%s%d%d%s%s", key, resTime, reqSeq, channel, version)))
|
||||
sign := hex.EncodeToString(m.Sum(nil))
|
||||
return Header{
|
||||
Key: key,
|
||||
ResTime: resTime,
|
||||
ReqSeq: int(reqSeq),
|
||||
Channel: channel,
|
||||
Sign: sign,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package wopan
|
||||
|
||||
func (w *WoClient) InitPhone() error {
|
||||
if w.Phone == "" {
|
||||
_resp, err := w.AppQueryUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Phone = _resp.UserId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WoClient) InitClassifyRule() error {
|
||||
if w.ClassifyRuleData != nil {
|
||||
return nil
|
||||
}
|
||||
data, err := w.ClassifyRule()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.ClassifyRuleData = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WoClient) InitZoneURL() error {
|
||||
if w.ZoneURL != "" {
|
||||
return nil
|
||||
}
|
||||
data, err := w.GetZoneInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.ZoneURL = data.Url
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WoClient) RefreshToken() error {
|
||||
resp, err := w.AppRefreshToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.onRefreshToken(resp.AccessToken, resp.RefreshToken)
|
||||
w.SetAccessToken(resp.AccessToken)
|
||||
w.SetRefreshToken(resp.RefreshToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WoClient) InitData() error {
|
||||
if w.accessToken == "" && w.refreshToken != "" {
|
||||
if err := w.RefreshToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := w.InitPhone(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.InitClassifyRule(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.InitZoneURL(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type Option func(w *WoClient)
|
||||
|
||||
func WithAccessToken(token string) Option {
|
||||
return func(w *WoClient) {
|
||||
w.SetAccessToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
func WithRefreshToken(token string) Option {
|
||||
return func(w *WoClient) {
|
||||
w.SetRefreshToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
func WithPsToken(psToken string) Option {
|
||||
return func(w *WoClient) {
|
||||
w.SetPsToken(psToken)
|
||||
}
|
||||
}
|
||||
|
||||
func WithUA(ua string) Option {
|
||||
return func(w *WoClient) {
|
||||
w.SetUA(ua)
|
||||
}
|
||||
}
|
||||
|
||||
func WithJsonMarshalFunc(f func(v interface{}) ([]byte, error)) Option {
|
||||
return func(w *WoClient) {
|
||||
w.SetJsonMarshalFunc(f)
|
||||
}
|
||||
}
|
||||
|
||||
func WithJsonUnmarshalFunc(f func(data []byte, v interface{}) error) Option {
|
||||
return func(w *WoClient) {
|
||||
w.SetJsonUnmarshalFunc(f)
|
||||
}
|
||||
}
|
||||
|
||||
func WithClient(hc *http.Client) Option {
|
||||
return func(c *WoClient) {
|
||||
c.SetHttpClient(hc)
|
||||
}
|
||||
}
|
||||
|
||||
func WithRestyClient(rc *resty.Client) Option {
|
||||
return func(c *WoClient) {
|
||||
c.client = rc
|
||||
}
|
||||
}
|
||||
|
||||
func WithDebug() Option {
|
||||
return func(c *WoClient) {
|
||||
c.SetDebug(true)
|
||||
}
|
||||
}
|
||||
|
||||
func WithTrace() Option {
|
||||
return func(c *WoClient) {
|
||||
c.EnableTrace()
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxy(proxy string) Option {
|
||||
return func(c *WoClient) {
|
||||
c.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
type RestyOption func(request *resty.Request)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package wopan
|
||||
|
||||
func (w *WoClient) EncryptParam(channel string, param Json) (string, error) {
|
||||
if param == nil {
|
||||
return "", nil
|
||||
}
|
||||
jsonBytes, err := w.jsonMarshalFunc(param)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encrypted, err := w.crypto.EncryptBytes(jsonBytes, channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func (w *WoClient) NewBody(channel string, param, other Json) (Json, error) {
|
||||
if param == nil {
|
||||
return other, nil
|
||||
}
|
||||
encrypted, err := w.EncryptParam(channel, param)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// copy other to avoid modifying the original and concurrent map writes
|
||||
_other := copyJson(other)
|
||||
_other["param"] = encrypted
|
||||
return _other, nil
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (w *WoClient) request(channel string, key string, param, other Json, resp interface{}, retry bool, opts ...RestyOption) ([]byte, error) {
|
||||
req := w.NewRequest()
|
||||
req.SetHeaders(map[string]string{
|
||||
"Origin": "https://pan.wo.cn",
|
||||
"Referer": "https://pan.wo.cn/",
|
||||
})
|
||||
if w.accessToken != "" {
|
||||
req.SetHeader("Accesstoken", w.accessToken)
|
||||
|
||||
}
|
||||
header := calHeader(channel, key)
|
||||
body, err := w.NewBody(channel, param, other)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var _resp Resp
|
||||
req.SetBody(Req[interface{}]{
|
||||
Header: header,
|
||||
Body: body,
|
||||
}).SetResult(&_resp)
|
||||
for _, opt := range opts {
|
||||
opt(req)
|
||||
}
|
||||
res, err := req.Post(fmt.Sprintf("%s/%s/dispatcher", DefaultBaseURL, channel))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.IsError() {
|
||||
return res.Body(), fmt.Errorf("request failed with status: %s", res.Status())
|
||||
}
|
||||
if _resp.Status != "200" {
|
||||
return res.Body(), fmt.Errorf("request failed with status: %s, msg: %s", _resp.Status, _resp.Msg)
|
||||
}
|
||||
if _resp.Rsp.RspCode != "0000" {
|
||||
if channel != ChannelAPIUser && retry && _resp.Rsp.RspCode == "9999" {
|
||||
err := w.RefreshToken()
|
||||
if err != nil {
|
||||
return res.Body(), err
|
||||
}
|
||||
return w.request(channel, key, param, other, resp, false, opts...)
|
||||
}
|
||||
return res.Body(), fmt.Errorf("request failed with rsp_code: %s,rep_desc: %s", _resp.Rsp.RspCode, _resp.Rsp.RspDesc)
|
||||
}
|
||||
if resp != nil {
|
||||
data := string(_resp.Rsp.Data)
|
||||
if strings.HasSuffix(data, "\"") && strings.HasPrefix(data, "\"") {
|
||||
data, err = w.crypto.Decrypt(data[1:len(data)-1], channel)
|
||||
if err != nil {
|
||||
return res.Body(), err
|
||||
}
|
||||
}
|
||||
err = w.jsonUnmarshalFunc([]byte(data), resp)
|
||||
if err != nil {
|
||||
return res.Body(), err
|
||||
}
|
||||
}
|
||||
return res.Body(), nil
|
||||
}
|
||||
|
||||
func (w *WoClient) Request(channel string, key string, param, other Json, resp interface{}, opts ...RestyOption) ([]byte, error) {
|
||||
return w.request(channel, key, param, other, resp, true, opts...)
|
||||
}
|
||||
|
||||
func (w *WoClient) RequestApiUser(key string, param, other Json, resp interface{}, opts ...RestyOption) ([]byte, error) {
|
||||
return w.Request(ChannelAPIUser, key, param, other, resp, opts...)
|
||||
}
|
||||
|
||||
func (w *WoClient) RequestWoHome(key string, param, other Json, resp interface{}, opts ...RestyOption) ([]byte, error) {
|
||||
return w.Request(ChannelWoHome, key, param, other, resp, opts...)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package wopan
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Json map[string]interface{}
|
||||
|
||||
func copyJson(src Json) Json {
|
||||
dst := make(Json, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
type Header struct {
|
||||
Key string `json:"key"`
|
||||
ResTime int64 `json:"resTime"`
|
||||
ReqSeq int `json:"reqSeq"`
|
||||
Channel string `json:"channel"`
|
||||
Sign string `json:"sign"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Req[T any] struct {
|
||||
Header `json:"header"`
|
||||
Body T `json:"body"`
|
||||
}
|
||||
|
||||
type Resp struct {
|
||||
Status string `json:"STATUS"`
|
||||
Msg string `json:"MSG"`
|
||||
LogID string `json:"LOGID"`
|
||||
Rsp struct {
|
||||
RspCode string `json:"RSP_CODE"`
|
||||
RspDesc string `json:"RSP_DESC"`
|
||||
Data json.RawMessage `json:"DATA"`
|
||||
} `json:"RSP"`
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Upload2COption struct {
|
||||
OnProgress func(current, total int64)
|
||||
Ctx context.Context
|
||||
RetryTimes int
|
||||
OnRetry func(err error, fileName string, partIndex int64, finishedSize int64)
|
||||
}
|
||||
|
||||
type Upload2CFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
Content *os.File
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type Upload2CResp struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Fid string `json:"fid"`
|
||||
} `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
func (w *WoClient) Upload2C(spaceType string, file Upload2CFile, targetDirId string, familyId string, opt Upload2COption) (string, error) {
|
||||
zoneURL := DefaultZoneURL
|
||||
w.zoneURLOnce.Do(func() {
|
||||
_ = w.InitZoneURL()
|
||||
})
|
||||
if w.ZoneURL != "" {
|
||||
zoneURL = w.ZoneURL
|
||||
}
|
||||
batchNo := time.Now().Format("20060102150405")
|
||||
fileInfo := Json{
|
||||
"spaceType": spaceType,
|
||||
"directoryId": targetDirId,
|
||||
"batchNo": batchNo,
|
||||
"fileName": file.Name,
|
||||
"fileSize": file.Size,
|
||||
"fileType": w.GetFileType(file.Name),
|
||||
}
|
||||
if spaceType == SpaceTypeFamily {
|
||||
fileInfo["familyId"] = familyId
|
||||
}
|
||||
if spaceType == SpaceTypePrivate {
|
||||
if w.psToken == "" {
|
||||
return "", ErrInvalidPsToken
|
||||
}
|
||||
fileInfo["psToken"] = w.psToken
|
||||
}
|
||||
fileInfoStr, err := w.EncryptParam(ChannelWoHome, fileInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
uploadURL := zoneURL + "/openapi/client/" + KeyUpload2C
|
||||
totalPart := file.Size / DefaultPartSize
|
||||
if totalPart == 0 {
|
||||
totalPart = 1
|
||||
}
|
||||
formData := map[string]string{
|
||||
"uniqueId": strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + randomChars(6),
|
||||
"accessToken": w.accessToken,
|
||||
"fileName": file.Name,
|
||||
"psToken": "undefined",
|
||||
"fileSize": strconv.FormatInt(file.Size, 10),
|
||||
"totalPart": strconv.FormatInt(totalPart, 10),
|
||||
"channel": ChannelWoCloud,
|
||||
"directoryId": targetDirId,
|
||||
"fileInfo": fileInfoStr,
|
||||
//partSize: 8388608
|
||||
//partIndex: 7
|
||||
//file: (binary)
|
||||
}
|
||||
var resp Upload2CResp
|
||||
var fid string
|
||||
var finishedSize int64 = 0
|
||||
for partIndex := int64(1); partIndex <= totalPart; partIndex++ {
|
||||
if opt.Ctx != nil {
|
||||
select {
|
||||
case <-opt.Ctx.Done():
|
||||
return "", opt.Ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
partSize := DefaultPartSize
|
||||
if partIndex == totalPart {
|
||||
partSize = file.Size - finishedSize
|
||||
}
|
||||
formData["partSize"] = strconv.FormatInt(partSize, 10)
|
||||
formData["partIndex"] = strconv.FormatInt(partIndex, 10)
|
||||
err := w.uploadPart(opt.Ctx, uploadURL, file, formData, partIndex, partSize, &resp)
|
||||
if err != nil {
|
||||
for i := 0; i < opt.RetryTimes; i++ {
|
||||
if opt.OnRetry != nil {
|
||||
opt.OnRetry(err, file.Name, partIndex, finishedSize)
|
||||
}
|
||||
_, serr := file.Content.Seek(finishedSize, 0)
|
||||
if serr != nil {
|
||||
return "", serr
|
||||
}
|
||||
err = w.uploadPart(opt.Ctx, uploadURL, file, formData, partIndex, partSize, &resp)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if resp.Data.Fid != "" {
|
||||
fid = resp.Data.Fid
|
||||
}
|
||||
finishedSize += partSize
|
||||
if opt.OnProgress != nil {
|
||||
opt.OnProgress(finishedSize, file.Size)
|
||||
}
|
||||
}
|
||||
return fid, nil
|
||||
}
|
||||
|
||||
func (w *WoClient) uploadPart(ctx context.Context, uploadURL string, file Upload2CFile, formData map[string]string, partIndex int64, partSize int64, resp *Upload2CResp) error {
|
||||
req := w.NewRequest().
|
||||
SetResult(resp).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetHeaders(map[string]string{
|
||||
"Origin": "https://pan.wo.cn",
|
||||
"Referer": "https://pan.wo.cn/",
|
||||
"User-Agent": w.ua,
|
||||
}).
|
||||
SetMultipartFormData(formData).
|
||||
SetMultipartField("file", file.Name, file.ContentType, io.LimitReader(file.Content, partSize))
|
||||
if ctx != nil {
|
||||
req.SetContext(ctx)
|
||||
}
|
||||
res, err := req.Post(uploadURL)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("partIndex: %d, failed to upload2C with http status: %d, body: %s", partIndex, res.StatusCode(), res.String())
|
||||
}
|
||||
if resp.Code != "0000" {
|
||||
return fmt.Errorf("partIndex: %d, failed to upload2C with code: %s, msg: %s", partIndex, resp.Code, resp.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WoClient) Upload2CPersonal(file Upload2CFile, targetDirId string, opt Upload2COption) (string, error) {
|
||||
return w.Upload2C(SpaceTypePersonal, file, targetDirId, "", opt)
|
||||
}
|
||||
|
||||
func (w *WoClient) Upload2CFamily(file Upload2CFile, targetDirId string, familyId string, opt Upload2COption) (string, error) {
|
||||
return w.Upload2C(SpaceTypeFamily, file, targetDirId, familyId, opt)
|
||||
}
|
||||
|
||||
func randomChars(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package wopan
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
JsonClientIDSecret = Json{
|
||||
"clientId": DefaultClientID,
|
||||
"secret": true,
|
||||
}
|
||||
JsonSecret = Json{
|
||||
"secret": true,
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAccessToken = errors.New("invalid access token")
|
||||
ErrInvalidPsToken = errors.New("invalid psToken")
|
||||
)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
**SPECIAL NOTICE - POLICY STATEMENT**
|
||||
|
||||
While this software is licensed under MIT License (see below), I, SheltonZhu,
|
||||
the copyright holder, explicitly oppose and refuse use of this software by:
|
||||
|
||||
- **AlistGo organization** (the official Alist maintainers)
|
||||
- **Any entity controlled by or controlling AlistGo**
|
||||
|
||||
**Reason:** Alist's commercialization plans include collecting user hardware data (CPU, GPU,
|
||||
memory, storage) without transparent consent. This practice violates user privacy and
|
||||
principles of open-source software. See: https://github.com/AlistGo/alist/issues/9142
|
||||
|
||||
I understand that under the MIT License below, I cannot legally prevent the use of this
|
||||
software. However, this notice serves as my explicit opposition to AlistGo's use of my work.
|
||||
|
||||
**I strongly advise against using 115driver in the official Alist maintained by AlistGo.**
|
||||
|
||||
**Note: This opposition does NOT apply to:**
|
||||
- Community-maintained forks (e.g., OpenList)
|
||||
- Other commercial users and organizations
|
||||
- Any entity that respects user privacy and open-source principles
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 SheltonZhu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
hashPreSize = 128 * 1024
|
||||
)
|
||||
|
||||
type DigestResult struct {
|
||||
Size int64
|
||||
PreID string
|
||||
QuickID string
|
||||
MD5 string
|
||||
}
|
||||
|
||||
func Digest(r io.Reader, result *DigestResult) (err error) {
|
||||
hs, hm := sha1.New(), md5.New()
|
||||
w := io.MultiWriter(hs, hm)
|
||||
// Calculate SHA1 hash of first 128K, which is used as PreID
|
||||
result.Size, err = io.CopyN(w, r, hashPreSize)
|
||||
if err != nil && err != io.EOF {
|
||||
return
|
||||
}
|
||||
result.PreID = strings.ToUpper(hex.EncodeToString(hs.Sum(nil)))
|
||||
// Write remain data.
|
||||
if err == nil {
|
||||
var n int64
|
||||
if n, err = io.Copy(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
result.Size += n
|
||||
result.QuickID = strings.ToUpper(hex.EncodeToString(hs.Sum(nil)))
|
||||
} else {
|
||||
result.QuickID = result.PreID
|
||||
}
|
||||
result.MD5 = base64.StdEncoding.EncodeToString(hm.Sum(nil))
|
||||
return nil
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package ec115
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"math/big"
|
||||
|
||||
"github.com/aead/ecdh"
|
||||
"github.com/andreburgaud/crypt2go/ecb"
|
||||
"github.com/andreburgaud/crypt2go/padding"
|
||||
"github.com/pierrec/lz4/v4"
|
||||
)
|
||||
|
||||
var remotePubKey = []byte{
|
||||
0x57, 0xA2, 0x92, 0x57, 0xCD, 0x23, 0x20, 0xE5,
|
||||
0xD6, 0xD1, 0x43, 0x32, 0x2F, 0xA4, 0xBB, 0x8A,
|
||||
0x3C, 0xF9, 0xD3, 0xCC, 0x62, 0x3E, 0xF5, 0xED,
|
||||
0xAC, 0x62, 0xB7, 0x67, 0x8A, 0x89, 0xC9, 0x1A,
|
||||
0x83, 0xBA, 0x80, 0x0D, 0x61, 0x29, 0xF5, 0x22,
|
||||
0xD0, 0x34, 0xC8, 0x95, 0xDD, 0x24, 0x65, 0x24,
|
||||
0x3A, 0xDD, 0xC2, 0x50, 0x95, 0x3B, 0xEE, 0xBA,
|
||||
}
|
||||
|
||||
const (
|
||||
p224BaseLen = 28
|
||||
crcSalt = "^j>WD3Kr?J2gLFjD4W2y@"
|
||||
)
|
||||
|
||||
// 利用key进行异或操作
|
||||
func xor(src, key []byte) []byte {
|
||||
secret := make([]byte, 0, len(src))
|
||||
pad := len(src) % 4
|
||||
if pad > 0 {
|
||||
for i := 0; i < pad; i++ {
|
||||
secret = append(secret, src[i]^key[i])
|
||||
}
|
||||
src = src[pad:]
|
||||
}
|
||||
keyLen := len(key)
|
||||
num := 0
|
||||
for _, s := range src {
|
||||
if num >= keyLen {
|
||||
num = num % keyLen
|
||||
}
|
||||
secret = append(secret, s^key[num])
|
||||
num++
|
||||
}
|
||||
|
||||
return secret
|
||||
}
|
||||
|
||||
// EcdhCipher ECDH加密解密信息
|
||||
type EcdhCipher struct {
|
||||
key []byte
|
||||
iv []byte
|
||||
pubKey []byte
|
||||
}
|
||||
|
||||
// NewEcdhCipher 新建EcdhCipher
|
||||
func NewEcdhCipher() (*EcdhCipher, error) {
|
||||
x := big.NewInt(0).SetBytes(remotePubKey[:p224BaseLen])
|
||||
y := big.NewInt(0).SetBytes(remotePubKey[p224BaseLen:])
|
||||
remotePublic := ecdh.Point{X: x, Y: y}
|
||||
|
||||
p224 := ecdh.Generic(elliptic.P224())
|
||||
private, public, err := p224.GenerateKey(rand.Reader)
|
||||
|
||||
buf := make([]byte, p224BaseLen)
|
||||
switch p := public.(type) {
|
||||
case ecdh.Point:
|
||||
p.X.FillBytes(buf)
|
||||
if big.NewInt(0).And(p.Y, big.NewInt(1)).Cmp(big.NewInt(1)) == 0 {
|
||||
buf = append([]byte{p224BaseLen + 1, 0x03}, buf...)
|
||||
} else {
|
||||
buf = append([]byte{p224BaseLen + 1, 0x02}, buf...)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("错误的public key类型")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret := p224.ComputeSecret(private, remotePublic)
|
||||
|
||||
cipher := new(EcdhCipher)
|
||||
cipher.key = secret[:aes.BlockSize]
|
||||
cipher.iv = secret[len(secret)-aes.BlockSize:]
|
||||
cipher.pubKey = buf
|
||||
return cipher, nil
|
||||
}
|
||||
|
||||
// Encrypt 加密
|
||||
func (c *EcdhCipher) Encrypt(plainText []byte) ([]byte, error) {
|
||||
pad := padding.NewPkcs7Padding(aes.BlockSize)
|
||||
data, err := pad.Pad(plainText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cipherText := make([]byte, 0, len(data))
|
||||
var xorKey []byte
|
||||
xorKey = append(xorKey, c.iv...)
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mode := ecb.NewECBEncrypter(block)
|
||||
tmp := make([]byte, 0, aes.BlockSize)
|
||||
|
||||
for i, b := range data {
|
||||
tmp = append(tmp, b^xorKey[i%aes.BlockSize])
|
||||
if i%aes.BlockSize == aes.BlockSize-1 {
|
||||
mode.CryptBlocks(xorKey, tmp)
|
||||
cipherText = append(cipherText, xorKey...)
|
||||
tmp = make([]byte, 0, aes.BlockSize)
|
||||
}
|
||||
}
|
||||
|
||||
return cipherText, nil
|
||||
}
|
||||
|
||||
// Decrypt 解密
|
||||
func (c *EcdhCipher) Decrypt(cipherText []byte) (text []byte, e error) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
e = fmt.Errorf("%v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
cipherText = cipherText[0 : len(cipherText)-len(cipherText)%aes.BlockSize]
|
||||
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lz4Block := make([]byte, len(cipherText))
|
||||
mode := cipher.NewCBCDecrypter(block, c.iv)
|
||||
mode.CryptBlocks(lz4Block, cipherText)
|
||||
|
||||
length := int(lz4Block[0]) + int(lz4Block[1])<<8
|
||||
text = make([]byte, 0x2000)
|
||||
l, err := lz4.UncompressBlock(lz4Block[2:length+2], text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return text[:l], nil
|
||||
}
|
||||
|
||||
// EncodeToken 加密token
|
||||
func (c *EcdhCipher) EncodeToken(timestamp int64) (string, error) {
|
||||
random, err := rand.Int(rand.Reader, big.NewInt(256))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r1 := byte(random.Uint64())
|
||||
random, err = rand.Int(rand.Reader, big.NewInt(256))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r2 := byte(random.Uint64())
|
||||
tmp := make([]byte, 0, 48)
|
||||
|
||||
time := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(time, uint32(timestamp))
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
tmp = append(tmp, c.pubKey[i]^r1)
|
||||
}
|
||||
tmp = append(tmp, []byte{r1, 0x73 ^ r1}...)
|
||||
for i := 0; i < 3; i++ {
|
||||
tmp = append(tmp, r1)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
tmp = append(tmp, r1^time[3-i])
|
||||
}
|
||||
for i := 15; i < len(c.pubKey); i++ {
|
||||
tmp = append(tmp, c.pubKey[i]^r2)
|
||||
}
|
||||
tmp = append(tmp, []byte{r2, 0x01 ^ r2}...)
|
||||
for i := 0; i < 3; i++ {
|
||||
tmp = append(tmp, r2)
|
||||
}
|
||||
|
||||
crc := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(crc, crc32.ChecksumIEEE(append([]byte(crcSalt), tmp...)))
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
tmp = append(tmp, crc[3-i])
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(tmp), nil
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package m115
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Key [16]byte
|
||||
|
||||
func GenerateKey() Key {
|
||||
key := Key{}
|
||||
_, _ = io.ReadFull(rand.Reader, key[:])
|
||||
return key
|
||||
}
|
||||
|
||||
func Encode(input []byte, key Key) (output string) {
|
||||
// Prepare buffer
|
||||
buf := make([]byte, 16+len(input))
|
||||
// Copy key and data to buffer
|
||||
copy(buf, key[:])
|
||||
copy(buf[16:], input)
|
||||
// XOR encode
|
||||
xorTransform(buf[16:], xorDeriveKey(key[:], 4))
|
||||
reverseBytes(buf[16:])
|
||||
xorTransform(buf[16:], xorClientKey)
|
||||
// Encrypt and encode
|
||||
output = base64.StdEncoding.EncodeToString(rsaEncrypt(buf))
|
||||
return
|
||||
}
|
||||
|
||||
func Decode(input string, key Key) (output []byte, err error) {
|
||||
// Base64 decode
|
||||
data, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// RSA decrypt
|
||||
data = rsaDecrypt(data)
|
||||
// XOR decode
|
||||
output = make([]byte, len(data)-16)
|
||||
copy(output, data[16:])
|
||||
xorTransform(output, xorDeriveKey(data[:16], 12))
|
||||
reverseBytes(output)
|
||||
xorTransform(output, xorDeriveKey(key[:], 4))
|
||||
return
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package m115
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
var (
|
||||
_N, _ = big.NewInt(0).SetString(
|
||||
"8686980c0f5a24c4b9d43020cd2c22703ff3f450756529058b1cf88f09b86021"+
|
||||
"36477198a6e2683149659bd122c33592fdb5ad47944ad1ea4d36c6b172aad633"+
|
||||
"8c3bb6ac6227502d010993ac967d1aef00f0c8e038de2e4d3bc2ec368af2e9f1"+
|
||||
"0a6f1eda4f7262f136420c07c331b871bf139f74f3010e3c4fe57df3afb71683", 16)
|
||||
_E, _ = big.NewInt(0).SetString("10001", 16)
|
||||
|
||||
_KeyLength = _N.BitLen() / 8
|
||||
)
|
||||
|
||||
func rsaEncrypt(input []byte) []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
for remainSize := len(input); remainSize > 0; {
|
||||
sliceSize := _KeyLength - 11
|
||||
if sliceSize > remainSize {
|
||||
sliceSize = remainSize
|
||||
}
|
||||
rsaEncryptSlice(input[:sliceSize], buf)
|
||||
|
||||
input = input[sliceSize:]
|
||||
remainSize -= sliceSize
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func rsaEncryptSlice(input []byte, w io.Writer) {
|
||||
// Padding
|
||||
padSize := _KeyLength - len(input) - 3
|
||||
padData := make([]byte, padSize)
|
||||
_, _ = rand.Read(padData)
|
||||
// Prepare message
|
||||
buf := make([]byte, _KeyLength)
|
||||
buf[0], buf[1] = 0, 2
|
||||
for i, b := range padData {
|
||||
buf[2+i] = b%0xff + 0x01
|
||||
}
|
||||
buf[padSize+2] = 0
|
||||
copy(buf[padSize+3:], input)
|
||||
msg := big.NewInt(0).SetBytes(buf)
|
||||
// RSA Encrypt
|
||||
ret := big.NewInt(0).Exp(msg, _E, _N).Bytes()
|
||||
// Fill zeros at beginning
|
||||
if fillSize := _KeyLength - len(ret); fillSize > 0 {
|
||||
zeros := make([]byte, fillSize)
|
||||
_, _ = w.Write(zeros)
|
||||
}
|
||||
_, _ = w.Write(ret)
|
||||
}
|
||||
|
||||
func rsaDecrypt(input []byte) []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
for remainSize := len(input); remainSize > 0; {
|
||||
sliceSize := _KeyLength
|
||||
if sliceSize > remainSize {
|
||||
sliceSize = remainSize
|
||||
}
|
||||
rsaDecryptSlice(input[:sliceSize], buf)
|
||||
|
||||
input = input[sliceSize:]
|
||||
remainSize -= sliceSize
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func rsaDecryptSlice(input []byte, w io.Writer) {
|
||||
// RSA Decrypt
|
||||
msg := big.NewInt(0).SetBytes(input)
|
||||
ret := big.NewInt(0).Exp(msg, _E, _N).Bytes()
|
||||
// Un-padding
|
||||
for i, b := range ret {
|
||||
// Find the beginning of plaintext
|
||||
if b == 0 && i != 0 {
|
||||
_, _ = w.Write(ret[i+1:])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package m115
|
||||
|
||||
// reverseBytes reverses data in place.
|
||||
func reverseBytes(data []byte) {
|
||||
for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 {
|
||||
data[i], data[j] = data[j], data[i]
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package m115
|
||||
|
||||
var (
|
||||
// Pre-calculated key data
|
||||
xorKeySeed = []byte{
|
||||
0xf0, 0xe5, 0x69, 0xae, 0xbf, 0xdc, 0xbf, 0x8a,
|
||||
0x1a, 0x45, 0xe8, 0xbe, 0x7d, 0xa6, 0x73, 0xb8,
|
||||
0xde, 0x8f, 0xe7, 0xc4, 0x45, 0xda, 0x86, 0xc4,
|
||||
0x9b, 0x64, 0x8b, 0x14, 0x6a, 0xb4, 0xf1, 0xaa,
|
||||
0x38, 0x01, 0x35, 0x9e, 0x26, 0x69, 0x2c, 0x86,
|
||||
0x00, 0x6b, 0x4f, 0xa5, 0x36, 0x34, 0x62, 0xa6,
|
||||
0x2a, 0x96, 0x68, 0x18, 0xf2, 0x4a, 0xfd, 0xbd,
|
||||
0x6b, 0x97, 0x8f, 0x4d, 0x8f, 0x89, 0x13, 0xb7,
|
||||
0x6c, 0x8e, 0x93, 0xed, 0x0e, 0x0d, 0x48, 0x3e,
|
||||
0xd7, 0x2f, 0x88, 0xd8, 0xfe, 0xfe, 0x7e, 0x86,
|
||||
0x50, 0x95, 0x4f, 0xd1, 0xeb, 0x83, 0x26, 0x34,
|
||||
0xdb, 0x66, 0x7b, 0x9c, 0x7e, 0x9d, 0x7a, 0x81,
|
||||
0x32, 0xea, 0xb6, 0x33, 0xde, 0x3a, 0xa9, 0x59,
|
||||
0x34, 0x66, 0x3b, 0xaa, 0xba, 0x81, 0x60, 0x48,
|
||||
0xb9, 0xd5, 0x81, 0x9c, 0xf8, 0x6c, 0x84, 0x77,
|
||||
0xff, 0x54, 0x78, 0x26, 0x5f, 0xbe, 0xe8, 0x1e,
|
||||
0x36, 0x9f, 0x34, 0x80, 0x5c, 0x45, 0x2c, 0x9b,
|
||||
0x76, 0xd5, 0x1b, 0x8f, 0xcc, 0xc3, 0xb8, 0xf5,
|
||||
}
|
||||
|
||||
xorClientKey = []byte{
|
||||
0x78, 0x06, 0xad, 0x4c, 0x33, 0x86, 0x5d, 0x18,
|
||||
0x4c, 0x01, 0x3f, 0x46,
|
||||
}
|
||||
)
|
||||
|
||||
func xorDeriveKey(seed []byte, size int) []byte {
|
||||
key := make([]byte, size)
|
||||
for i := 0; i < size; i++ {
|
||||
key[i] = (seed[i] + xorKeySeed[size*i]) & 0xff
|
||||
key[i] ^= xorKeySeed[size*(size-i-1)]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func xorTransform(data []byte, key []byte) {
|
||||
dataSize, keySize := len(data), len(key)
|
||||
mod := dataSize % 4
|
||||
if mod > 0 {
|
||||
for i := 0; i < mod; i++ {
|
||||
data[i] ^= key[i%keySize]
|
||||
}
|
||||
}
|
||||
for i := mod; i < dataSize; i++ {
|
||||
data[i] ^= key[(i-mod)%keySize]
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package driver
|
||||
|
||||
const (
|
||||
ApiGetVersion = "https://appversion.115.com/1/web/1.0/api/chrome"
|
||||
|
||||
// login
|
||||
ApiLoginCheck = "https://passportapi.115.com/app/1.0/web/1.0/check/sso"
|
||||
ApiUserInfo = "https://my.115.com/?ct=ajax&ac=nav"
|
||||
ApiStatusCheck = "https://my.115.com/?ct=guide&ac=status"
|
||||
// dir
|
||||
ApiDirAdd = "https://webapi.115.com/files/add"
|
||||
ApiDirName2CID = "https://webapi.115.com/files/getid"
|
||||
|
||||
// file
|
||||
ApiFileDelete = "https://webapi.115.com/rb/delete"
|
||||
ApiFileMove = "https://webapi.115.com/files/move"
|
||||
ApiFileCopy = "https://webapi.115.com/files/copy"
|
||||
ApiFileRename = "https://webapi.115.com/files/batch_rename"
|
||||
ApiFileIndexInfo = "https://webapi.115.com/files/index_info"
|
||||
|
||||
ApiFileList = "https://webapi.115.com/files"
|
||||
ApiFileList1 = "http://web.api.115.com/files"
|
||||
// ApiFileList2 = "http://anxia.com/webapi/files"
|
||||
// ApiFileList3 = "http://v.anxia.com/webapi/files"
|
||||
ApiFileListByName = "https://aps.115.com/natsort/files.php"
|
||||
|
||||
ApiFileStat = "https://webapi.115.com/category/get"
|
||||
ApiFileInfo = "https://webapi.115.com/files/get_info"
|
||||
ApiFileSearch = "https://webapi.115.com/files/search"
|
||||
|
||||
// share
|
||||
ApiShareSnap = "https://115cdn.com/webapi/share/snap"
|
||||
|
||||
// download
|
||||
ApiDownloadGetUrl = "https://proapi.115.com/app/chrome/downurl"
|
||||
ApiDownloadGetShareUrl = "https://115cdn.com/webapi/share/downurl"
|
||||
AndroidApiDownloadGetUrl = "https://proapi.115.com/android/2.0/ufile/download"
|
||||
|
||||
// offline download
|
||||
ApiAddOfflineUrl = "https://lixian.115.com/lixianssp/?ac=add_task_urls"
|
||||
ApiDelOfflineUrl = "https://lixian.115.com/lixian/?ct=lixian&ac=task_del"
|
||||
ApiListOfflineUrl = "https://lixian.115.com/lixian/?ct=lixian&ac=task_lists"
|
||||
ApiClearOfflineUrl = "https://lixian.115.com/lixian/?ct=lixian&ac=task_clear"
|
||||
|
||||
// upload
|
||||
ApiUploadInfo = "https://proapi.115.com/app/uploadinfo"
|
||||
ApiGetUploadEndpoint = "https://uplb.115.com/3.0/getuploadinfo.php"
|
||||
ApiUploadInit = "https://uplb.115.com/4.0/initupload.php"
|
||||
|
||||
// oss
|
||||
ApiUploadOSSToken = "https://uplb.115.com/3.0/gettoken.php"
|
||||
|
||||
// qrcode
|
||||
ApiQrcodeToken = "https://qrcodeapi.115.com/api/1.0/web/1.0/token"
|
||||
ApiQrcodeStatus = "https://qrcodeapi.115.com/get/status/"
|
||||
ApiQrcodeLogin = "https://passportapi.115.com/app/1.0/web/1.0/login/qrcode"
|
||||
ApiQrcodeLoginWithApp = "https://passportapi.115.com/app/1.0/%s/1.0/login/qrcode"
|
||||
ApiQrcodeImage = "https://qrcodeapi.115.com/api/1.0/mac/1.0/qrcode?uid=%s"
|
||||
|
||||
// recycle
|
||||
ApiRecycleList = "https://webapi.115.com/rb"
|
||||
ApiRecycleClean = "https://webapi.115.com/rb/clean"
|
||||
ApiRecycleRevert = "https://webapi.115.com/rb/revert"
|
||||
)
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package driver
|
||||
|
||||
// GetAppVersion get app version (win, android, mac, mac_arc, etc...)
|
||||
func (c *Pan115Client) GetAppVersion() ([]AppVersion, error) {
|
||||
result := VersionResp{}
|
||||
req := c.NewRequest().
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Get(ApiGetVersion)
|
||||
|
||||
err = CheckErr(err, &result, resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result.Data.GetAppVersions(), nil
|
||||
}
|
||||
|
||||
type VersionResp struct {
|
||||
BasicResp
|
||||
ErrCode int `json:"err_code,omitempty"`
|
||||
Data Versions `json:"data"`
|
||||
}
|
||||
|
||||
type Versions map[string]map[string]any
|
||||
|
||||
func (v Versions) GetAppVersions() []AppVersion {
|
||||
vers := make([]AppVersion, len(v))
|
||||
for app, ver := range v {
|
||||
vers = append(vers, AppVersion{
|
||||
AppName: app,
|
||||
Version: ver["version_code"].(string),
|
||||
})
|
||||
}
|
||||
return vers
|
||||
}
|
||||
|
||||
func (resp *VersionResp) Err(respBody ...string) error {
|
||||
if resp.State {
|
||||
return nil
|
||||
}
|
||||
if len(respBody) > 0 {
|
||||
return GetErr(resp.ErrCode, respBody[0])
|
||||
}
|
||||
return GetErr(resp.ErrCode)
|
||||
}
|
||||
|
||||
type AppVersion struct {
|
||||
AppName string
|
||||
Version string
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Pan115Client driver client
|
||||
type Pan115Client struct {
|
||||
Client *resty.Client
|
||||
Request *resty.Request
|
||||
UserID int64
|
||||
Userkey string
|
||||
UploadMetaInfo *UploadMetaInfo
|
||||
UseInternalUpload bool
|
||||
}
|
||||
|
||||
// New creates Client with customized options.
|
||||
func New(opts ...Option) *Pan115Client {
|
||||
c := &Pan115Client{
|
||||
Client: resty.New(),
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
for _, optFunc := range opts {
|
||||
optFunc(c)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Default creates an Client with default settings.
|
||||
func Default() *Pan115Client {
|
||||
return New(UA())
|
||||
}
|
||||
|
||||
// Defalut is deprecated: use Default instead. This function exists for backward compatibility.
|
||||
func Defalut() *Pan115Client {
|
||||
return Default()
|
||||
}
|
||||
|
||||
func (c *Pan115Client) SetHttpClient(httpClient *http.Client) *Pan115Client {
|
||||
c.Client = resty.NewWithClient(httpClient)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Pan115Client) SetUserAgent(userAgent string) *Pan115Client {
|
||||
c.Client.SetHeader("User-Agent", userAgent)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Pan115Client) SetCookies(cs ...*http.Cookie) *Pan115Client {
|
||||
c.Client.SetCookies(cs)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Pan115Client) SetDebug(d bool) *Pan115Client {
|
||||
c.Client.SetDebug(d)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Pan115Client) EnableTrace() *Pan115Client {
|
||||
c.Client.EnableTrace()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Pan115Client) SetProxy(proxy string) *Pan115Client {
|
||||
c.Client.SetProxy(proxy)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Pan115Client) NewRequest() *resty.Request {
|
||||
c.Request = c.Client.R()
|
||||
return c.Request
|
||||
}
|
||||
|
||||
func (c *Pan115Client) GetRequest() *resty.Request {
|
||||
if c.Request != nil {
|
||||
return c.Request
|
||||
}
|
||||
return c.NewRequest()
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package driver
|
||||
|
||||
const (
|
||||
UADefault = "Mozilla/5.0"
|
||||
UADefalut = "Mozilla/5.0" // UADefalut is deprecated: use UADefault instead. This constant exists for backward compatibility
|
||||
UA115Browser = "Mozilla/5.0 115Browser/27.0.5.7"
|
||||
UA115Disk = "Mozilla/5.0 115disk/30.1.0"
|
||||
UA115Desktop = "Mozilla/5.0 115Desktop/2.0.3.6"
|
||||
UAIosApp = "Mozilla/5.0; Darwin/10.0; UDown/30.1.0"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieDomain115 = ".115.com"
|
||||
|
||||
CookieUrl = "https://115.com"
|
||||
|
||||
CookieNameUid = "UID"
|
||||
CookieNameCid = "CID"
|
||||
CookieNameSeid = "SEID"
|
||||
CookieNameKid = "KID"
|
||||
)
|
||||
|
||||
const (
|
||||
OSSRegionID = "oss-cn-shenzhen"
|
||||
OSSEndpoint = "cn-shenzhen.oss.aliyuncs.com" // 双栈域名
|
||||
|
||||
OSSUserAgent = "aliyun-sdk-android/2.9.1"
|
||||
OssSecurityTokenHeaderName = "X-OSS-Security-Token"
|
||||
)
|
||||
|
||||
const (
|
||||
KB = 1 << (10 * (iota + 1))
|
||||
MB
|
||||
GB
|
||||
)
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Mkdir make a new directory which name and parent directory id, return directory id
|
||||
func (c *Pan115Client) Mkdir(parentID string, name string) (string, error) {
|
||||
result := MkdirResp{}
|
||||
form := map[string]string{
|
||||
"pid": parentID,
|
||||
"cname": name,
|
||||
}
|
||||
req := c.NewRequest().
|
||||
SetFormData(form).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Post(ApiDirAdd)
|
||||
|
||||
err = CheckErr(err, &result, resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(result.CategoryID), nil
|
||||
}
|
||||
|
||||
// List list all files and directories
|
||||
func (c *Pan115Client) List(dirID string, opts ...ListOption) (*[]File, error) {
|
||||
return c.ListWithLimit(dirID, FileListLimit, opts...)
|
||||
}
|
||||
|
||||
const MaxDirPageLimit = 1150
|
||||
|
||||
// ListWithLimit list all files and directories with limit
|
||||
func (c *Pan115Client) ListWithLimit(dirID string, limit int64, opts ...ListOption) (*[]File, error) {
|
||||
if isCalledByAlistV3() {
|
||||
return nil, ErrorNotSupportAlist
|
||||
}
|
||||
if limit > MaxDirPageLimit {
|
||||
limit = MaxDirPageLimit
|
||||
}
|
||||
|
||||
o := DefaultListOptions()
|
||||
if len(opts) > 0 {
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
}
|
||||
|
||||
apiURLs := o.ApiURLs
|
||||
var files []File
|
||||
offset := int64(0)
|
||||
for i := 0; ; i++ {
|
||||
apiURL := apiURLs[i%len(apiURLs)]
|
||||
req := c.NewRequest().ForceContentType("application/json;charset=UTF-8")
|
||||
getFilesOpts := []GetFileOptions{
|
||||
WithApiURL(apiURL),
|
||||
WithLimit(limit),
|
||||
WithOffset(offset),
|
||||
}
|
||||
result, err := GetFiles(req, dirID, getFilesOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fileInfo := range result.Files {
|
||||
files = append(files, *(&File{}).from(&fileInfo))
|
||||
}
|
||||
offset = int64(result.Offset) + limit
|
||||
if offset >= int64(result.Count) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return &files, nil
|
||||
}
|
||||
|
||||
// ListPage list files and directories with page
|
||||
func (c *Pan115Client) ListPage(dirID string, offset, limit int64, opts ...ListOption) (*[]File, error) {
|
||||
o := DefaultListOptions()
|
||||
if len(opts) > 0 {
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
}
|
||||
|
||||
apiURLs := o.ApiURLs
|
||||
var files []File
|
||||
req := c.NewRequest().ForceContentType("application/json;charset=UTF-8")
|
||||
getFilesOpts := []GetFileOptions{
|
||||
WithApiURL(apiURLs[0]),
|
||||
WithLimit(limit),
|
||||
WithOffset(offset),
|
||||
}
|
||||
result, err := GetFiles(req, dirID, getFilesOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(result.Count) <= offset {
|
||||
return &files, nil
|
||||
}
|
||||
for _, fileInfo := range result.Files {
|
||||
files = append(files, *(&File{}).from(&fileInfo))
|
||||
}
|
||||
return &files, nil
|
||||
}
|
||||
|
||||
func GetFiles(req *resty.Request, dirID string, opts ...GetFileOptions) (*FileListResp, error) {
|
||||
if dirID == "" {
|
||||
dirID = "0"
|
||||
}
|
||||
o := DefaultGetFileOptions()
|
||||
if len(opts) > 0 {
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
}
|
||||
result := FileListResp{}
|
||||
params := map[string]string{
|
||||
"aid": "1",
|
||||
"cid": dirID,
|
||||
"o": o.GetOrder(),
|
||||
"asc": o.GetAsc(),
|
||||
"offset": o.GetOffset(),
|
||||
"show_dir": o.GetshowDir(),
|
||||
"limit": o.GetPageSize(),
|
||||
"snap": "0",
|
||||
"natsort": "0",
|
||||
"record_open_time": "1",
|
||||
"format": "json",
|
||||
"fc_mix": "0",
|
||||
}
|
||||
req = req.SetQueryParams(params).
|
||||
SetResult(&result)
|
||||
resp, err := req.Get(o.GetApiURL())
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return &FileListResp{}, err
|
||||
}
|
||||
if dirID != string(result.CategoryID) {
|
||||
return &FileListResp{}, err
|
||||
}
|
||||
return &result, err
|
||||
}
|
||||
|
||||
func (c *Pan115Client) DirName2CID(dir string) (*APIGetDirIDResp, error) {
|
||||
result := APIGetDirIDResp{}
|
||||
dir = strings.TrimPrefix(dir, "/")
|
||||
req := c.NewRequest().ForceContentType("application/json;charset=UTF-8")
|
||||
req.SetQueryParam("path", dir).SetResult(&result)
|
||||
resp, err := req.Get(ApiDirName2CID)
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, err
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
crypto "github.com/SheltonZhu/115driver/pkg/crypto/m115"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type FileDownloadUrl struct {
|
||||
Client float64 `json:"client"`
|
||||
OSSID string `json:"oss_id"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type DownloadInfo struct {
|
||||
FileName string `json:"file_name"`
|
||||
FileSize StringInt64 `json:"file_size"`
|
||||
PickCode string `json:"pick_code"`
|
||||
Url FileDownloadUrl `json:"url"`
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// Get Download file from download info url
|
||||
func (info *DownloadInfo) Get() (io.ReadSeeker, error) {
|
||||
req := resty.New().R().SetHeaderMultiValues(info.Header)
|
||||
resp, err := req.Get(info.Url.Url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(resp.Body()), nil
|
||||
}
|
||||
|
||||
type DownloadData map[string]*DownloadInfo
|
||||
|
||||
// DownloadWithUA get download info with pickcode and user agent
|
||||
func (c *Pan115Client) DownloadWithUA(pickCode, ua string) (*DownloadInfo, error) {
|
||||
key := crypto.GenerateKey()
|
||||
|
||||
result := DownloadResp{}
|
||||
params, err := json.Marshal(map[string]string{"pickcode": pickCode})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := crypto.Encode(params, key)
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("t", Now().String()).
|
||||
SetFormData(map[string]string{"data": data}).
|
||||
ForceContentType("application/json").
|
||||
SetResult(&result)
|
||||
if len(ua) > 0 {
|
||||
req = req.SetHeader("User-Agent", ua)
|
||||
}
|
||||
resp, err := req.Post(ApiDownloadGetUrl)
|
||||
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := crypto.Decode(string(result.EncodedData), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
downloadInfo := DownloadData{}
|
||||
if err := json.Unmarshal(bytes, &downloadInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, info := range downloadInfo {
|
||||
if info.FileSize < 0 {
|
||||
return nil, ErrDownloadEmpty
|
||||
}
|
||||
info.Header = buildDownloadHeaders(resp.Request.Header, resp.Cookies())
|
||||
return info, nil
|
||||
}
|
||||
return nil, ErrUnexpected
|
||||
}
|
||||
|
||||
// DownloadWithUAByAndroidAPI get download info with pickcode and user agent
|
||||
func (c *Pan115Client) DownloadWithUAByAndroidAPI(pickCode string, ua string) (*DownloadInfo, error) {
|
||||
key := crypto.GenerateKey()
|
||||
|
||||
result := DownloadResp{}
|
||||
params, err := json.Marshal(map[string]string{"pick_code": pickCode})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := crypto.Encode(params, key)
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("t", Now().String()).
|
||||
SetFormData(map[string]string{"data": data}).
|
||||
ForceContentType("application/json").
|
||||
SetResult(&result)
|
||||
if len(ua) > 0 {
|
||||
req = req.SetHeader("User-Agent", ua)
|
||||
}
|
||||
resp, err := req.Post(AndroidApiDownloadGetUrl)
|
||||
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bytes, err := crypto.Decode(string(result.EncodedData), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
infoResp := struct {
|
||||
URL string `json:"url"`
|
||||
}{}
|
||||
if err := json.Unmarshal(bytes, &infoResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := DownloadInfo{
|
||||
Url: FileDownloadUrl{
|
||||
Url: infoResp.URL,
|
||||
},
|
||||
PickCode: pickCode,
|
||||
Header: buildDownloadHeaders(resp.Request.Header, resp.Cookies()),
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// Download get download info with pickcode
|
||||
func (c *Pan115Client) Download(pickCode string) (*DownloadInfo, error) {
|
||||
return c.DownloadWithUA(pickCode, "")
|
||||
}
|
||||
|
||||
func buildDownloadHeaders(requestHeaders http.Header, responseCookies []*http.Cookie) http.Header {
|
||||
headers := requestHeaders.Clone()
|
||||
if len(responseCookies) == 0 {
|
||||
return headers
|
||||
}
|
||||
|
||||
cookies := make([]string, 0, len(responseCookies)+1)
|
||||
if existing := strings.TrimSpace(headers.Get("Cookie")); existing != "" {
|
||||
cookies = append(cookies, existing)
|
||||
}
|
||||
for _, cookie := range responseCookies {
|
||||
if cookie == nil {
|
||||
continue
|
||||
}
|
||||
cookies = append(cookies, cookie.String())
|
||||
}
|
||||
if len(cookies) > 0 {
|
||||
headers.Set("Cookie", strings.Join(cookies, "; "))
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
type SharedDownloadInfo struct {
|
||||
FileID string `json:"fid"`
|
||||
FileName string `json:"fn"`
|
||||
FileSize StringInt64 `json:"fs"`
|
||||
URL struct {
|
||||
URL string `json:"url"`
|
||||
Client int `json:"client"`
|
||||
Desc any `json:"desc"`
|
||||
Isp any `json:"isp"`
|
||||
OSSID string `json:"oss_id"`
|
||||
OOID string `json:"ooid"`
|
||||
} `json:"url"`
|
||||
}
|
||||
|
||||
// DownloadByShareCode get download info with share code
|
||||
func (c *Pan115Client) DownloadByShareCode(shareCode, receiveCode, fileID string) (*SharedDownloadInfo, error) {
|
||||
return c.DownloadByShareCodeWithUA("", shareCode, receiveCode, fileID)
|
||||
}
|
||||
|
||||
func (c *Pan115Client) DownloadByShareCodeWithUA(ua, shareCode, receiveCode, fileID string) (*SharedDownloadInfo, error) {
|
||||
if isCalledByAlistV3() {
|
||||
return nil, ErrorNotSupportAlist
|
||||
}
|
||||
result := DownloadShareResp{}
|
||||
params := map[string]string{
|
||||
"share_code": shareCode,
|
||||
"receive_code": receiveCode,
|
||||
"file_id": fileID,
|
||||
"dl": "1",
|
||||
}
|
||||
|
||||
req := c.NewRequest().
|
||||
SetQueryParams(params).
|
||||
ForceContentType("application/json").
|
||||
SetHeader("referer", BuildShareReferer(shareCode, receiveCode)).
|
||||
SetResult(&result)
|
||||
|
||||
if len(ua) > 0 {
|
||||
req = req.SetHeader("User-Agent", ua)
|
||||
}
|
||||
resp, err := req.Get(ApiDownloadGetShareUrl)
|
||||
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
downloadInfo := result.Data
|
||||
return &downloadInfo, nil
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrorNotSupportAlist = errors.New("not support alist due to privacy risk, please use openlist: https://github.com/OpenListTeam/OpenList")
|
||||
)
|
||||
// cookie err
|
||||
var (
|
||||
ErrBadCookie = errors.New("bad cookie")
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotLogin = errors.New("user not login")
|
||||
|
||||
ErrOfflineNoTimes = errors.New("offline download quota has been used up, you can purchase a VIP experience or upgrade to VIP service to get more quota")
|
||||
ErrOfflineInvalidLink = errors.New("invalid download link")
|
||||
ErrOfflineTaskExisted = errors.New("offline task existed")
|
||||
|
||||
ErrOrderNotSupport = errors.New("file order not supported")
|
||||
|
||||
ErrPasswordIncorrect = errors.New("password incorrect")
|
||||
ErrLoginTwoStepVerify = errors.New("requires two-step verification")
|
||||
ErrAccountNotBindMobile = errors.New("account not binds mobile")
|
||||
ErrCredentialInvalid = errors.New("credential invalid")
|
||||
ErrSessionExited = errors.New("session exited")
|
||||
|
||||
ErrQrcodeExpired = errors.New("qrcode expired")
|
||||
|
||||
// ErrUnexpected is the fall-back error whose code is not handled.
|
||||
ErrUnexpected = errors.New("unexpected error")
|
||||
|
||||
// ErrExist means an item which you want to create is already existed.
|
||||
ErrExist = errors.New("target already exists")
|
||||
// ErrNotExist means an item which you find is not existed.
|
||||
ErrNotExist = errors.New("target does not exist")
|
||||
|
||||
ErrInvalidCursor = errors.New("invalid cursor")
|
||||
|
||||
ErrUploadTooLarge = errors.New("upload reach the limit")
|
||||
|
||||
ErrUploadFailed = errors.New("upload failed")
|
||||
|
||||
ErrImportDirectory = errors.New("can not import directory")
|
||||
|
||||
ErrDownloadEmpty = errors.New("can not get download URL")
|
||||
|
||||
ErrDownloadDirectory = errors.New("can not download directory")
|
||||
|
||||
ErrDownloadFileNotExistOrHasDeleted = errors.New("target file does not exist or has deleted")
|
||||
|
||||
ErrDownloadFileTooBig = errors.New("target file is too big to download")
|
||||
|
||||
ErrCyclicCopy = errors.New("cyclic copy")
|
||||
|
||||
ErrCyclicMove = errors.New("cyclic move")
|
||||
|
||||
ErrVideoNotReady = errors.New("video is not ready")
|
||||
|
||||
ErrWrongParams = errors.New("wrong parameters")
|
||||
|
||||
ErrRepeatLogin = errors.New("repeat login")
|
||||
|
||||
ErrFailedToLogin = errors.New("failed to login")
|
||||
|
||||
ErrDoesLoggedOut = errors.New("you have been kicked out by multi-device login management")
|
||||
|
||||
ErrPickCodeNotExist = errors.New("pickcode does not exist")
|
||||
|
||||
ErrSharedInvalid = errors.New("shared link invalid")
|
||||
|
||||
ErrSharedNotFound = errors.New("shared link not found")
|
||||
|
||||
ErrPickCodeIsEmpty = errors.New("empty pickcode")
|
||||
|
||||
ErrUploadSH1Invalid = errors.New("userid/filesize/target/pickcode/ invalid")
|
||||
|
||||
ErrUploadSigInvalid = errors.New("sig invalid")
|
||||
|
||||
errMap = map[int]error{
|
||||
// Normal errors
|
||||
99: ErrNotLogin,
|
||||
990001: ErrNotLogin,
|
||||
// Offline errors
|
||||
10010: ErrOfflineNoTimes,
|
||||
10004: ErrOfflineInvalidLink,
|
||||
10008: ErrOfflineTaskExisted,
|
||||
// Dir errors
|
||||
20004: ErrExist,
|
||||
// Label errors
|
||||
21003: ErrExist,
|
||||
// File errors
|
||||
20130827: ErrOrderNotSupport,
|
||||
50028: ErrDownloadFileTooBig,
|
||||
70005: ErrDownloadFileNotExistOrHasDeleted,
|
||||
231011: ErrDownloadFileNotExistOrHasDeleted,
|
||||
91002: ErrCyclicCopy,
|
||||
800006: ErrCyclicMove,
|
||||
// Login errors
|
||||
40101009: ErrPasswordIncorrect,
|
||||
40101010: ErrLoginTwoStepVerify,
|
||||
40101017: ErrFailedToLogin,
|
||||
40100000: ErrWrongParams,
|
||||
40101030: ErrAccountNotBindMobile,
|
||||
40101032: ErrCredentialInvalid,
|
||||
40101033: ErrRepeatLogin,
|
||||
40101035: ErrDoesLoggedOut,
|
||||
40101037: ErrSessionExited,
|
||||
40101038: ErrRepeatLogin,
|
||||
// QRCode errors
|
||||
40199002: ErrQrcodeExpired,
|
||||
// Params errors
|
||||
1001: ErrWrongParams,
|
||||
200900: ErrWrongParams,
|
||||
990002: ErrWrongParams,
|
||||
// share
|
||||
4100009: ErrSharedInvalid,
|
||||
4100026: ErrSharedNotFound,
|
||||
// pickCode
|
||||
50003: ErrPickCodeNotExist,
|
||||
50001: ErrPickCodeIsEmpty,
|
||||
// upload SH1
|
||||
402: ErrUploadSH1Invalid,
|
||||
400: ErrUploadSigInvalid,
|
||||
}
|
||||
)
|
||||
|
||||
func GetErr(code int, respBody ...string) error {
|
||||
errWithMsg := ErrUnexpected
|
||||
if err, found := errMap[code]; found {
|
||||
errWithMsg = err
|
||||
}
|
||||
// if len(respBody) > 0 && errors.Is(ErrUnexpected, errWithMsg) {
|
||||
if len(respBody) > 0 {
|
||||
bodyRaw := respBody[0]
|
||||
readableBody, err := strconv.Unquote(strings.Replace(strconv.Quote(bodyRaw), `\\u`, `\u`, -1))
|
||||
if err != nil {
|
||||
return errors.Wrap(errWithMsg, bodyRaw)
|
||||
}
|
||||
return errors.Wrap(errWithMsg, readableBody)
|
||||
}
|
||||
return errWithMsg
|
||||
}
|
||||
|
||||
type ResultWithErr interface {
|
||||
Err(respBody ...string) error
|
||||
}
|
||||
|
||||
func CheckErr(err error, result ResultWithErr, restyResp *resty.Response) error {
|
||||
if err == nil {
|
||||
err = result.Err(restyResp.String())
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
// Marks is the file a directory.
|
||||
IsDirectory bool
|
||||
// Unique identifier of the file on the cloud storage.
|
||||
FileID string
|
||||
// FileID of the parent directory.
|
||||
ParentID string
|
||||
|
||||
// Base name of the file.
|
||||
Name string
|
||||
// Size in bytes of the file.
|
||||
Size int64
|
||||
// IDentifier used for downloading or playing the file.
|
||||
PickCode string
|
||||
// SHA1 hash of file content, in HEX format.
|
||||
Sha1 string
|
||||
|
||||
// Is file stared
|
||||
Star bool
|
||||
// File labels
|
||||
Labels []*Label
|
||||
|
||||
// Create time of the file.
|
||||
CreateTime time.Time
|
||||
// Update time of the file.
|
||||
UpdateTime time.Time
|
||||
|
||||
// Thumb URL of the file.
|
||||
ThumbURL string
|
||||
}
|
||||
|
||||
func (f *File) From(fileInfo *FileInfo) *File {
|
||||
return f.from(fileInfo)
|
||||
}
|
||||
|
||||
func (f *File) from(fileInfo *FileInfo) *File {
|
||||
if fileInfo.FileID != "" {
|
||||
f.FileID = fileInfo.FileID
|
||||
f.ParentID = string(fileInfo.CategoryID)
|
||||
f.IsDirectory = false
|
||||
loc, err := time.LoadLocation("Asia/Shanghai") // updatetime is a string without timezone
|
||||
if err != nil {
|
||||
// if missing Asia/Shanghai use CST(UTC+8)
|
||||
loc = time.FixedZone("UTC+8", 8*3600)
|
||||
}
|
||||
localTime, err := time.ParseInLocation("2006-01-02 15:04", fileInfo.UpdateTime, loc)
|
||||
if err == nil {
|
||||
f.UpdateTime = time.Unix(localTime.Unix(), 0)
|
||||
}
|
||||
f.ThumbURL = fileInfo.ThumbURL
|
||||
} else {
|
||||
f.FileID = string(fileInfo.CategoryID)
|
||||
f.ParentID = fileInfo.ParentID
|
||||
f.IsDirectory = true
|
||||
t, err := strconv.ParseInt(fileInfo.UpdateTime, 10, 64)
|
||||
if err == nil {
|
||||
f.UpdateTime = time.Unix(t, 0)
|
||||
}
|
||||
}
|
||||
f.Name = fileInfo.Name
|
||||
f.Size = int64(fileInfo.Size)
|
||||
f.PickCode = fileInfo.PickCode
|
||||
f.Sha1 = fileInfo.Sha1
|
||||
|
||||
f.Star = fileInfo.IsStar != 0
|
||||
f.Labels = make([]*Label, len(fileInfo.Labels))
|
||||
for i, l := range fileInfo.Labels {
|
||||
f.Labels[i] = &Label{
|
||||
ID: l.ID,
|
||||
Name: l.Name,
|
||||
Color: LabelColor(LabelColorMap[l.Color]),
|
||||
}
|
||||
}
|
||||
|
||||
f.CreateTime = time.Unix(int64(fileInfo.CreateTime), 0)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f File) GetPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f File) GetSize() int64 {
|
||||
return f.Size
|
||||
}
|
||||
|
||||
func (f File) GetName() string {
|
||||
return f.Name
|
||||
}
|
||||
|
||||
func (f File) ModTime() time.Time {
|
||||
return f.UpdateTime
|
||||
}
|
||||
|
||||
func (f File) IsDir() bool {
|
||||
return f.IsDirectory
|
||||
}
|
||||
|
||||
func (f File) GetID() string {
|
||||
return f.FileID
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package driver
|
||||
|
||||
// GetInfo get space info and login device info.
|
||||
func (c *Pan115Client) GetInfo() (InfoData, error) {
|
||||
result := InfoResponse{}
|
||||
req := c.NewRequest().
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Get(ApiFileIndexInfo)
|
||||
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return InfoData{}, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
type InfoResponse struct {
|
||||
BasicResp
|
||||
Data InfoData `json:"data"`
|
||||
}
|
||||
|
||||
type InfoData struct {
|
||||
SpaceInfo SpaceInfo `json:"space_info"`
|
||||
LoginDevicesInfo LoginDevicesInfo `json:"login_devices_info"`
|
||||
ImeiInfo bool `json:"imei_info"`
|
||||
}
|
||||
|
||||
type TotalSize struct {
|
||||
Size int64 `json:"size"`
|
||||
SizeFormat string `json:"size_format"`
|
||||
}
|
||||
|
||||
type RemainSize struct {
|
||||
Size int64 `json:"size"`
|
||||
SizeFormat string `json:"size_format"`
|
||||
}
|
||||
|
||||
type UseSize struct {
|
||||
Size int64 `json:"size"`
|
||||
SizeFormat string `json:"size_format"`
|
||||
}
|
||||
|
||||
type SpaceInfo struct {
|
||||
AllTotal TotalSize `json:"all_total"`
|
||||
AllRemain RemainSize `json:"all_remain"`
|
||||
AllUse UseSize `json:"all_use"`
|
||||
}
|
||||
|
||||
type LastDevice struct {
|
||||
IP string `json:"ip"`
|
||||
Device string `json:"device"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Network string `json:"network"`
|
||||
Os string `json:"os"`
|
||||
City string `json:"city"`
|
||||
Utime int `json:"utime"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
IsCurrent int `json:"is_current"`
|
||||
Ssoent string `json:"ssoent"`
|
||||
Utime int `json:"utime"`
|
||||
Device string `json:"device"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Desc string `json:"desc"`
|
||||
IP string `json:"ip"`
|
||||
City string `json:"city"`
|
||||
IsUnusual int `json:"is_unusual"`
|
||||
}
|
||||
|
||||
type LoginDevicesInfo struct {
|
||||
Last LastDevice `json:"last"`
|
||||
List []Device `json:"list"`
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package driver
|
||||
|
||||
var (
|
||||
LabelColors = []string{
|
||||
// No Color
|
||||
"#000000",
|
||||
// Red
|
||||
"#FF4B30",
|
||||
// Orange
|
||||
"#F78C26",
|
||||
// Yellow
|
||||
"#FFC032",
|
||||
// Green
|
||||
"#43BA80",
|
||||
// Blue
|
||||
"#2670FC",
|
||||
// Purple
|
||||
"#8B69FE",
|
||||
// Gray
|
||||
"#CCCCCC",
|
||||
}
|
||||
|
||||
LabelColorMap = map[string]int{
|
||||
"#000000": 0,
|
||||
"#FF4B30": 1,
|
||||
"#F78C26": 2,
|
||||
"#FFC032": 3,
|
||||
"#43BA80": 4,
|
||||
"#2670FC": 5,
|
||||
"#8B69FE": 6,
|
||||
"#CCCCCC": 7,
|
||||
}
|
||||
)
|
||||
|
||||
type Label struct {
|
||||
ID string
|
||||
Name string
|
||||
Color LabelColor
|
||||
}
|
||||
|
||||
type LabelColor int
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// CookieCheck checks the cookie status and will not logout of other devices.
|
||||
func (c *Pan115Client) CookieCheck() error {
|
||||
result := struct {
|
||||
State bool `json:"state"`
|
||||
}{}
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("_", NowMilli().String()).
|
||||
SetResult(&result)
|
||||
|
||||
if _, _ = req.Get(ApiStatusCheck); !result.State {
|
||||
return ErrBadCookie
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginCheck checks the login status and will logout of other devices.
|
||||
func (c *Pan115Client) LoginCheck() error {
|
||||
result := LoginResp{}
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("_", NowMilli().String()).
|
||||
SetResult(&result)
|
||||
resp, err := req.Get(ApiLoginCheck)
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return err
|
||||
}
|
||||
c.UserID = result.Data.UserID
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportCredential import uid, cid, seid
|
||||
func (c *Pan115Client) ImportCredential(cr *Credential) *Pan115Client {
|
||||
cookies := map[string]string{
|
||||
CookieNameUid: cr.UID,
|
||||
CookieNameCid: cr.CID,
|
||||
CookieNameSeid: cr.SEID,
|
||||
CookieNameKid: cr.KID,
|
||||
}
|
||||
c.ImportCookies(cookies, CookieDomain115)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Pan115Client) ImportCookies(cookies map[string]string, domains ...string) {
|
||||
for _, domain := range domains {
|
||||
c.importCookies(cookies, domain, "/")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Pan115Client) importCookies(cookies map[string]string, domain string, path string) {
|
||||
// Make a dummy URL for saving cookie
|
||||
url := &neturl.URL{
|
||||
Scheme: "https",
|
||||
Path: "/",
|
||||
}
|
||||
if domain[0] == '.' {
|
||||
url.Host = "www" + domain
|
||||
} else {
|
||||
url.Host = domain
|
||||
}
|
||||
// Prepare cookies
|
||||
cks := make([]*http.Cookie, 0, len(cookies))
|
||||
for name, value := range cookies {
|
||||
cookie := &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Domain: domain,
|
||||
Path: path,
|
||||
HttpOnly: true,
|
||||
}
|
||||
cks = append(cks, cookie)
|
||||
}
|
||||
// Save cookies
|
||||
c.SetCookies(cks...)
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
UID string `json:"UID"`
|
||||
CID string `json:"CID"`
|
||||
SEID string `json:"SEID"`
|
||||
KID string `json:"KID"`
|
||||
}
|
||||
|
||||
// FromCookie get uid, cid, seid from cookie string
|
||||
func (cr *Credential) FromCookie(cookie string) error {
|
||||
items := strings.Split(cookie, ";")
|
||||
if len(items) < 3 {
|
||||
return errors.Wrap(ErrBadCookie, "number of cookie paris < 3")
|
||||
}
|
||||
|
||||
cookieMap := map[string]string{}
|
||||
for _, item := range items {
|
||||
pairs := strings.Split(strings.TrimSpace(item), "=")
|
||||
if len(pairs) != 2 {
|
||||
return ErrBadCookie
|
||||
}
|
||||
key := pairs[0]
|
||||
value := pairs[1]
|
||||
cookieMap[strings.ToUpper(key)] = value
|
||||
}
|
||||
cr.UID = cookieMap["UID"]
|
||||
cr.CID = cookieMap["CID"]
|
||||
cr.SEID = cookieMap["SEID"]
|
||||
cr.KID = cookieMap["KID"]
|
||||
// No need to verify the KID for those old cookies that are still available.
|
||||
if cr.CID == "" || cr.UID == "" || cr.SEID == "" {
|
||||
return errors.Wrap(ErrBadCookie, "bad cookie, miss UID, CID or SEID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cookie return cookie format
|
||||
func (cr *Credential) Cookie() string {
|
||||
return fmt.Sprintf("UID=%s;CID=%s;SEID=%s;KID=%s", cr.UID, cr.CID, cr.SEID, cr.KID)
|
||||
}
|
||||
|
||||
// GetUser get user information
|
||||
func (c *Pan115Client) GetUser() (*UserInfo, error) {
|
||||
result := UserInfoResp{}
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("_", Now().String()).
|
||||
SetResult(&result)
|
||||
resp, err := req.Get(ApiUserInfo)
|
||||
return &result.UserInfo, CheckErr(err, &result, resp)
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
crypto "github.com/SheltonZhu/115driver/pkg/crypto/m115"
|
||||
)
|
||||
|
||||
// OfflineTask describe an offline downloading task.
|
||||
type OfflineTask struct {
|
||||
InfoHash string `json:"info_hash"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Url string `json:"url"`
|
||||
AddTime int64 `json:"add_time"`
|
||||
Peers int64 `json:"peers"`
|
||||
RateDownload float64 `json:"rateDownload"`
|
||||
Status int `json:"status"`
|
||||
Percent float64 `json:"percentDone"`
|
||||
UpdateTime int64 `json:"last_update"`
|
||||
LeftTime int64 `json:"left_time"`
|
||||
FileId string `json:"file_id"`
|
||||
DelFileId string `json:"delete_file_id"`
|
||||
DirId string `json:"wp_path_id"`
|
||||
Move int `json:"move"`
|
||||
}
|
||||
|
||||
func (t *OfflineTask) IsTodo() bool {
|
||||
return t.Status == 0
|
||||
}
|
||||
|
||||
func (t *OfflineTask) IsRunning() bool {
|
||||
return t.Status == 1
|
||||
}
|
||||
|
||||
func (t *OfflineTask) IsDone() bool {
|
||||
return t.Status == 2
|
||||
}
|
||||
|
||||
func (t *OfflineTask) IsFailed() bool {
|
||||
return t.Status == -1
|
||||
}
|
||||
|
||||
func (t *OfflineTask) GetStatus() string {
|
||||
if t.IsTodo() {
|
||||
return "准备开始离线下载"
|
||||
}
|
||||
if t.IsDone() {
|
||||
return "离线下载完成"
|
||||
}
|
||||
if t.IsFailed() {
|
||||
return "离线下载失败"
|
||||
}
|
||||
if t.IsRunning() {
|
||||
return "离线任务下载中"
|
||||
}
|
||||
return fmt.Sprintf("未知状态: %d", t.Status)
|
||||
}
|
||||
|
||||
// ListOfflineTask list tasks
|
||||
func (c *Pan115Client) ListOfflineTask(page int64) (OfflineTaskResp, error) {
|
||||
result := OfflineTaskResp{}
|
||||
if isCalledByAlistV3() {
|
||||
return result, ErrorNotSupportAlist
|
||||
}
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("page", strconv.FormatInt(page, 10)).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Post(ApiListOfflineUrl)
|
||||
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return OfflineTaskResp{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddOfflineTaskURIs adds offline tasks by download URIs.
|
||||
// supports http, ed2k, magent
|
||||
func (c *Pan115Client) AddOfflineTaskURIs(uris []string, saveDirID string, opts ...OfflineOption) (hashes []string, err error) {
|
||||
if isCalledByAlistV3() {
|
||||
return nil, ErrorNotSupportAlist
|
||||
}
|
||||
opt := DefaultOfflineOptions()
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
count := len(uris)
|
||||
if count == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if c.UserID <= 0 {
|
||||
userInfo, err := c.GetUser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.UserID = userInfo.UserID
|
||||
}
|
||||
|
||||
key := crypto.GenerateKey()
|
||||
|
||||
result := DownloadResp{}
|
||||
|
||||
params := map[string]string{
|
||||
"ac": "add_task_urls",
|
||||
"wp_path_id": saveDirID,
|
||||
"app_ver": opt.appVer,
|
||||
"uid": strconv.FormatInt(c.UserID, 10),
|
||||
}
|
||||
for i, uri := range uris {
|
||||
key := fmt.Sprintf("url[%d]", i)
|
||||
params[key] = uri
|
||||
}
|
||||
paramsBytes, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := crypto.Encode(paramsBytes, key)
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("t", Now().String()).
|
||||
SetFormData(map[string]string{"data": data}).
|
||||
ForceContentType("application/json").
|
||||
SetResult(&result)
|
||||
|
||||
resp, err := req.Post(ApiAddOfflineUrl)
|
||||
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bytes, err := crypto.Decode(string(result.EncodedData), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
taskInfos := OfflineAddUrlResponse{}
|
||||
if err := json.Unmarshal(bytes, &taskInfos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hashes = make([]string, count)
|
||||
for i, task := range taskInfos.Result {
|
||||
hashes[i] = task.InfoHash
|
||||
}
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
// DeleteOfflineTasks deletes tasks.
|
||||
func (c *Pan115Client) DeleteOfflineTasks(hashes []string, deleteFiles bool) error {
|
||||
if isCalledByAlistV3() {
|
||||
return ErrorNotSupportAlist
|
||||
}
|
||||
form := url.Values{}
|
||||
for _, hash := range hashes {
|
||||
form.Add("hash", hash)
|
||||
}
|
||||
|
||||
form.Set("flag", "0")
|
||||
if deleteFiles {
|
||||
form.Set("flag", "1")
|
||||
}
|
||||
|
||||
result := MkdirResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormDataFromValues(form).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Post(ApiDelOfflineUrl)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
// ClearOfflineTasks deletes tasks.
|
||||
func (c *Pan115Client) ClearOfflineTasks(clearFlag int64) error {
|
||||
form := url.Values{}
|
||||
form.Set("flag", strconv.FormatInt(int64(clearFlag), 10))
|
||||
|
||||
result := MkdirResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormDataFromValues(form).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Post(ApiClearOfflineUrl)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
type OfflineAddUrlResponse struct {
|
||||
BasicResp
|
||||
Result []OfflineTaskResponse `json:"result"`
|
||||
}
|
||||
type OfflineTaskResponse struct {
|
||||
InfoHash string `json:"info_hash"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type OfflineTaskResp struct {
|
||||
BasicResp
|
||||
Total int64 `json:"total"`
|
||||
Count int64 `json:"count"`
|
||||
PageRow int64 `json:"page_row"`
|
||||
PageCount int64 `json:"page_count"`
|
||||
Page int64 `json:"page"`
|
||||
Quota int64 `json:"quota"`
|
||||
Tasks []*OfflineTask `json:"tasks"`
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Delete delete files or directory from file ids
|
||||
func (c *Pan115Client) Delete(fileIDs ...string) error {
|
||||
if len(fileIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
form := map[string]string{}
|
||||
for i, value := range fileIDs {
|
||||
key := fmt.Sprintf("%s[%d]", "fid", i)
|
||||
form[key] = value
|
||||
}
|
||||
|
||||
result := BasicResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormData(form).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Post(ApiFileDelete)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
// Rename rename a file or directory with file id and name
|
||||
func (c *Pan115Client) Rename(fileID, newName string) error {
|
||||
if isCalledByAlistV3() {
|
||||
return ErrorNotSupportAlist
|
||||
}
|
||||
form := map[string]string{
|
||||
"fid": fileID,
|
||||
"file_name": newName,
|
||||
fmt.Sprintf("files_new_name[%s]", fileID): newName,
|
||||
}
|
||||
|
||||
result := BasicResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormData(form).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Post(ApiFileRename)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
// Move move files or directory into another directory with directroy id
|
||||
func (c *Pan115Client) Move(dirID string, fileIDs ...string) error {
|
||||
if isCalledByAlistV3() {
|
||||
return ErrorNotSupportAlist
|
||||
}
|
||||
if len(fileIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
form := map[string]string{
|
||||
"pid": dirID,
|
||||
}
|
||||
for i, value := range fileIDs {
|
||||
key := fmt.Sprintf("%s[%d]", "fid", i)
|
||||
form[key] = value
|
||||
}
|
||||
result := BasicResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormData(form).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Post(ApiFileMove)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
// Copy copy files or directory into another directory with directroy id
|
||||
func (c *Pan115Client) Copy(dirID string, fileIDs ...string) error {
|
||||
if isCalledByAlistV3() {
|
||||
return ErrorNotSupportAlist
|
||||
}
|
||||
if len(fileIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
form := map[string]string{
|
||||
"pid": dirID,
|
||||
}
|
||||
for i, value := range fileIDs {
|
||||
key := fmt.Sprintf("%s[%d]", "fid", i)
|
||||
form[key] = value
|
||||
}
|
||||
result := BasicResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormData(form).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Post(ApiFileCopy)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
type FileStatInfo struct {
|
||||
// Base name of the file.
|
||||
Name string
|
||||
// Identifier used for downloading or playing the file.
|
||||
PickCode string
|
||||
// SHA1 hash of file content, in HEX format.
|
||||
Sha1 string
|
||||
// Marks is file a directory.
|
||||
IsDirectory bool
|
||||
// Files count under this directory.
|
||||
FileCount int
|
||||
// Subdirectories count under this directory.
|
||||
DirCount int
|
||||
|
||||
// Create time of the file.
|
||||
CreateTime time.Time
|
||||
// Last update time of the file.
|
||||
UpdateTime time.Time
|
||||
// Last access time of the file.
|
||||
// AccessTime time.Time
|
||||
|
||||
// Parent directory list.
|
||||
Parents []*DirInfo
|
||||
}
|
||||
|
||||
// DirInfo only used in FileInfo.
|
||||
type DirInfo struct {
|
||||
// Directory ID.
|
||||
ID string
|
||||
// Directory Name.
|
||||
Name string
|
||||
}
|
||||
|
||||
// Stat get statistic information of a file or directory
|
||||
func (c *Pan115Client) Stat(fileID string) (*FileStatInfo, error) {
|
||||
result := FileStatResponse{}
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("cid", fileID).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Get(ApiFileStat)
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := &FileStatInfo{
|
||||
Name: result.FileName,
|
||||
PickCode: result.PickCode,
|
||||
Sha1: result.Sha1,
|
||||
CreateTime: time.Unix(int64(result.CreateTime), 0),
|
||||
UpdateTime: time.Unix(int64(result.UpdateTime), 0),
|
||||
// AccessTime: time.Unix(result.AccessTime, 0),
|
||||
}
|
||||
// Fill parents
|
||||
info.Parents = make([]*DirInfo, len(result.Paths))
|
||||
for i, path := range result.Paths {
|
||||
info.Parents[i] = &DirInfo{
|
||||
ID: strconv.Itoa(path.FileID),
|
||||
Name: path.FileName,
|
||||
}
|
||||
}
|
||||
// Directory info
|
||||
info.IsDirectory = result.IsFile == 0
|
||||
if info.IsDirectory {
|
||||
info.FileCount = int(result.FileCount)
|
||||
info.DirCount = int(result.FolderCount)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetFile gets information of a file or directory by its ID.
|
||||
func (c *Pan115Client) GetFile(fileID string) (*File, error) {
|
||||
result := GetFileInfoResponse{}
|
||||
req := c.NewRequest().
|
||||
SetQueryParam("file_id", fileID).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Get(ApiFileInfo)
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileInfo := &FileInfo{}
|
||||
if len(result.Files) > 0 {
|
||||
fileInfo = result.Files[0]
|
||||
}
|
||||
f := &File{}
|
||||
f.from(fileInfo)
|
||||
return f, nil
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// Option driver client options
|
||||
type Option func(c *Pan115Client)
|
||||
|
||||
func UA(userAgent ...string) Option {
|
||||
return func(c *Pan115Client) {
|
||||
if len(userAgent) > 0 {
|
||||
c.SetUserAgent(userAgent[0])
|
||||
} else {
|
||||
c.SetUserAgent(UADefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithClient(hc *http.Client) Option {
|
||||
return func(c *Pan115Client) {
|
||||
c.SetHttpClient(hc)
|
||||
}
|
||||
}
|
||||
|
||||
func WithRestyClient(resty *resty.Client) Option {
|
||||
return func(c *Pan115Client) {
|
||||
c.Client = resty
|
||||
}
|
||||
}
|
||||
|
||||
func WithDebug() Option {
|
||||
return func(c *Pan115Client) {
|
||||
c.SetDebug(true)
|
||||
}
|
||||
}
|
||||
|
||||
func WithTrace() Option {
|
||||
return func(c *Pan115Client) {
|
||||
c.EnableTrace()
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxy(proxy string) Option {
|
||||
return func(c *Pan115Client) {
|
||||
c.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
func InsecureSkipVerify(insecureSkipVerify bool) Option {
|
||||
return func(c *Pan115Client) {
|
||||
c.Client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify})
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
FileOrderByTime = "user_ptime"
|
||||
FileOrderByType = "file_type"
|
||||
FileOrderBySize = "file_size"
|
||||
FileOrderByName = "file_name"
|
||||
|
||||
FileListLimit = int64(56)
|
||||
)
|
||||
|
||||
// GetFileOption get file options
|
||||
type GetFileOption struct {
|
||||
order string
|
||||
asc string
|
||||
pageSize int64
|
||||
offset int64
|
||||
showDir string
|
||||
apiURL string
|
||||
}
|
||||
|
||||
type GetFileOptions func(o *GetFileOption)
|
||||
|
||||
func WithApiURL(url string) GetFileOptions {
|
||||
return func(o *GetFileOption) {
|
||||
o.apiURL = url
|
||||
}
|
||||
}
|
||||
|
||||
func WithLimit(pageSize int64) GetFileOptions {
|
||||
return func(o *GetFileOption) {
|
||||
o.pageSize = pageSize
|
||||
}
|
||||
}
|
||||
|
||||
func WithOffset(offset int64) GetFileOptions {
|
||||
return func(o *GetFileOption) {
|
||||
o.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
func WithOrder(order string) GetFileOptions {
|
||||
return func(o *GetFileOption) {
|
||||
o.order = order
|
||||
}
|
||||
}
|
||||
|
||||
func WithShowDirEnable(e bool) GetFileOptions {
|
||||
return func(o *GetFileOption) {
|
||||
o.showDir = "0"
|
||||
if e {
|
||||
o.showDir = "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithAsc(d bool) GetFileOptions {
|
||||
return func(o *GetFileOption) {
|
||||
o.showDir = "0"
|
||||
if d {
|
||||
o.showDir = "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *GetFileOption) GetApiURL() string {
|
||||
return o.apiURL
|
||||
}
|
||||
|
||||
func (o *GetFileOption) GetOrder() string {
|
||||
return o.order
|
||||
}
|
||||
|
||||
func (o *GetFileOption) GetAsc() string {
|
||||
return o.asc
|
||||
}
|
||||
|
||||
func (o *GetFileOption) GetPageSize() string {
|
||||
return strconv.FormatInt(o.pageSize, 10)
|
||||
}
|
||||
|
||||
func (o *GetFileOption) GetOffset() string {
|
||||
return strconv.FormatInt(o.offset, 10)
|
||||
}
|
||||
|
||||
func (o *GetFileOption) GetshowDir() string {
|
||||
return o.showDir
|
||||
}
|
||||
|
||||
func DefaultGetFileOptions() *GetFileOption {
|
||||
return &GetFileOption{
|
||||
order: FileOrderByTime,
|
||||
asc: "1",
|
||||
pageSize: int64(56),
|
||||
offset: int64(0),
|
||||
showDir: "1",
|
||||
apiURL: ApiFileList,
|
||||
}
|
||||
}
|
||||
|
||||
type UploadMultipartOptions struct {
|
||||
ThreadsNum int
|
||||
Timeout time.Duration
|
||||
TokenRefreshTime time.Duration
|
||||
}
|
||||
|
||||
// DefalutUploadMultipartOptions is deprecated: use DefaultUploadMultipartOptions instead. This function exists for backward compatibility.
|
||||
func DefalutUploadMultipartOptions() *UploadMultipartOptions {
|
||||
return DefaultUploadMultipartOptions()
|
||||
}
|
||||
|
||||
func DefaultUploadMultipartOptions() *UploadMultipartOptions {
|
||||
return &UploadMultipartOptions{
|
||||
// oss 启用Sequential必须按顺序上传
|
||||
ThreadsNum: 1,
|
||||
Timeout: time.Hour * 24,
|
||||
TokenRefreshTime: time.Minute * 50,
|
||||
}
|
||||
}
|
||||
|
||||
type UploadMultipartOption func(o *UploadMultipartOptions)
|
||||
|
||||
func UploadMultipartWithThreadsNum(n int) UploadMultipartOption {
|
||||
return func(o *UploadMultipartOptions) {
|
||||
o.ThreadsNum = n
|
||||
}
|
||||
}
|
||||
|
||||
func UploadMultipartWithTimeout(timeout time.Duration) UploadMultipartOption {
|
||||
return func(o *UploadMultipartOptions) {
|
||||
o.Timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func UploadMultipartWithTokenRefreshTime(refreshTime time.Duration) UploadMultipartOption {
|
||||
return func(o *UploadMultipartOptions) {
|
||||
o.TokenRefreshTime = refreshTime
|
||||
}
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
ApiURLs []string
|
||||
}
|
||||
|
||||
func DefaultListOptions() *ListOptions {
|
||||
return &ListOptions{
|
||||
ApiURLs: []string{ApiFileList},
|
||||
}
|
||||
}
|
||||
|
||||
type ListOption func(o *ListOptions)
|
||||
|
||||
func WithApiURLs(urls ...string) ListOption {
|
||||
return func(o *ListOptions) {
|
||||
if len(urls) > 0 {
|
||||
o.ApiURLs = urls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithMultiUrls() ListOption {
|
||||
return WithApiURLs([]string{
|
||||
ApiFileList,
|
||||
ApiFileList1,
|
||||
// ApiFileList2,
|
||||
// ApiFileList3,
|
||||
}...)
|
||||
}
|
||||
|
||||
type OfflineOptions struct {
|
||||
appVer string
|
||||
}
|
||||
|
||||
func DefaultOfflineOptions() OfflineOptions {
|
||||
return OfflineOptions{
|
||||
appVer: appVer,
|
||||
}
|
||||
}
|
||||
|
||||
type OfflineOption func(o *OfflineOptions)
|
||||
|
||||
func WithAppVer(appVer string) OfflineOption {
|
||||
return func(o *OfflineOptions) {
|
||||
o.appVer = appVer
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
type QRCodeSession struct {
|
||||
// The raw data of QRCode, caller should use third-party tools/libraries
|
||||
// to convert it into QRCode matrix or image.
|
||||
QrcodeContent string `json:"qrcode"`
|
||||
Sign string `json:"sign"`
|
||||
Time int64 `json:"time"`
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
// QRCode get QRCode matrix or image.
|
||||
func (s *QRCodeSession) QRCode() ([]byte, error) {
|
||||
return qrcode.Encode(s.QrcodeContent, qrcode.Medium, 256)
|
||||
}
|
||||
|
||||
// QRCodeByApi get QRCode matrix or image by api.
|
||||
func (s *QRCodeSession) QRCodeByApi() ([]byte, error) {
|
||||
resp, err := resty.New().R().Get(fmt.Sprintf(ApiQrcodeImage, s.UID))
|
||||
return resp.Body(), err
|
||||
}
|
||||
|
||||
// QRCodeStart starts a QRCode login session.
|
||||
func (c *Pan115Client) QRCodeStart() (*QRCodeSession, error) {
|
||||
result := QRCodeTokenResp{}
|
||||
resp, err := c.NewRequest().
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
Get(ApiQrcodeToken)
|
||||
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// QRCodeLogin logins user through QRCode with web app.
|
||||
// You SHOULD call this method ONLY when `QRCodeStatus.IsAllowed()` is true.
|
||||
func (c *Pan115Client) QRCodeLogin(s *QRCodeSession) (*Credential, error) {
|
||||
return c.QRCodeLoginWithApp(s, LoginAppWeb)
|
||||
}
|
||||
|
||||
type LoginApp string
|
||||
|
||||
const (
|
||||
LoginAppWeb LoginApp = "web"
|
||||
LoginAppAndroid LoginApp = "android"
|
||||
LoginAppIOS LoginApp = "ios"
|
||||
// LoginAppLinux LoginApp = "linux" // disabled
|
||||
// LoginAppMac LoginApp = "mac" // disabled
|
||||
// LoginAppWindows LoginApp = "windows" // disabled
|
||||
LoginAppTV LoginApp = "tv"
|
||||
LoginAppAlipayMini LoginApp = "alipaymini"
|
||||
LoginAppWechatMini LoginApp = "wechatmini"
|
||||
LoginQAppAndroid LoginApp = "qandroid"
|
||||
)
|
||||
|
||||
// QRCodeLoginWithApp logins user through QRCode with specified app.
|
||||
// You SHOULD call this method ONLY when `QRCodeStatus.IsAllowed()` is true.
|
||||
func (c *Pan115Client) QRCodeLoginWithApp(s *QRCodeSession, app LoginApp) (*Credential, error) {
|
||||
result := QRCodeLoginResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormData(map[string]string{
|
||||
"account": s.UID,
|
||||
"app": string(app),
|
||||
}).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Post(fmt.Sprintf(ApiQrcodeLoginWithApp, app))
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result.Data.Credential, nil
|
||||
}
|
||||
|
||||
type QRCodeStatus struct {
|
||||
Msg string `json:"msg"`
|
||||
Status int `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (s *QRCodeStatus) IsWaiting() bool {
|
||||
return s.Status == 0
|
||||
}
|
||||
|
||||
func (s *QRCodeStatus) IsScanned() bool {
|
||||
return s.Status == 1
|
||||
}
|
||||
|
||||
func (s *QRCodeStatus) IsAllowed() bool {
|
||||
return s.Status == 2
|
||||
}
|
||||
|
||||
func (s *QRCodeStatus) IsExpired() bool {
|
||||
return s.Status == -1
|
||||
}
|
||||
|
||||
func (s *QRCodeStatus) IsCanceled() bool {
|
||||
return s.Status == -2
|
||||
}
|
||||
|
||||
/*
|
||||
QRCodeStatus represents the status of a QRCode session.
|
||||
|
||||
There are 4 possible status values:
|
||||
- Waiting
|
||||
- Scanned
|
||||
- Allowed
|
||||
- Canceled
|
||||
*/
|
||||
func (c *Pan115Client) QRCodeStatus(s *QRCodeSession) (*QRCodeStatus, error) {
|
||||
result := QRCodeStatusResp{}
|
||||
req := c.NewRequest().
|
||||
SetQueryParams(map[string]string{
|
||||
"uid": s.UID,
|
||||
"time": strconv.FormatInt(s.Time, 10),
|
||||
"sign": s.Sign,
|
||||
"_": Now().String(),
|
||||
}).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
|
||||
resp, err := req.Get(ApiQrcodeStatus)
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CleanRecycleBin clean the recycle bin
|
||||
func (c *Pan115Client) CleanRecycleBin(password string, rIDs ...string) error {
|
||||
form := url.Values{}
|
||||
form.Set("password", password)
|
||||
for idx, rID := range rIDs {
|
||||
form.Add(fmt.Sprintf("rid[%d]", idx), rID)
|
||||
}
|
||||
result := BasicResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormDataFromValues(form).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Post(ApiRecycleClean)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
// ListRecycleBin list the recycle bin
|
||||
func (c *Pan115Client) ListRecycleBin(offset, limit int) ([]RecycleBinItem, error) {
|
||||
result := RecycleListResponse{}
|
||||
req := c.NewRequest().
|
||||
SetQueryParams(map[string]string{
|
||||
"aid": "7",
|
||||
"cid": "0",
|
||||
"format": "json",
|
||||
"offset": strconv.Itoa(offset),
|
||||
"limit": strconv.Itoa(limit),
|
||||
}).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Get(ApiRecycleList)
|
||||
err = CheckErr(err, &result, resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
type RecycleListResponse struct {
|
||||
BasicResp
|
||||
Data []RecycleBinItem `json:"data"`
|
||||
}
|
||||
|
||||
type RecycleBinItem struct {
|
||||
FileId string `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize StringInt64 `json:"file_size"`
|
||||
ParentId IntString `json:"cid"`
|
||||
ParentName string `json:"parent_name"`
|
||||
DeleteTime StringInt64 `json:"dtime"`
|
||||
}
|
||||
|
||||
// RevertRecycleBin revert the recycle bin
|
||||
func (c *Pan115Client) RevertRecycleBin(rIDs ...string) error {
|
||||
form := url.Values{}
|
||||
for idx, rID := range rIDs {
|
||||
form.Add(fmt.Sprintf("rid[%d]", idx), rID)
|
||||
}
|
||||
result := BasicResp{}
|
||||
req := c.NewRequest().
|
||||
SetFormDataFromValues(form).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Post(ApiRecycleRevert)
|
||||
return CheckErr(err, &result, resp)
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoginResp struct {
|
||||
Code int `json:"code"`
|
||||
CheckSsd bool `json:"check_ssd"`
|
||||
Data struct {
|
||||
Expire int64 `json:"expire"`
|
||||
Link string `json:"link"`
|
||||
UserID int64 `json:"user_id"`
|
||||
} `json:"data"`
|
||||
Errno int `json:"errno"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
State int `json:"state"`
|
||||
Expire int `json:"expire"`
|
||||
}
|
||||
|
||||
func (resp *LoginResp) Err(respBody ...string) error {
|
||||
if resp.State == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(respBody) > 0 {
|
||||
return GetErr(resp.Code, respBody[0])
|
||||
}
|
||||
return GetErr(resp.Code)
|
||||
}
|
||||
|
||||
type BasicResp struct {
|
||||
Errno StringInt `json:"errno,omitempty"`
|
||||
ErrNo int `json:"errNo,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
State bool `json:"state,omitempty"`
|
||||
Errtype string `json:"errtype,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
|
||||
func (resp *BasicResp) Err(respBody ...string) error {
|
||||
if resp.State {
|
||||
return nil
|
||||
}
|
||||
nonZeroCode := findNonZero(int(resp.Errno), resp.ErrNo)
|
||||
if len(respBody) > 0 {
|
||||
return GetErr(nonZeroCode, respBody[0])
|
||||
}
|
||||
return GetErr(nonZeroCode)
|
||||
}
|
||||
|
||||
func findNonZero(code ...int) int {
|
||||
for _, c := range code {
|
||||
if c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type MkdirResp struct {
|
||||
BasicResp
|
||||
AreaID IntString `json:"aid"`
|
||||
|
||||
CategoryID IntString `json:"cid"`
|
||||
CategoryName string `json:"cname"`
|
||||
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
}
|
||||
|
||||
type FileListResp struct {
|
||||
BasicResp
|
||||
|
||||
AreaID string `json:"aid"`
|
||||
CategoryID IntString `json:"cid"`
|
||||
|
||||
Count int `json:"count"`
|
||||
Order string `json:"order"`
|
||||
IsAsc int `json:"is_asc"`
|
||||
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
Files []FileInfo `json:"data"`
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
AreaID IntString `json:"aid"`
|
||||
CategoryID IntString `json:"cid"`
|
||||
FileID string `json:"fid"`
|
||||
ParentID string `json:"pid"`
|
||||
|
||||
Name string `json:"n"`
|
||||
Type string `json:"ico"`
|
||||
Size StringInt64 `json:"s"`
|
||||
Sha1 string `json:"sha"`
|
||||
PickCode string `json:"pc"`
|
||||
|
||||
IsStar StringInt `json:"m"`
|
||||
Labels []*LabelInfo `json:"fl"`
|
||||
|
||||
CreateTime StringInt64 `json:"tp"`
|
||||
UpdateTime string `json:"t"`
|
||||
|
||||
ThumbURL string `json:"u"`
|
||||
}
|
||||
|
||||
type LabelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
|
||||
Sort StringInt `json:"sort"`
|
||||
|
||||
CreateTime int64 `json:"create_time"`
|
||||
UpdateTime int64 `json:"update_time"`
|
||||
}
|
||||
|
||||
type UploadInfoResp struct {
|
||||
BasicResp
|
||||
UploadMetaInfo
|
||||
UserID int64 `json:"user_id"`
|
||||
Userkey string `json:"userkey"`
|
||||
}
|
||||
type UploadEndpointResp struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
GetTokenURL string `json:"gettokenurl"`
|
||||
}
|
||||
|
||||
type UploadMetaInfo struct {
|
||||
AppID int64 `json:"app_id"`
|
||||
AppVersion int64 `json:"app_version"`
|
||||
IspType int64 `json:"isp_type"`
|
||||
MaxDirLevel int64 `json:"max_dir_level"`
|
||||
MaxDirLevelYun int64 `json:"max_dir_level_yun"`
|
||||
MaxFileNum int64 `json:"max_file_num"`
|
||||
MaxFileNumYun int64 `json:"max_file_num_yun"`
|
||||
SizeLimit int64 `json:"size_limit"`
|
||||
SizeLimitYun int64 `json:"size_limit_yun"`
|
||||
TypeLimit []string `json:"type_limit"`
|
||||
UploadAllowed bool `json:"upload_allowed"`
|
||||
UploadAllowedMsg string `json:"upload_allowed_msg"`
|
||||
}
|
||||
|
||||
type UploadInitResp struct {
|
||||
Request string `json:"request"`
|
||||
ErrorCode int `json:"statuscode"`
|
||||
ErrorMsg string `json:"statusmsg"`
|
||||
|
||||
Status BoolInt `json:"status"`
|
||||
PickCode string `json:"pickcode"`
|
||||
Target string `json:"target"`
|
||||
Version string `json:"version"`
|
||||
|
||||
// OSS upload fields
|
||||
UploadOSSParams
|
||||
|
||||
// Useless fields
|
||||
FileID int `json:"fileid"`
|
||||
FileInfo string `json:"fileinfo"`
|
||||
|
||||
// New fields in upload v4.0
|
||||
SignKey string `json:"sign_key"`
|
||||
SignCheck string `json:"sign_check"`
|
||||
}
|
||||
|
||||
type UploadOSSParams struct {
|
||||
SHA1 string `json:"-"`
|
||||
Bucket string `json:"bucket"`
|
||||
Object string `json:"object"`
|
||||
Callback struct {
|
||||
Callback string `json:"callback"`
|
||||
CallbackVar string `json:"callback_var"`
|
||||
} `json:"callback"`
|
||||
}
|
||||
|
||||
func (r *UploadInitResp) Err(respBody ...string) error {
|
||||
if r.ErrorCode == 0 || r.ErrorCode == 701 {
|
||||
return nil
|
||||
}
|
||||
return GetErr(r.ErrorCode, r.ErrorMsg)
|
||||
}
|
||||
|
||||
// Ok if fastupload is successful will return true, otherwise return false
|
||||
func (r *UploadInitResp) Ok() (bool, error) {
|
||||
switch r.Status {
|
||||
case 2:
|
||||
return true, nil
|
||||
case 1:
|
||||
return false, nil
|
||||
default:
|
||||
return false, ErrUnexpected
|
||||
}
|
||||
}
|
||||
|
||||
type UploadOSSTokenResp struct {
|
||||
AccessKeyID string `json:"AccessKeyID"`
|
||||
AccessKeySecret string `json:"AccessKeySecret"`
|
||||
Expiration time.Time `json:"Expiration"`
|
||||
SecurityToken string `json:"SecurityToken"`
|
||||
StatusCode string `json:"StatusCode"`
|
||||
}
|
||||
|
||||
func (r *UploadOSSTokenResp) Err(respBody ...string) error {
|
||||
if r.StatusCode == "200" {
|
||||
return nil
|
||||
}
|
||||
if len(respBody) > 0 {
|
||||
return GetErr(0, respBody[0])
|
||||
}
|
||||
return ErrUnexpected
|
||||
}
|
||||
|
||||
type DownloadResp struct {
|
||||
BasicResp
|
||||
EncodedData DataString `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type DataString string
|
||||
|
||||
func (v *DataString) UnmarshalJSON(b []byte) (err error) {
|
||||
var s string
|
||||
if b[0] == '"' {
|
||||
err = json.Unmarshal(b, &s)
|
||||
}
|
||||
if err == nil {
|
||||
*v = DataString(s)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type UserInfoResp struct {
|
||||
BasicResp
|
||||
UserInfo UserInfo `json:"data"`
|
||||
}
|
||||
type UserInfo struct {
|
||||
Device int `json:"device"`
|
||||
Rank int `json:"rank"`
|
||||
Liang int `json:"liang"`
|
||||
Mark int `json:"mark"`
|
||||
Mark1 int `json:"mark1"`
|
||||
Vip int `json:"vip"`
|
||||
Expire int `json:"expire"`
|
||||
Global int `json:"global"`
|
||||
Forever int `json:"forever"`
|
||||
IsPrivilege bool `json:"is_privilege"`
|
||||
Privilege Privilege `json:"privilege"`
|
||||
UserName string `json:"user_name"`
|
||||
Face string `json:"face"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
type Privilege struct {
|
||||
Start int `json:"start"`
|
||||
Expire int `json:"expire"`
|
||||
State bool `json:"state"`
|
||||
Mark StringInt `json:"mark"`
|
||||
}
|
||||
|
||||
type FileStatResponse struct {
|
||||
FileCount StringInt `json:"count"`
|
||||
Size string `json:"size"`
|
||||
FolderCount StringInt `json:"folder_count"`
|
||||
CreateTime StringInt64 `json:"ptime"`
|
||||
UpdateTime StringInt64 `json:"utime"`
|
||||
IsShare StringInt `json:"is_share"`
|
||||
FileName string `json:"file_name"`
|
||||
PickCode string `json:"pick_code"`
|
||||
Sha1 string `json:"sha1"`
|
||||
IsMark StringInt `json:"is_mark"`
|
||||
OpenTime int64 `json:"open_time"`
|
||||
IsFile StringInt `json:"file_category"`
|
||||
Paths []*FileParentInfo `json:"paths"`
|
||||
}
|
||||
type FileParentInfo struct {
|
||||
FileID int `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
}
|
||||
|
||||
func (r *FileStatResponse) Err(respBody ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetFileInfoResponse struct {
|
||||
BasicResp
|
||||
Files []*FileInfo `json:"data"`
|
||||
}
|
||||
|
||||
type QRCodeBasicResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
State int `json:"state"`
|
||||
Errno int `json:"errno"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (resp *QRCodeBasicResp) Err(respBody ...string) error {
|
||||
if resp.State == 1 {
|
||||
return nil
|
||||
}
|
||||
if len(respBody) > 0 {
|
||||
return GetErr(resp.Code, respBody[0])
|
||||
}
|
||||
return GetErr(resp.Code)
|
||||
}
|
||||
|
||||
type QRCodeTokenResp struct {
|
||||
QRCodeBasicResp
|
||||
Data QRCodeSession `json:"data"`
|
||||
}
|
||||
|
||||
type QRCodeLoginResp struct {
|
||||
QRCodeBasicResp
|
||||
Data struct {
|
||||
Alert string `json:"alert"`
|
||||
BindMobile int `json:"bind_mobile"`
|
||||
Credential Credential `json:"cookie"`
|
||||
Country string `json:"country"`
|
||||
Email string `json:"email"`
|
||||
Face struct {
|
||||
FaceL string `json:"face_l"`
|
||||
FaceM string `json:"face_m"`
|
||||
FaceS string `json:"face_s"`
|
||||
} `json:"face"`
|
||||
From string `json:"from"`
|
||||
IsChangPasswd int `json:"is_chang_passwd"`
|
||||
IsFirstLogin int `json:"is_first_login"`
|
||||
IsTrusted interface{} `json:"is_trusted"`
|
||||
IsVip int64 `json:"is_vip"`
|
||||
Mark int `json:"mark"`
|
||||
Mobile string `json:"mobile"`
|
||||
UserID int `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type QRCodeStatusResp struct {
|
||||
QRCodeBasicResp
|
||||
Data QRCodeStatus `json:"data"`
|
||||
}
|
||||
|
||||
type ShareSnapResp struct {
|
||||
BasicResp
|
||||
Data struct {
|
||||
Userinfo struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
Face string `json:"face"`
|
||||
} `json:"userinfo"`
|
||||
Shareinfo struct {
|
||||
SnapID string `json:"snap_id"`
|
||||
FileSize StringInt64 `json:"file_size"`
|
||||
ShareTitle string `json:"share_title"`
|
||||
ShareState StringInt64 `json:"share_state"`
|
||||
ForbidReason string `json:"forbid_reason"`
|
||||
CreateTime StringInt64 `json:"create_time"`
|
||||
ReceiveCode string `json:"receive_code"`
|
||||
ReceiveCount StringInt64 `json:"receive_count"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FileCategory int64 `json:"file_category"`
|
||||
AutoRenewal StringInt64 `json:"auto_renewal"`
|
||||
ShareDuration int `json:"share_duration"`
|
||||
AutoFillRecvcode StringInt64 `json:"auto_fill_recvcode"`
|
||||
CanReport int `json:"can_report"`
|
||||
CanNotice int `json:"can_notice"`
|
||||
HaveVioFile int `json:"have_vio_file"`
|
||||
SkipLoginState StringInt64 `json:"skip_login_state"`
|
||||
} `json:"shareinfo"`
|
||||
Count int `json:"count"`
|
||||
List []ShareFile `json:"list"`
|
||||
ShareState StringInt64 `json:"share_state"`
|
||||
UserAppeal struct {
|
||||
CanAppeal int `json:"can_appeal"`
|
||||
CanShareAppeal int `json:"can_share_appeal"`
|
||||
PopupAppealPage int `json:"popup_appeal_page"`
|
||||
CanGlobalAppeal int `json:"can_global_appeal"`
|
||||
} `json:"user_appeal"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ShareFile struct {
|
||||
FileID string `json:"fid"`
|
||||
UID int `json:"uid"`
|
||||
CategoryID IntString `json:"cid"`
|
||||
FileName string `json:"n"`
|
||||
Type string `json:"ico"`
|
||||
Sha1 string `json:"sha"`
|
||||
Size StringInt64 `json:"s"`
|
||||
Labels []*LabelInfo `json:"fl"`
|
||||
UpdateTime string `json:"t"`
|
||||
IsFile int `json:"fc"`
|
||||
ParentID string `json:"pid"`
|
||||
// Ns string `json:"ns"`
|
||||
// D int `json:"d"`
|
||||
// C int `json:"c"`
|
||||
// E string `json:"e"`
|
||||
// Ms int `json:"ms"`
|
||||
IsSkipLogin int `json:"is_skip_login"`
|
||||
ThumbURL string `json:"u"`
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
BasicResp
|
||||
Data struct {
|
||||
PickCode string `json:"pick_code"`
|
||||
FileSize int `json:"file_size"`
|
||||
FileID string `json:"file_id"`
|
||||
ThumbURL string `json:"thumb_url"`
|
||||
Sha1 string `json:"sha1"`
|
||||
Aid int `json:"aid"`
|
||||
FileName string `json:"file_name"`
|
||||
Cid string `json:"cid"`
|
||||
IsVideo int `json:"is_video"`
|
||||
} `json:"data"`
|
||||
}
|
||||
type APIGetDirIDResp struct {
|
||||
BasicResp
|
||||
CategoryID IntString `json:"id"`
|
||||
IsPrivate IntString `json:"is_private"`
|
||||
}
|
||||
|
||||
type DownloadShareResp struct {
|
||||
BasicResp
|
||||
Data SharedDownloadInfo `json:"data"`
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SearchOption defines options for search
|
||||
type SearchOption struct {
|
||||
// Offset for pagination
|
||||
Offset int
|
||||
// Limit number of results
|
||||
Limit int
|
||||
// SearchValue search keyword
|
||||
SearchValue string
|
||||
// Date filter
|
||||
Date string
|
||||
// Aid area ID
|
||||
Aid string
|
||||
// Cid category ID
|
||||
Cid string
|
||||
// PickCode pickcode
|
||||
PickCode string
|
||||
// Type file type filter 0:all 1:folder 2:document 3:image 4:video 5:audio 6:archive
|
||||
Type int
|
||||
// CountFolders whether to count folders
|
||||
CountFolders int
|
||||
// Source source filter
|
||||
Source string
|
||||
// Star star file only
|
||||
Star string
|
||||
// Suffix file suffix filter
|
||||
Suffix string
|
||||
// Order sort field
|
||||
Order string
|
||||
// Asc ascending order 0:descending 1:ascending
|
||||
Asc int
|
||||
}
|
||||
|
||||
// SearchFile represents a file in search results
|
||||
type SearchFile struct {
|
||||
// File ID
|
||||
FileID string `json:"fid"`
|
||||
// Category ID
|
||||
CategoryID IntString `json:"cid"`
|
||||
// File name
|
||||
Name string `json:"n"`
|
||||
// File size
|
||||
Size StringInt64 `json:"s"`
|
||||
// SHA1 hash
|
||||
Sha1 string `json:"sha"`
|
||||
// PickCode
|
||||
PickCode string `json:"pc"`
|
||||
// Is directory
|
||||
IsDirectory int `json:"fc"`
|
||||
// Is starred file
|
||||
IsStar StringInt `json:"m"`
|
||||
// Update time
|
||||
UpdateTime string `json:"t"`
|
||||
// Create time
|
||||
CreateTime StringInt64 `json:"tp"`
|
||||
// File type icon
|
||||
Icon string `json:"ico"`
|
||||
// Highlighted file name
|
||||
HighlightName string `json:"ns"`
|
||||
// File labels
|
||||
Labels []*LabelInfo `json:"fl"`
|
||||
// Thumbnail URL
|
||||
ThumbURL string `json:"u"`
|
||||
}
|
||||
|
||||
// SearchResult represents search results
|
||||
type SearchResult struct {
|
||||
// File list
|
||||
Files []File `json:"data"`
|
||||
// Total count
|
||||
Count int `json:"count"`
|
||||
// File count
|
||||
FileCount int `json:"file_count"`
|
||||
// Folder count
|
||||
FolderCount int `json:"folder_count"`
|
||||
// Page size
|
||||
PageSize int `json:"page_size"`
|
||||
// Offset
|
||||
Offset int `json:"offset"`
|
||||
// Sort field
|
||||
Order string `json:"order"`
|
||||
// Ascending order
|
||||
IsAsc int `json:"is_asc"`
|
||||
}
|
||||
|
||||
// Search searches for files using given options
|
||||
func (c *Pan115Client) Search(opts *SearchOption) (*SearchResult, error) {
|
||||
result := FileListResp{}
|
||||
params := map[string]string{
|
||||
"aid": "7",
|
||||
"cid": "0",
|
||||
"format": "json",
|
||||
"offset": "0",
|
||||
"limit": "30",
|
||||
"search_value": "",
|
||||
"type": "0",
|
||||
"count_folders": "1",
|
||||
"o": "file_name",
|
||||
"asc": "1",
|
||||
}
|
||||
|
||||
// Set search parameters
|
||||
if opts != nil {
|
||||
if opts.Offset >= 0 {
|
||||
params["offset"] = strconv.Itoa(opts.Offset)
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
params["limit"] = strconv.Itoa(opts.Limit)
|
||||
}
|
||||
if opts.SearchValue != "" {
|
||||
params["search_value"] = opts.SearchValue
|
||||
}
|
||||
if opts.Date != "" {
|
||||
params["date"] = opts.Date
|
||||
}
|
||||
if opts.Aid != "" {
|
||||
params["aid"] = opts.Aid
|
||||
}
|
||||
if opts.Cid != "" {
|
||||
params["cid"] = opts.Cid
|
||||
}
|
||||
if opts.PickCode != "" {
|
||||
params["pick_code"] = opts.PickCode
|
||||
}
|
||||
if opts.Type > 0 {
|
||||
params["type"] = strconv.Itoa(opts.Type)
|
||||
}
|
||||
if opts.CountFolders > 0 {
|
||||
params["count_folders"] = strconv.Itoa(opts.CountFolders)
|
||||
}
|
||||
if opts.Source != "" {
|
||||
params["source"] = opts.Source
|
||||
}
|
||||
if opts.Star != "" {
|
||||
params["star"] = opts.Star
|
||||
}
|
||||
if opts.Suffix != "" {
|
||||
params["suffix"] = opts.Suffix
|
||||
}
|
||||
if opts.Order != "" {
|
||||
params["o"] = opts.Order
|
||||
}
|
||||
params["asc"] = strconv.Itoa(opts.Asc)
|
||||
}
|
||||
|
||||
req := c.NewRequest().
|
||||
SetQueryParams(params).
|
||||
SetResult(&result).
|
||||
ForceContentType("application/json;charset=UTF-8")
|
||||
|
||||
resp, err := req.Get(ApiFileSearch)
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert results
|
||||
searchResult := &SearchResult{
|
||||
Count: result.Count,
|
||||
FileCount: 0, // Not available in FileListResp
|
||||
FolderCount: 0, // Not available in FileListResp
|
||||
PageSize: result.PageSize,
|
||||
Offset: result.Offset,
|
||||
Order: result.Order,
|
||||
IsAsc: result.IsAsc,
|
||||
Files: make([]File, 0, len(result.Files)),
|
||||
}
|
||||
|
||||
for _, fileInfo := range result.Files {
|
||||
searchResult.Files = append(searchResult.Files, *(&File{}).from(&fileInfo))
|
||||
}
|
||||
|
||||
return searchResult, nil
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Query func(query *map[string]string)
|
||||
|
||||
// QueryLimit set query limit
|
||||
func QueryLimit(limit int) Query {
|
||||
return func(query *map[string]string) {
|
||||
(*query)["limit"] = strconv.FormatInt(int64(limit), 10)
|
||||
}
|
||||
}
|
||||
|
||||
// QueryOffset set query offset
|
||||
func QueryOffset(offset int) Query {
|
||||
return func(query *map[string]string) {
|
||||
(*query)["offset"] = strconv.FormatInt(int64(offset), 10)
|
||||
}
|
||||
}
|
||||
|
||||
// GetShareSnapWithUA get share snap info with user agent
|
||||
func (c *Pan115Client) GetShareSnapWithUA(ua, shareCode, receiveCode, dirID string, Queries ...Query) (*ShareSnapResp, error) {
|
||||
if isCalledByAlistV3() {
|
||||
return nil, ErrorNotSupportAlist
|
||||
}
|
||||
result := ShareSnapResp{}
|
||||
query := map[string]string{
|
||||
"share_code": shareCode,
|
||||
"receive_code": receiveCode,
|
||||
"cid": dirID,
|
||||
"limit": "20",
|
||||
"asc": "0",
|
||||
"offset": "0",
|
||||
"format": "json",
|
||||
}
|
||||
|
||||
for _, q := range Queries {
|
||||
q(&query)
|
||||
}
|
||||
|
||||
req := c.NewRequest().
|
||||
SetQueryParams(query).
|
||||
SetHeader("referer", BuildShareReferer(shareCode, receiveCode)).
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
if ua != "" {
|
||||
req.SetHeader("User-Agent", ua)
|
||||
}
|
||||
resp, err := req.Get(ApiShareSnap)
|
||||
if err := CheckErr(err, &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func BuildShareReferer(shareCode, receiveCode string) string {
|
||||
return fmt.Sprintf("https://115cdn.com/s/%s?password=%s&", shareCode, receiveCode)
|
||||
}
|
||||
|
||||
// GetShareSnap get share snap info
|
||||
func (c *Pan115Client) GetShareSnap(shareCode, receiveCode, dirID string, Queries ...Query) (*ShareSnapResp, error) {
|
||||
return c.GetShareSnapWithUA("", shareCode, receiveCode, dirID, Queries...)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StringInt uses for json field which maybe a string or an int.
|
||||
type StringInt int64
|
||||
|
||||
func (v *StringInt) UnmarshalJSON(b []byte) (err error) {
|
||||
var i int
|
||||
if b[0] == '"' {
|
||||
var s string
|
||||
if err = json.Unmarshal(b, &s); err == nil {
|
||||
i, _ = strconv.Atoi(s)
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(b, &i)
|
||||
}
|
||||
if err == nil {
|
||||
*v = StringInt(i)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// StringInt64 uses for json field which maybe a string or an int64.
|
||||
type StringInt64 int64
|
||||
|
||||
func (v *StringInt64) UnmarshalJSON(b []byte) (err error) {
|
||||
var i int64
|
||||
if b[0] == '"' {
|
||||
var s string
|
||||
if err = json.Unmarshal(b, &s); err == nil {
|
||||
i, err = strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(b, &i)
|
||||
}
|
||||
if err == nil {
|
||||
*v = StringInt64(i)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// StringFloat64 uses for json field which maybe a string or a float64.
|
||||
type StringFloat64 float64
|
||||
|
||||
func (v *StringFloat64) UnmarshalJSON(b []byte) (err error) {
|
||||
var f float64
|
||||
if b[0] == '"' {
|
||||
var s string
|
||||
if err = json.Unmarshal(b, &s); err == nil {
|
||||
f, err = strconv.ParseFloat(s, 64)
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(b, &f)
|
||||
}
|
||||
if err == nil {
|
||||
*v = StringFloat64(f)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type IntString string
|
||||
|
||||
func (v *IntString) UnmarshalJSON(b []byte) (err error) {
|
||||
var s string
|
||||
if b[0] == '"' {
|
||||
err = json.Unmarshal(b, &s)
|
||||
} else {
|
||||
var i int64
|
||||
if err = json.Unmarshal(b, &i); err == nil {
|
||||
s = strconv.FormatInt(i, 10)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
*v = IntString(s)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type BoolInt int
|
||||
|
||||
func (v *BoolInt) UnmarshalJSON(b []byte) (err error) {
|
||||
if b[0] == 'f' || b[0] == 'F' {
|
||||
*v = -1
|
||||
} else {
|
||||
var i int
|
||||
if err = json.Unmarshal(b, &i); err == nil {
|
||||
*v = BoolInt(i)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func BoolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type StringTime int64
|
||||
|
||||
func (v *StringTime) UnmarshalJSON(b []byte) (err error) {
|
||||
var t time.Time
|
||||
if b[0] == '"' {
|
||||
var s string
|
||||
if err = json.Unmarshal(b, &s); err == nil {
|
||||
t, err = time.Parse("2006-01-02 15:04", s)
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(b, &t)
|
||||
}
|
||||
if err == nil {
|
||||
*v = StringTime(t.Unix())
|
||||
}
|
||||
return
|
||||
}
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
hash "github.com/SheltonZhu/115driver/pkg/crypto"
|
||||
cipher "github.com/SheltonZhu/115driver/pkg/crypto/ec115"
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// GetDigestResult get digest of file or stream
|
||||
func (c *Pan115Client) GetDigestResult(r io.Reader) (*hash.DigestResult, error) {
|
||||
d := hash.DigestResult{}
|
||||
return &d, hash.Digest(r, &d)
|
||||
}
|
||||
|
||||
// GetUploadEndpoint get upload endPoint
|
||||
func (c *Pan115Client) GetUploadEndpoint(endpoint *UploadEndpointResp) error {
|
||||
req := c.NewRequest().
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&endpoint)
|
||||
_, err := req.Get(ApiGetUploadEndpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUploadInfo get some info for upload
|
||||
func (c *Pan115Client) GetUploadInfo() error {
|
||||
result := UploadInfoResp{}
|
||||
req := c.NewRequest().
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
resp, err := req.Post(ApiUploadInfo)
|
||||
if err = CheckErr(err, &result, resp); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Userkey = result.Userkey
|
||||
c.UserID = result.UserID
|
||||
c.UploadMetaInfo = &result.UploadMetaInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadAvailable check and prepare to upload
|
||||
func (c *Pan115Client) UploadAvailable() (bool, error) {
|
||||
if c.UserID != 0 && len(c.Userkey) > 0 {
|
||||
return true, nil
|
||||
}
|
||||
if err := c.GetUploadInfo(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// UploadFastOrByOSS Upload By OSS when unable to rapid upload file
|
||||
// Deprecated: As of v1.0.22, this function simply calls [RapidUploadOrByOSS].
|
||||
func (c *Pan115Client) UploadFastOrByOSS(dirID, fileName string, fileSize int64, r io.ReadSeeker) error {
|
||||
return c.RapidUploadOrByOSS(dirID, fileName, fileSize, r)
|
||||
}
|
||||
|
||||
// RapidUploadOrByOSS Upload By OSS when unable to rapid upload file
|
||||
func (c *Pan115Client) RapidUploadOrByOSS(dirID, fileName string, fileSize int64, r io.ReadSeeker) error {
|
||||
var (
|
||||
err error
|
||||
digest *hash.DigestResult
|
||||
fastInfo *UploadInitResp
|
||||
)
|
||||
|
||||
if ok, err := c.UploadAvailable(); err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if fileSize > c.UploadMetaInfo.SizeLimit {
|
||||
return ErrUploadTooLarge
|
||||
}
|
||||
if digest, err = c.GetDigestResult(r); err != nil {
|
||||
return err
|
||||
}
|
||||
// 闪传
|
||||
if fastInfo, err = c.RapidUpload(
|
||||
digest.Size, fileName, dirID, digest.PreID, digest.QuickID, r,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, err := fastInfo.Ok(); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
return nil
|
||||
}
|
||||
if _, err = r.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
// 闪传失败,普通上传
|
||||
return c.UploadByOSS(&fastInfo.UploadOSSParams, r, dirID)
|
||||
}
|
||||
|
||||
// getOSSEndpoint get oss endpoint 利用阿里云内网上传文件,需要在阿里云服务器上运行本程序,同时也需要115在服务器的所在地域开通了阿里云OSS
|
||||
func (c *Pan115Client) getOSSEndpoint(enableInternalUpload bool) string {
|
||||
if enableInternalUpload {
|
||||
uploadEndpoint := UploadEndpointResp{}
|
||||
if err := c.GetUploadEndpoint(&uploadEndpoint); err != nil {
|
||||
// TODO warn error log
|
||||
return OSSEndpoint
|
||||
}
|
||||
i := strings.Index(uploadEndpoint.Endpoint, ".aliyuncs.com")
|
||||
if i > -1 {
|
||||
endpoint := uploadEndpoint.Endpoint[:i] + "-internal" + uploadEndpoint.Endpoint[i:]
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
return OSSEndpoint
|
||||
}
|
||||
|
||||
// GetOSSEndpoint get oss endpoint 利用阿里云内网上传文件,需要在阿里云服务器上运行本程序,同时也需要115在服务器的所在地域开通了阿里云OSS
|
||||
func (c *Pan115Client) GetOSSEndpoint(enableInternalUpload bool) string {
|
||||
return c.getOSSEndpoint(enableInternalUpload)
|
||||
}
|
||||
|
||||
// UploadByOSS use aliyun sdk to upload
|
||||
func (c *Pan115Client) UploadByOSS(params *UploadOSSParams, r io.Reader, dirID string) error {
|
||||
ossToken, err := c.GetOSSToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ossClient, err := oss.New(c.getOSSEndpoint(c.UseInternalUpload), ossToken.AccessKeyID, ossToken.AccessKeySecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket, err := ossClient.Bucket(params.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = bucket.PutObject(params.Object, r, OssOption(params, ossToken)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.checkUploadStatus(dirID, params.SHA1)
|
||||
}
|
||||
|
||||
func (c *Pan115Client) checkUploadStatus(dirID, sha1 string) error {
|
||||
// 验证上传是否成功
|
||||
req := c.NewRequest().ForceContentType("application/json;charset=UTF-8")
|
||||
opts := []GetFileOptions{
|
||||
WithOrder(FileOrderByTime),
|
||||
WithShowDirEnable(false),
|
||||
WithAsc(false),
|
||||
WithLimit(500),
|
||||
}
|
||||
fResp, err := GetFiles(req, dirID, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fileInfo := range fResp.Files {
|
||||
if fileInfo.Sha1 == sha1 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrUploadFailed
|
||||
}
|
||||
|
||||
// GetOSSToken get oss token for oss upload
|
||||
func (c *Pan115Client) GetOSSToken() (*UploadOSSTokenResp, error) {
|
||||
result := UploadOSSTokenResp{}
|
||||
req := c.NewRequest().
|
||||
ForceContentType("application/json;charset=UTF-8").
|
||||
SetResult(&result)
|
||||
|
||||
resp, err := req.Get(ApiUploadOSSToken)
|
||||
return &result, CheckErr(err, &result, resp)
|
||||
}
|
||||
|
||||
// UploadSHA1 upload a sha1, alias of RapidUpload
|
||||
// Deprecated: As of v1.0.22, this function simply calls [RapidUpload].
|
||||
func (c *Pan115Client) UploadSHA1(fileSize int64, fileName, dirID, preID, fileID string, r io.ReadSeeker) (*UploadInitResp, error) {
|
||||
return c.RapidUpload(fileSize, fileName, dirID, preID, fileID, r)
|
||||
}
|
||||
|
||||
// RapidUpload rapid upload
|
||||
func (c *Pan115Client) RapidUpload(fileSize int64, fileName, dirID, preID, fileID string, r io.ReadSeeker) (*UploadInitResp, error) {
|
||||
var (
|
||||
ecdhCipher *cipher.EcdhCipher
|
||||
encrypted []byte
|
||||
decrypted []byte
|
||||
encodedToken string
|
||||
err error
|
||||
target = "U_1_" + dirID
|
||||
bodyBytes []byte
|
||||
result = UploadInitResp{}
|
||||
fileSizeStr = strconv.FormatInt(fileSize, 10)
|
||||
)
|
||||
if ecdhCipher, err = cipher.NewEcdhCipher(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ok, err := c.UploadAvailable(); !ok || err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID := strconv.FormatInt(c.UserID, 10)
|
||||
form := url.Values{}
|
||||
form.Set("appid", "0")
|
||||
form.Set("appversion", appVer)
|
||||
form.Set("userid", userID)
|
||||
form.Set("filename", fileName)
|
||||
form.Set("filesize", fileSizeStr)
|
||||
form.Set("fileid", fileID)
|
||||
form.Set("target", target)
|
||||
form.Set("sig", c.GenerateSignature(fileID, target))
|
||||
form.Set("topupload", "true")
|
||||
|
||||
signKey, signVal := "", ""
|
||||
for retry := true; retry; {
|
||||
t := NowMilli()
|
||||
|
||||
if encodedToken, err = ecdhCipher.EncodeToken(t.ToInt64()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := map[string]string{
|
||||
"k_ec": encodedToken,
|
||||
}
|
||||
|
||||
form.Set("t", t.String())
|
||||
form.Set("token", c.GenerateToken(fileID, preID, t.String(), fileSizeStr, signKey, signVal))
|
||||
if signKey != "" && signVal != "" {
|
||||
form.Set("sign_key", signKey)
|
||||
form.Set("sign_val", signVal)
|
||||
}
|
||||
if encrypted, err = ecdhCipher.Encrypt([]byte(form.Encode())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := c.NewRequest().
|
||||
SetQueryParams(params).
|
||||
SetBody(encrypted).
|
||||
SetHeaderVerbatim("Content-Type", "application/x-www-form-urlencoded").
|
||||
SetDoNotParseResponse(true)
|
||||
resp, err := req.Post(ApiUploadInit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := resp.RawBody()
|
||||
defer data.Close()
|
||||
if bodyBytes, err = io.ReadAll(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decrypted, err = ecdhCipher.Decrypt(bodyBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = CheckErr(json.Unmarshal(decrypted, &result), &result, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Status == 7 {
|
||||
// Update signKey & signVal
|
||||
signKey = result.SignKey
|
||||
signVal, _ = c.UploadDigestRange(r, result.SignCheck)
|
||||
} else {
|
||||
retry = false
|
||||
}
|
||||
result.SHA1 = fileID
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
const (
|
||||
md5Salt = "Qclm8MGWUv59TnrR0XPg"
|
||||
appVer = "27.0.5.7"
|
||||
)
|
||||
|
||||
func (c *Pan115Client) UploadDigestRange(r io.ReadSeeker, rangeSpec string) (result string, err error) {
|
||||
var start, end int64
|
||||
if _, err = fmt.Sscanf(rangeSpec, "%d-%d", &start, &end); err != nil {
|
||||
return
|
||||
}
|
||||
h := sha1.New()
|
||||
_, err = r.Seek(start, io.SeekStart)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = io.CopyN(h, r, end-start+1); err == nil {
|
||||
result = strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Pan115Client) GenerateSignature(fileID, target string) string {
|
||||
sh1hash := sha1.Sum([]byte(strconv.FormatInt(c.UserID, 10) + fileID + target + "0"))
|
||||
sigStr := c.Userkey + hex.EncodeToString(sh1hash[:]) + "000000"
|
||||
sh1Sig := sha1.Sum([]byte(sigStr))
|
||||
return strings.ToUpper(hex.EncodeToString(sh1Sig[:]))
|
||||
}
|
||||
|
||||
func (c *Pan115Client) GenerateToken(fileID, preID, timeStamp, fileSize, signKey, signVal string) string {
|
||||
userID := strconv.FormatInt(c.UserID, 10)
|
||||
userIDMd5 := md5.Sum([]byte(userID))
|
||||
tokenMd5 := md5.Sum([]byte(md5Salt + fileID + fileSize + signKey + signVal + userID + timeStamp + hex.EncodeToString(userIDMd5[:]) + appVer))
|
||||
return hex.EncodeToString(tokenMd5[:])
|
||||
}
|
||||
|
||||
// UploadFastOrByMultipart upload by mutipart blocks when unable to rapid upload
|
||||
// Deprecated: As of v1.0.22, this function simply calls [RapidUploadOrByMultipart].
|
||||
func (c *Pan115Client) UploadFastOrByMultipart(dirID, fileName string, fileSize int64, r *os.File, opts ...UploadMultipartOption) error {
|
||||
return c.RapidUploadOrByMultipart(dirID, fileName, fileSize, r, opts...)
|
||||
}
|
||||
|
||||
// RapidUploadOrByMultipart upload by mutipart blocks when unable to rapid upload
|
||||
func (c *Pan115Client) RapidUploadOrByMultipart(dirID, fileName string, fileSize int64, r *os.File, opts ...UploadMultipartOption) error {
|
||||
var (
|
||||
err error
|
||||
digest *hash.DigestResult
|
||||
fastInfo *UploadInitResp
|
||||
)
|
||||
|
||||
if ok, err := c.UploadAvailable(); err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if fileSize > c.UploadMetaInfo.SizeLimit {
|
||||
return ErrUploadTooLarge
|
||||
}
|
||||
if digest, err = c.GetDigestResult(r); err != nil {
|
||||
return err
|
||||
}
|
||||
// 闪传
|
||||
if fastInfo, err = c.RapidUpload(
|
||||
digest.Size, fileName, dirID, digest.PreID, digest.QuickID, r,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, err := fastInfo.Ok(); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
return nil
|
||||
}
|
||||
if _, err = r.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 闪传失败,上传
|
||||
if digest.Size <= KB { // 文件大小小于1KB,改用普通模式上传
|
||||
return c.UploadByOSS(&fastInfo.UploadOSSParams, r, dirID)
|
||||
}
|
||||
// 分片上传
|
||||
return c.UploadByMultipart(&fastInfo.UploadOSSParams, digest.Size, r, dirID, opts...)
|
||||
}
|
||||
|
||||
// UploadByMultipart upload by mutipart blocks
|
||||
func (c *Pan115Client) UploadByMultipart(params *UploadOSSParams, fileSize int64, f *os.File, dirID string, opts ...UploadMultipartOption) error {
|
||||
var (
|
||||
chunks []oss.FileChunk
|
||||
parts []oss.UploadPart
|
||||
imur oss.InitiateMultipartUploadResult
|
||||
ossClient *oss.Client
|
||||
bucket *oss.Bucket
|
||||
ossToken *UploadOSSTokenResp
|
||||
bodyBytes []byte
|
||||
err error
|
||||
)
|
||||
|
||||
options := DefaultUploadMultipartOptions()
|
||||
if len(opts) > 0 {
|
||||
for _, f := range opts {
|
||||
f(options)
|
||||
}
|
||||
}
|
||||
|
||||
options.ThreadsNum = 1
|
||||
if ossToken, err = c.GetOSSToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ossClient, err = oss.New(
|
||||
c.getOSSEndpoint(c.UseInternalUpload),
|
||||
ossToken.AccessKeyID,
|
||||
ossToken.AccessKeySecret,
|
||||
oss.EnableMD5(true),
|
||||
oss.EnableCRC(true),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bucket, err = ossClient.Bucket(params.Bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ossToken一小时后就会失效,所以每50分钟重新获取一次
|
||||
ticker := time.NewTicker(options.TokenRefreshTime)
|
||||
defer ticker.Stop()
|
||||
// 设置超时
|
||||
timeout := time.NewTimer(options.Timeout)
|
||||
|
||||
if chunks, err = SplitFile(f.Name(), fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if imur, err = bucket.InitiateMultipartUpload(params.Object,
|
||||
oss.SetHeader(OssSecurityTokenHeaderName, ossToken.SecurityToken),
|
||||
oss.UserAgentHeader(OSSUserAgent),
|
||||
oss.EnableSha1(),
|
||||
oss.Sequential(), // oss 启用Sequential必须按顺序上传, options.ThreadsNum = 1
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(chunks))
|
||||
|
||||
chunksCh := make(chan oss.FileChunk)
|
||||
errCh := make(chan error)
|
||||
UploadedPartsCh := make(chan oss.UploadPart)
|
||||
quit := make(chan struct{})
|
||||
|
||||
// producter
|
||||
go chunksProducer(chunksCh, chunks)
|
||||
go func() {
|
||||
wg.Wait()
|
||||
quit <- struct{}{}
|
||||
}()
|
||||
|
||||
// consumers
|
||||
for i := 0; i < options.ThreadsNum; i++ {
|
||||
go func(threadId int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errCh <- fmt.Errorf("recovered in %v", r)
|
||||
}
|
||||
}()
|
||||
for chunk := range chunksCh {
|
||||
var part oss.UploadPart // 出现错误就继续尝试,共尝试3次
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if ossToken, err = c.GetOSSToken(); err != nil { // 到时重新获取ossToken
|
||||
errCh <- errors.Wrap(err, "刷新token时出现错误")
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
buf := make([]byte, chunk.Size)
|
||||
if _, err = f.ReadAt(buf, chunk.Offset); err != nil && !errors.Is(err, io.EOF) {
|
||||
continue
|
||||
}
|
||||
|
||||
if part, err = bucket.UploadPart(
|
||||
imur,
|
||||
bytes.NewBuffer(buf),
|
||||
chunk.Size,
|
||||
chunk.Number,
|
||||
OssOption(params, ossToken)...); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- errors.Wrap(err, fmt.Sprintf("上传 %s 的第%d个分片时出现错误:%v", f.Name(), chunk.Number, err))
|
||||
}
|
||||
UploadedPartsCh <- part
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for part := range UploadedPartsCh {
|
||||
parts = append(parts, part)
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
LOOP:
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 到时重新获取ossToken
|
||||
if ossToken, err = c.GetOSSToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-quit:
|
||||
break LOOP
|
||||
case <-errCh:
|
||||
return err
|
||||
case <-timeout.C:
|
||||
return fmt.Errorf("time out")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := bucket.CompleteMultipartUpload(imur, parts,
|
||||
append(
|
||||
OssOption(params, ossToken),
|
||||
oss.CallbackResult(&bodyBytes),
|
||||
)...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var uploadResult UploadResult
|
||||
if err = json.Unmarshal(bodyBytes, &uploadResult); err != nil {
|
||||
return err
|
||||
}
|
||||
return uploadResult.Err(string(bodyBytes))
|
||||
}
|
||||
|
||||
func chunksProducer(ch chan oss.FileChunk, chunks []oss.FileChunk) {
|
||||
for _, chunk := range chunks {
|
||||
ch <- chunk
|
||||
}
|
||||
}
|
||||
|
||||
// SplitFile pplitFile
|
||||
func SplitFile(filePath string, fileSize int64) (chunks []oss.FileChunk, err error) {
|
||||
for i := int64(1); i < 10; i++ {
|
||||
if fileSize < i*GB { // 文件大小小于iGB时分为i*1000片
|
||||
if chunks, err = oss.SplitFileByPartNum(filePath, int(i*1000)); err != nil {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if fileSize > 9*GB { // 文件大小大于9GB时分为10000片
|
||||
if chunks, err = oss.SplitFileByPartNum(filePath, 10000); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 单个分片大小不能小于100KB
|
||||
if chunks[0].Size < 100*KB {
|
||||
if chunks, err = oss.SplitFileByPartSize(filePath, 100*KB); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// OssOption get options
|
||||
func OssOption(params *UploadOSSParams, ossToken *UploadOSSTokenResp) []oss.Option {
|
||||
options := []oss.Option{
|
||||
oss.SetHeader(OssSecurityTokenHeaderName, ossToken.SecurityToken),
|
||||
oss.Callback(base64.StdEncoding.EncodeToString([]byte(params.Callback.Callback))),
|
||||
oss.CallbackVar(base64.StdEncoding.EncodeToString([]byte(params.Callback.CallbackVar))),
|
||||
oss.UserAgentHeader(OSSUserAgent),
|
||||
}
|
||||
return options
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Time int64
|
||||
|
||||
func Now() Time {
|
||||
return Time(time.Now().Unix())
|
||||
}
|
||||
|
||||
func NowMilli() Time {
|
||||
return Time(time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func (t Time) String() string {
|
||||
return strconv.FormatInt(t.ToInt64(), 10)
|
||||
}
|
||||
|
||||
func (t Time) ToInt64() int64 {
|
||||
return int64(t)
|
||||
}
|
||||
|
||||
func Date() string {
|
||||
GMT, _ := time.LoadLocation("GMT")
|
||||
now := time.Now().In(GMT)
|
||||
return now.Format(time.RFC1123)
|
||||
}
|
||||
|
||||
func isCalledByAlistV3() bool {
|
||||
pc, _, _, ok := runtime.Caller(3)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
funcName := runtime.FuncForPC(pc).Name()
|
||||
return strings.Contains(funcName, "alist")
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
.vscode
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.11.x
|
||||
- master
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
script:
|
||||
- diff -au <(gofmt -d .) <(printf "")
|
||||
- go test -v ./...
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Andreas Auernhammer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
[](https://godoc.org/github.com/aead/ecdh)
|
||||
[](https://travis-ci.org/aead/ecdh)
|
||||
|
||||
## The ECDH key exchange
|
||||
|
||||
Elliptic curve Diffie–Hellman (ECDH) is an anonymous key agreement protocol that allows two parties,
|
||||
each having an elliptic curve public–private key pair, to establish a shared secret over an insecure channel.
|
||||
|
||||
This package implements a generic interface for ECDH and supports the generic [crypto/elliptic](https://godoc.org/crypto/elliptic)
|
||||
and the [x/crypto/curve25519](https://godoc.org/golang.org/x/crypto/curve25519) out of the box.
|
||||
|
||||
### Installation
|
||||
Install in your GOPATH: `go get -u github.com/aead/ecdh`
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2016 Andreas Auernhammer. All rights reserved.
|
||||
// Use of this source code is governed by a license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
package ecdh
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
type ecdh25519 struct{}
|
||||
|
||||
var curve25519Params = CurveParams{
|
||||
Name: "Curve25519",
|
||||
BitSize: 255,
|
||||
}
|
||||
|
||||
// X25519 creates a new ecdh.KeyExchange with
|
||||
// the elliptic curve Curve25519.
|
||||
func X25519() KeyExchange {
|
||||
return ecdh25519{}
|
||||
}
|
||||
|
||||
func (ecdh25519) GenerateKey(random io.Reader) (private crypto.PrivateKey, public crypto.PublicKey, err error) {
|
||||
if random == nil {
|
||||
random = rand.Reader
|
||||
}
|
||||
|
||||
var pri, pub [32]byte
|
||||
_, err = io.ReadFull(random, pri[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// From https://cr.yp.to/ecdh.html
|
||||
pri[0] &= 248
|
||||
pri[31] &= 127
|
||||
pri[31] |= 64
|
||||
|
||||
curve25519.ScalarBaseMult(&pub, &pri)
|
||||
|
||||
private = pri
|
||||
public = pub
|
||||
return
|
||||
}
|
||||
|
||||
func (ecdh25519) Params() CurveParams { return curve25519Params }
|
||||
|
||||
func (ecdh25519) PublicKey(private crypto.PrivateKey) (public crypto.PublicKey) {
|
||||
var pri, pub [32]byte
|
||||
if ok := checkType(&pri, private); !ok {
|
||||
panic("ecdh: unexpected type of private key")
|
||||
}
|
||||
|
||||
curve25519.ScalarBaseMult(&pub, &pri)
|
||||
|
||||
public = pub
|
||||
return
|
||||
}
|
||||
|
||||
func (ecdh25519) Check(peersPublic crypto.PublicKey) (err error) {
|
||||
if ok := checkType(new([32]byte), peersPublic); !ok {
|
||||
err = errors.New("unexptected type of peers public key")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (ecdh25519) ComputeSecret(private crypto.PrivateKey, peersPublic crypto.PublicKey) (secret []byte) {
|
||||
var sec, pri, pub [32]byte
|
||||
if ok := checkType(&pri, private); !ok {
|
||||
panic("ecdh: unexpected type of private key")
|
||||
}
|
||||
if ok := checkType(&pub, peersPublic); !ok {
|
||||
panic("ecdh: unexpected type of peers public key")
|
||||
}
|
||||
|
||||
curve25519.ScalarMult(&sec, &pri, &pub)
|
||||
|
||||
secret = sec[:]
|
||||
return
|
||||
}
|
||||
|
||||
func checkType(key *[32]byte, typeToCheck interface{}) (ok bool) {
|
||||
switch t := typeToCheck.(type) {
|
||||
case [32]byte:
|
||||
copy(key[:], t[:])
|
||||
ok = true
|
||||
case *[32]byte:
|
||||
copy(key[:], t[:])
|
||||
ok = true
|
||||
case []byte:
|
||||
if len(t) == 32 {
|
||||
copy(key[:], t)
|
||||
ok = true
|
||||
}
|
||||
case *[]byte:
|
||||
if len(*t) == 32 {
|
||||
copy(key[:], *t)
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2016 Andreas Auernhammer. All rights reserved.
|
||||
// Use of this source code is governed by a license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
// Package ecdh implements the Diffie-Hellman key exchange
|
||||
// using elliptic curves (ECDH). It directly provides ECDH
|
||||
// implementations for the NIST curves P224, P256, P384,
|
||||
// and Bernstein's Cruve25519.
|
||||
//
|
||||
// For generic curves this implementation of ECDH
|
||||
// only uses the x-coordinate as the computed secret.
|
||||
package ecdh // import "github.com/aead/ecdh"
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"io"
|
||||
)
|
||||
|
||||
// KeyExchange is the interface defining all functions
|
||||
// necessary for ECDH.
|
||||
type KeyExchange interface {
|
||||
// GenerateKey generates a private/public key pair using entropy from rand.
|
||||
// If rand is nil, crypto/rand.Reader will be used.
|
||||
GenerateKey(rand io.Reader) (private crypto.PrivateKey, public crypto.PublicKey, err error)
|
||||
|
||||
// Params returns the curve parameters - like the field size.
|
||||
Params() CurveParams
|
||||
|
||||
// PublicKey returns the public key corresponding to the given private one.
|
||||
PublicKey(private crypto.PrivateKey) (public crypto.PublicKey)
|
||||
|
||||
// Check returns a non-nil error if the peers public key cannot used for the
|
||||
// key exchange - for instance the public key isn't a point on the elliptic curve.
|
||||
// It's recommended to check peer's public key before computing the secret.
|
||||
Check(peersPublic crypto.PublicKey) (err error)
|
||||
|
||||
// ComputeSecret returns the secret value computed from the given private key
|
||||
// and the peers public key.
|
||||
ComputeSecret(private crypto.PrivateKey, peersPublic crypto.PublicKey) (secret []byte)
|
||||
}
|
||||
|
||||
// CurveParams contains the parameters of an elliptic curve.
|
||||
type CurveParams struct {
|
||||
Name string // the canonical name of the curve
|
||||
BitSize int // the size of the underlying field
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2016 Andreas Auernhammer. All rights reserved.
|
||||
// Use of this source code is governed by a license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
package ecdh
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Point represents a generic elliptic curve Point with a
|
||||
// X and a Y coordinate.
|
||||
type Point struct {
|
||||
X, Y *big.Int
|
||||
}
|
||||
|
||||
// Generic creates a new ecdh.KeyExchange with
|
||||
// generic elliptic.Curve implementations.
|
||||
func Generic(c elliptic.Curve) KeyExchange {
|
||||
if c == nil {
|
||||
panic("ecdh: curve is nil")
|
||||
}
|
||||
return genericCurve{curve: c}
|
||||
}
|
||||
|
||||
type genericCurve struct {
|
||||
curve elliptic.Curve
|
||||
}
|
||||
|
||||
func (g genericCurve) GenerateKey(random io.Reader) (private crypto.PrivateKey, public crypto.PublicKey, err error) {
|
||||
if random == nil {
|
||||
random = rand.Reader
|
||||
}
|
||||
private, x, y, err := elliptic.GenerateKey(g.curve, random)
|
||||
if err != nil {
|
||||
private = nil
|
||||
return
|
||||
}
|
||||
public = Point{X: x, Y: y}
|
||||
return
|
||||
}
|
||||
|
||||
func (g genericCurve) Params() CurveParams {
|
||||
p := g.curve.Params()
|
||||
return CurveParams{
|
||||
Name: p.Name,
|
||||
BitSize: p.BitSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (g genericCurve) PublicKey(private crypto.PrivateKey) (public crypto.PublicKey) {
|
||||
key, ok := checkPrivateKey(private)
|
||||
if !ok {
|
||||
panic("ecdh: unexpected type of private key")
|
||||
}
|
||||
|
||||
N := g.curve.Params().N
|
||||
if new(big.Int).SetBytes(key).Cmp(N) >= 0 {
|
||||
panic("ecdh: private key cannot used with given curve")
|
||||
}
|
||||
|
||||
x, y := g.curve.ScalarBaseMult(key)
|
||||
public = Point{X: x, Y: y}
|
||||
return
|
||||
}
|
||||
|
||||
func (g genericCurve) Check(peersPublic crypto.PublicKey) (err error) {
|
||||
key, ok := checkPublicKey(peersPublic)
|
||||
if !ok {
|
||||
err = errors.New("unexpected type of peers public key")
|
||||
}
|
||||
if !g.curve.IsOnCurve(key.X, key.Y) {
|
||||
err = errors.New("peer's public key is not on curve")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (g genericCurve) ComputeSecret(private crypto.PrivateKey, peersPublic crypto.PublicKey) (secret []byte) {
|
||||
priKey, ok := checkPrivateKey(private)
|
||||
if !ok {
|
||||
panic("ecdh: unexpected type of private key")
|
||||
}
|
||||
pubKey, ok := checkPublicKey(peersPublic)
|
||||
if !ok {
|
||||
panic("ecdh: unexpected type of peers public key")
|
||||
}
|
||||
|
||||
sX, _ := g.curve.ScalarMult(pubKey.X, pubKey.Y, priKey)
|
||||
|
||||
secret = sX.Bytes()
|
||||
return
|
||||
}
|
||||
|
||||
func checkPrivateKey(typeToCheck interface{}) (key []byte, ok bool) {
|
||||
switch t := typeToCheck.(type) {
|
||||
case []byte:
|
||||
key = t
|
||||
ok = true
|
||||
case *[]byte:
|
||||
key = *t
|
||||
ok = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func checkPublicKey(typeToCheck interface{}) (key Point, ok bool) {
|
||||
switch t := typeToCheck.(type) {
|
||||
case Point:
|
||||
key = t
|
||||
ok = true
|
||||
case *Point:
|
||||
key = *t
|
||||
ok = true
|
||||
}
|
||||
return
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
Copyright (c) 2015 aliyun.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
||||
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// headerSorter defines the key-value structure for storing the sorted data in signHeader.
|
||||
type headerSorter struct {
|
||||
Keys []string
|
||||
Vals []string
|
||||
}
|
||||
|
||||
// getAdditionalHeaderKeys get exist key in http header
|
||||
func (conn Conn) getAdditionalHeaderKeys(req *http.Request) ([]string, map[string]string) {
|
||||
var keysList []string
|
||||
keysMap := make(map[string]string)
|
||||
srcKeys := make(map[string]string)
|
||||
|
||||
for k := range req.Header {
|
||||
srcKeys[strings.ToLower(k)] = ""
|
||||
}
|
||||
|
||||
for _, v := range conn.config.AdditionalHeaders {
|
||||
if _, ok := srcKeys[strings.ToLower(v)]; ok {
|
||||
keysMap[strings.ToLower(v)] = ""
|
||||
}
|
||||
}
|
||||
|
||||
for k := range keysMap {
|
||||
keysList = append(keysList, k)
|
||||
}
|
||||
sort.Strings(keysList)
|
||||
return keysList, keysMap
|
||||
}
|
||||
|
||||
// getAdditionalHeaderKeysV4 get exist key in http header
|
||||
func (conn Conn) getAdditionalHeaderKeysV4(req *http.Request) ([]string, map[string]string) {
|
||||
var keysList []string
|
||||
keysMap := make(map[string]string)
|
||||
srcKeys := make(map[string]string)
|
||||
|
||||
for k := range req.Header {
|
||||
srcKeys[strings.ToLower(k)] = ""
|
||||
}
|
||||
|
||||
for _, v := range conn.config.AdditionalHeaders {
|
||||
if _, ok := srcKeys[strings.ToLower(v)]; ok {
|
||||
if !strings.EqualFold(v, HTTPHeaderContentMD5) && !strings.EqualFold(v, HTTPHeaderContentType) {
|
||||
keysMap[strings.ToLower(v)] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k := range keysMap {
|
||||
keysList = append(keysList, k)
|
||||
}
|
||||
sort.Strings(keysList)
|
||||
return keysList, keysMap
|
||||
}
|
||||
|
||||
// signHeader signs the header and sets it as the authorization header.
|
||||
func (conn Conn) signHeader(req *http.Request, canonicalizedResource string, credentials Credentials) {
|
||||
akIf := credentials
|
||||
authorizationStr := ""
|
||||
if conn.config.AuthVersion == AuthV4 {
|
||||
strDay := ""
|
||||
strDate := req.Header.Get(HttpHeaderOssDate)
|
||||
if strDate == "" {
|
||||
strDate = req.Header.Get(HTTPHeaderDate)
|
||||
t, _ := time.Parse(http.TimeFormat, strDate)
|
||||
strDay = t.Format("20060102")
|
||||
} else {
|
||||
t, _ := time.Parse(timeFormatV4, strDate)
|
||||
strDay = t.Format("20060102")
|
||||
}
|
||||
signHeaderProduct := conn.config.GetSignProduct()
|
||||
signHeaderRegion := conn.config.GetSignRegion()
|
||||
|
||||
additionalList, _ := conn.getAdditionalHeaderKeysV4(req)
|
||||
if len(additionalList) > 0 {
|
||||
authorizationFmt := "OSS4-HMAC-SHA256 Credential=%v/%v/%v/" + signHeaderProduct + "/aliyun_v4_request,AdditionalHeaders=%v,Signature=%v"
|
||||
additionnalHeadersStr := strings.Join(additionalList, ";")
|
||||
authorizationStr = fmt.Sprintf(authorizationFmt, akIf.GetAccessKeyID(), strDay, signHeaderRegion, additionnalHeadersStr, conn.getSignedStrV4(req, canonicalizedResource, akIf.GetAccessKeySecret(), nil))
|
||||
} else {
|
||||
authorizationFmt := "OSS4-HMAC-SHA256 Credential=%v/%v/%v/" + signHeaderProduct + "/aliyun_v4_request,Signature=%v"
|
||||
authorizationStr = fmt.Sprintf(authorizationFmt, akIf.GetAccessKeyID(), strDay, signHeaderRegion, conn.getSignedStrV4(req, canonicalizedResource, akIf.GetAccessKeySecret(), nil))
|
||||
}
|
||||
} else if conn.config.AuthVersion == AuthV2 {
|
||||
additionalList, _ := conn.getAdditionalHeaderKeys(req)
|
||||
if len(additionalList) > 0 {
|
||||
authorizationFmt := "OSS2 AccessKeyId:%v,AdditionalHeaders:%v,Signature:%v"
|
||||
additionnalHeadersStr := strings.Join(additionalList, ";")
|
||||
authorizationStr = fmt.Sprintf(authorizationFmt, akIf.GetAccessKeyID(), additionnalHeadersStr, conn.getSignedStr(req, canonicalizedResource, akIf.GetAccessKeySecret()))
|
||||
} else {
|
||||
authorizationFmt := "OSS2 AccessKeyId:%v,Signature:%v"
|
||||
authorizationStr = fmt.Sprintf(authorizationFmt, akIf.GetAccessKeyID(), conn.getSignedStr(req, canonicalizedResource, akIf.GetAccessKeySecret()))
|
||||
}
|
||||
} else {
|
||||
// Get the final authorization string
|
||||
authorizationStr = "OSS " + akIf.GetAccessKeyID() + ":" + conn.getSignedStr(req, canonicalizedResource, akIf.GetAccessKeySecret())
|
||||
}
|
||||
|
||||
// Give the parameter "Authorization" value
|
||||
req.Header.Set(HTTPHeaderAuthorization, authorizationStr)
|
||||
}
|
||||
|
||||
func (conn Conn) getSignedStr(req *http.Request, canonicalizedResource string, keySecret string) string {
|
||||
// Find out the "x-oss-"'s address in header of the request
|
||||
ossHeadersMap := make(map[string]string)
|
||||
additionalList, additionalMap := conn.getAdditionalHeaderKeys(req)
|
||||
for k, v := range req.Header {
|
||||
if strings.HasPrefix(strings.ToLower(k), "x-oss-") {
|
||||
ossHeadersMap[strings.ToLower(k)] = v[0]
|
||||
} else if conn.config.AuthVersion == AuthV2 {
|
||||
if _, ok := additionalMap[strings.ToLower(k)]; ok {
|
||||
ossHeadersMap[strings.ToLower(k)] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
hs := newHeaderSorter(ossHeadersMap)
|
||||
|
||||
// Sort the ossHeadersMap by the ascending order
|
||||
hs.Sort()
|
||||
|
||||
// Get the canonicalizedOSSHeaders
|
||||
canonicalizedOSSHeaders := ""
|
||||
for i := range hs.Keys {
|
||||
canonicalizedOSSHeaders += hs.Keys[i] + ":" + hs.Vals[i] + "\n"
|
||||
}
|
||||
|
||||
// Give other parameters values
|
||||
// when sign URL, date is expires
|
||||
date := req.Header.Get(HTTPHeaderDate)
|
||||
contentType := req.Header.Get(HTTPHeaderContentType)
|
||||
contentMd5 := req.Header.Get(HTTPHeaderContentMD5)
|
||||
|
||||
// default is v1 signature
|
||||
signStr := req.Method + "\n" + contentMd5 + "\n" + contentType + "\n" + date + "\n" + canonicalizedOSSHeaders + canonicalizedResource
|
||||
h := hmac.New(func() hash.Hash { return sha1.New() }, []byte(keySecret))
|
||||
|
||||
// v2 signature
|
||||
if conn.config.AuthVersion == AuthV2 {
|
||||
signStr = req.Method + "\n" + contentMd5 + "\n" + contentType + "\n" + date + "\n" + canonicalizedOSSHeaders + strings.Join(additionalList, ";") + "\n" + canonicalizedResource
|
||||
h = hmac.New(func() hash.Hash { return sha256.New() }, []byte(keySecret))
|
||||
}
|
||||
|
||||
if conn.config.LogLevel >= Debug {
|
||||
conn.config.WriteLog(Debug, "[Req:%p]signStr:%s\n", req, EscapeLFString(signStr))
|
||||
}
|
||||
|
||||
io.WriteString(h, signStr)
|
||||
signedStr := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return signedStr
|
||||
}
|
||||
|
||||
func (conn Conn) getSignedStrV4(req *http.Request, canonicalizedResource string, keySecret string, signingTime *time.Time) string {
|
||||
// Find out the "x-oss-"'s address in header of the request
|
||||
ossHeadersMap := make(map[string]string)
|
||||
additionalList, additionalMap := conn.getAdditionalHeaderKeysV4(req)
|
||||
for k, v := range req.Header {
|
||||
lowKey := strings.ToLower(k)
|
||||
if strings.EqualFold(lowKey, HTTPHeaderContentMD5) ||
|
||||
strings.EqualFold(lowKey, HTTPHeaderContentType) ||
|
||||
strings.HasPrefix(lowKey, "x-oss-") {
|
||||
ossHeadersMap[lowKey] = strings.Trim(v[0], " ")
|
||||
} else {
|
||||
if _, ok := additionalMap[lowKey]; ok {
|
||||
ossHeadersMap[lowKey] = strings.Trim(v[0], " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get day,eg 20210914
|
||||
//signingTime
|
||||
signDate := ""
|
||||
strDay := ""
|
||||
if signingTime != nil {
|
||||
signDate = signingTime.Format(timeFormatV4)
|
||||
strDay = signingTime.Format(shortTimeFormatV4)
|
||||
} else {
|
||||
var t time.Time
|
||||
// Required parameters
|
||||
if date := req.Header.Get(HTTPHeaderDate); date != "" {
|
||||
signDate = date
|
||||
t, _ = time.Parse(http.TimeFormat, date)
|
||||
}
|
||||
|
||||
if ossDate := req.Header.Get(HttpHeaderOssDate); ossDate != "" {
|
||||
signDate = ossDate
|
||||
t, _ = time.Parse(timeFormatV4, ossDate)
|
||||
}
|
||||
|
||||
strDay = t.Format("20060102")
|
||||
}
|
||||
|
||||
hs := newHeaderSorter(ossHeadersMap)
|
||||
|
||||
// Sort the ossHeadersMap by the ascending order
|
||||
hs.Sort()
|
||||
|
||||
// Get the canonicalizedOSSHeaders
|
||||
canonicalizedOSSHeaders := ""
|
||||
for i := range hs.Keys {
|
||||
canonicalizedOSSHeaders += hs.Keys[i] + ":" + hs.Vals[i] + "\n"
|
||||
}
|
||||
|
||||
signStr := ""
|
||||
|
||||
// v4 signature
|
||||
hashedPayload := DefaultContentSha256
|
||||
if val := req.Header.Get(HttpHeaderOssContentSha256); val != "" {
|
||||
hashedPayload = val
|
||||
}
|
||||
|
||||
// subResource
|
||||
resource := canonicalizedResource
|
||||
subResource := ""
|
||||
subPos := strings.LastIndex(canonicalizedResource, "?")
|
||||
if subPos != -1 {
|
||||
subResource = canonicalizedResource[subPos+1:]
|
||||
resource = canonicalizedResource[0:subPos]
|
||||
}
|
||||
|
||||
// get canonical request
|
||||
canonicalReuqest := req.Method + "\n" + resource + "\n" + subResource + "\n" + canonicalizedOSSHeaders + "\n" + strings.Join(additionalList, ";") + "\n" + hashedPayload
|
||||
rh := sha256.New()
|
||||
io.WriteString(rh, canonicalReuqest)
|
||||
hashedRequest := hex.EncodeToString(rh.Sum(nil))
|
||||
|
||||
if conn.config.LogLevel >= Debug {
|
||||
conn.config.WriteLog(Debug, "[Req:%p]CanonicalRequest:%s\n", req, EscapeLFString(canonicalReuqest))
|
||||
}
|
||||
|
||||
// Product & Region
|
||||
signedStrV4Product := conn.config.GetSignProduct()
|
||||
signedStrV4Region := conn.config.GetSignRegion()
|
||||
|
||||
signStr = "OSS4-HMAC-SHA256" + "\n" + signDate + "\n" + strDay + "/" + signedStrV4Region + "/" + signedStrV4Product + "/aliyun_v4_request" + "\n" + hashedRequest
|
||||
if conn.config.LogLevel >= Debug {
|
||||
conn.config.WriteLog(Debug, "[Req:%p]signStr:%s\n", req, EscapeLFString(signStr))
|
||||
}
|
||||
|
||||
h1 := hmac.New(func() hash.Hash { return sha256.New() }, []byte("aliyun_v4"+keySecret))
|
||||
io.WriteString(h1, strDay)
|
||||
h1Key := h1.Sum(nil)
|
||||
|
||||
h2 := hmac.New(func() hash.Hash { return sha256.New() }, h1Key)
|
||||
io.WriteString(h2, signedStrV4Region)
|
||||
h2Key := h2.Sum(nil)
|
||||
|
||||
h3 := hmac.New(func() hash.Hash { return sha256.New() }, h2Key)
|
||||
io.WriteString(h3, signedStrV4Product)
|
||||
h3Key := h3.Sum(nil)
|
||||
|
||||
h4 := hmac.New(func() hash.Hash { return sha256.New() }, h3Key)
|
||||
io.WriteString(h4, "aliyun_v4_request")
|
||||
h4Key := h4.Sum(nil)
|
||||
|
||||
h := hmac.New(func() hash.Hash { return sha256.New() }, h4Key)
|
||||
io.WriteString(h, signStr)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
func (conn Conn) getRtmpSignedStr(bucketName, channelName, playlistName string, expiration int64, keySecret string, params map[string]interface{}) string {
|
||||
if params[HTTPParamAccessKeyID] == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
canonResource := fmt.Sprintf("/%s/%s", bucketName, channelName)
|
||||
canonParamsKeys := []string{}
|
||||
for key := range params {
|
||||
if key != HTTPParamAccessKeyID && key != HTTPParamSignature && key != HTTPParamExpires && key != HTTPParamSecurityToken {
|
||||
canonParamsKeys = append(canonParamsKeys, key)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(canonParamsKeys)
|
||||
canonParamsStr := ""
|
||||
for _, key := range canonParamsKeys {
|
||||
canonParamsStr = fmt.Sprintf("%s%s:%s\n", canonParamsStr, key, params[key].(string))
|
||||
}
|
||||
|
||||
expireStr := strconv.FormatInt(expiration, 10)
|
||||
signStr := expireStr + "\n" + canonParamsStr + canonResource
|
||||
|
||||
h := hmac.New(func() hash.Hash { return sha1.New() }, []byte(keySecret))
|
||||
io.WriteString(h, signStr)
|
||||
signedStr := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
return signedStr
|
||||
}
|
||||
|
||||
// newHeaderSorter is an additional function for function SignHeader.
|
||||
func newHeaderSorter(m map[string]string) *headerSorter {
|
||||
hs := &headerSorter{
|
||||
Keys: make([]string, 0, len(m)),
|
||||
Vals: make([]string, 0, len(m)),
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
hs.Keys = append(hs.Keys, k)
|
||||
hs.Vals = append(hs.Vals, v)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
// Sort is an additional function for function SignHeader.
|
||||
func (hs *headerSorter) Sort() {
|
||||
sort.Sort(hs)
|
||||
}
|
||||
|
||||
// Len is an additional function for function SignHeader.
|
||||
func (hs *headerSorter) Len() int {
|
||||
return len(hs.Vals)
|
||||
}
|
||||
|
||||
// Less is an additional function for function SignHeader.
|
||||
func (hs *headerSorter) Less(i, j int) bool {
|
||||
return bytes.Compare([]byte(hs.Keys[i]), []byte(hs.Keys[j])) < 0
|
||||
}
|
||||
|
||||
// Swap is an additional function for function SignHeader.
|
||||
func (hs *headerSorter) Swap(i, j int) {
|
||||
hs.Vals[i], hs.Vals[j] = hs.Vals[j], hs.Vals[i]
|
||||
hs.Keys[i], hs.Keys[j] = hs.Keys[j], hs.Keys[i]
|
||||
}
|
||||
+1321
File diff suppressed because it is too large
Load Diff
+2956
File diff suppressed because it is too large
Load Diff
+301
@@ -0,0 +1,301 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Define the level of the output log
|
||||
const (
|
||||
LogOff = iota
|
||||
Error
|
||||
Warn
|
||||
Info
|
||||
Debug
|
||||
)
|
||||
|
||||
// LogTag Tag for each level of log
|
||||
var LogTag = []string{"[error]", "[warn]", "[info]", "[debug]"}
|
||||
|
||||
// HTTPTimeout defines HTTP timeout.
|
||||
type HTTPTimeout struct {
|
||||
ConnectTimeout time.Duration
|
||||
ReadWriteTimeout time.Duration
|
||||
HeaderTimeout time.Duration
|
||||
LongTimeout time.Duration
|
||||
IdleConnTimeout time.Duration
|
||||
}
|
||||
|
||||
// HTTPMaxConns defines max idle connections and max idle connections per host
|
||||
type HTTPMaxConns struct {
|
||||
MaxIdleConns int
|
||||
MaxIdleConnsPerHost int
|
||||
MaxConnsPerHost int
|
||||
}
|
||||
|
||||
// Credentials is interface for get AccessKeyID,AccessKeySecret,SecurityToken
|
||||
type Credentials interface {
|
||||
GetAccessKeyID() string
|
||||
GetAccessKeySecret() string
|
||||
GetSecurityToken() string
|
||||
}
|
||||
|
||||
// CredentialsProvider is interface for get Credential Info
|
||||
type CredentialsProvider interface {
|
||||
GetCredentials() Credentials
|
||||
}
|
||||
|
||||
type CredentialsProviderE interface {
|
||||
CredentialsProvider
|
||||
GetCredentialsE() (Credentials, error)
|
||||
}
|
||||
|
||||
type defaultCredentials struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
func (defCre *defaultCredentials) GetAccessKeyID() string {
|
||||
return defCre.config.AccessKeyID
|
||||
}
|
||||
|
||||
func (defCre *defaultCredentials) GetAccessKeySecret() string {
|
||||
return defCre.config.AccessKeySecret
|
||||
}
|
||||
|
||||
func (defCre *defaultCredentials) GetSecurityToken() string {
|
||||
return defCre.config.SecurityToken
|
||||
}
|
||||
|
||||
type defaultCredentialsProvider struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
func (defBuild *defaultCredentialsProvider) GetCredentials() Credentials {
|
||||
return &defaultCredentials{config: defBuild.config}
|
||||
}
|
||||
|
||||
type envCredentials struct {
|
||||
AccessKeyId string
|
||||
AccessKeySecret string
|
||||
SecurityToken string
|
||||
}
|
||||
|
||||
type EnvironmentVariableCredentialsProvider struct {
|
||||
cred Credentials
|
||||
}
|
||||
|
||||
func (credentials *envCredentials) GetAccessKeyID() string {
|
||||
return credentials.AccessKeyId
|
||||
}
|
||||
|
||||
func (credentials *envCredentials) GetAccessKeySecret() string {
|
||||
return credentials.AccessKeySecret
|
||||
}
|
||||
|
||||
func (credentials *envCredentials) GetSecurityToken() string {
|
||||
return credentials.SecurityToken
|
||||
}
|
||||
|
||||
func (defBuild *EnvironmentVariableCredentialsProvider) GetCredentials() Credentials {
|
||||
var accessID, accessKey, token string
|
||||
if defBuild.cred == nil {
|
||||
accessID = os.Getenv("OSS_ACCESS_KEY_ID")
|
||||
accessKey = os.Getenv("OSS_ACCESS_KEY_SECRET")
|
||||
token = os.Getenv("OSS_SESSION_TOKEN")
|
||||
} else {
|
||||
accessID = defBuild.cred.GetAccessKeyID()
|
||||
accessKey = defBuild.cred.GetAccessKeySecret()
|
||||
token = defBuild.cred.GetSecurityToken()
|
||||
}
|
||||
|
||||
return &envCredentials{
|
||||
AccessKeyId: accessID,
|
||||
AccessKeySecret: accessKey,
|
||||
SecurityToken: token,
|
||||
}
|
||||
}
|
||||
|
||||
func NewEnvironmentVariableCredentialsProvider() (EnvironmentVariableCredentialsProvider, error) {
|
||||
var provider EnvironmentVariableCredentialsProvider
|
||||
accessID := os.Getenv("OSS_ACCESS_KEY_ID")
|
||||
if accessID == "" {
|
||||
return provider, fmt.Errorf("access key id is empty!")
|
||||
}
|
||||
accessKey := os.Getenv("OSS_ACCESS_KEY_SECRET")
|
||||
if accessKey == "" {
|
||||
return provider, fmt.Errorf("access key secret is empty!")
|
||||
}
|
||||
token := os.Getenv("OSS_SESSION_TOKEN")
|
||||
envCredential := &envCredentials{
|
||||
AccessKeyId: accessID,
|
||||
AccessKeySecret: accessKey,
|
||||
SecurityToken: token,
|
||||
}
|
||||
return EnvironmentVariableCredentialsProvider{
|
||||
cred: envCredential,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config defines oss configuration
|
||||
type Config struct {
|
||||
Endpoint string // OSS endpoint
|
||||
AccessKeyID string // AccessId
|
||||
AccessKeySecret string // AccessKey
|
||||
RetryTimes uint // Retry count by default it's 5.
|
||||
UserAgent string // SDK name/version/system information
|
||||
IsDebug bool // Enable debug mode. Default is false.
|
||||
Timeout uint // Timeout in seconds. By default it's 60.
|
||||
SecurityToken string // STS Token
|
||||
IsCname bool // If cname is in the endpoint.
|
||||
IsPathStyle bool // If Path Style is in the endpoint.
|
||||
HTTPTimeout HTTPTimeout // HTTP timeout
|
||||
HTTPMaxConns HTTPMaxConns // Http max connections
|
||||
IsUseProxy bool // Flag of using proxy.
|
||||
ProxyHost string // Flag of using proxy host.
|
||||
IsAuthProxy bool // Flag of needing authentication.
|
||||
ProxyUser string // Proxy user
|
||||
ProxyPassword string // Proxy password
|
||||
IsEnableMD5 bool // Flag of enabling MD5 for upload.
|
||||
MD5Threshold int64 // Memory footprint threshold for each MD5 computation (16MB is the default), in byte. When the data is more than that, temp file is used.
|
||||
IsEnableCRC bool // Flag of enabling CRC for upload.
|
||||
LogLevel int // Log level
|
||||
Logger *log.Logger // For write log
|
||||
UploadLimitSpeed int // Upload limit speed:KB/s, 0 is unlimited
|
||||
UploadLimiter *OssLimiter // Bandwidth limit reader for upload
|
||||
DownloadLimitSpeed int // Download limit speed:KB/s, 0 is unlimited
|
||||
DownloadLimiter *OssLimiter // Bandwidth limit reader for download
|
||||
CredentialsProvider CredentialsProvider // User provides interface to get AccessKeyID, AccessKeySecret, SecurityToken
|
||||
LocalAddr net.Addr // local client host info
|
||||
UserSetUa bool // UserAgent is set by user or not
|
||||
AuthVersion AuthVersionType // v1 or v2, v4 signature,default is v1
|
||||
AdditionalHeaders []string // special http headers needed to be sign
|
||||
RedirectEnabled bool // only effective from go1.7 onward, enable http redirect or not
|
||||
InsecureSkipVerify bool // for https, Whether to skip verifying the server certificate file
|
||||
Region string // such as cn-hangzhou
|
||||
CloudBoxId string //
|
||||
Product string // oss or oss-cloudbox, default is oss
|
||||
VerifyObjectStrict bool // a flag of verifying object name strictly. Default is enable.
|
||||
}
|
||||
|
||||
// LimitUploadSpeed uploadSpeed:KB/s, 0 is unlimited,default is 0
|
||||
func (config *Config) LimitUploadSpeed(uploadSpeed int) error {
|
||||
if uploadSpeed < 0 {
|
||||
return fmt.Errorf("invalid argument, the value of uploadSpeed is less than 0")
|
||||
} else if uploadSpeed == 0 {
|
||||
config.UploadLimitSpeed = 0
|
||||
config.UploadLimiter = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
config.UploadLimiter, err = GetOssLimiter(uploadSpeed)
|
||||
if err == nil {
|
||||
config.UploadLimitSpeed = uploadSpeed
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// LimitDownLoadSpeed downloadSpeed:KB/s, 0 is unlimited,default is 0
|
||||
func (config *Config) LimitDownloadSpeed(downloadSpeed int) error {
|
||||
if downloadSpeed < 0 {
|
||||
return fmt.Errorf("invalid argument, the value of downloadSpeed is less than 0")
|
||||
} else if downloadSpeed == 0 {
|
||||
config.DownloadLimitSpeed = 0
|
||||
config.DownloadLimiter = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
config.DownloadLimiter, err = GetOssLimiter(downloadSpeed)
|
||||
if err == nil {
|
||||
config.DownloadLimitSpeed = downloadSpeed
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteLog output log function
|
||||
func (config *Config) WriteLog(LogLevel int, format string, a ...interface{}) {
|
||||
if config.LogLevel < LogLevel || config.Logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
logBuffer.WriteString(LogTag[LogLevel-1])
|
||||
logBuffer.WriteString(fmt.Sprintf(format, a...))
|
||||
config.Logger.Printf("%s", logBuffer.String())
|
||||
}
|
||||
|
||||
// for get Credentials
|
||||
func (config *Config) GetCredentials() Credentials {
|
||||
return config.CredentialsProvider.GetCredentials()
|
||||
}
|
||||
|
||||
// for get Sign Product
|
||||
func (config *Config) GetSignProduct() string {
|
||||
if config.CloudBoxId != "" {
|
||||
return "oss-cloudbox"
|
||||
}
|
||||
return "oss"
|
||||
}
|
||||
|
||||
// for get Sign Region
|
||||
func (config *Config) GetSignRegion() string {
|
||||
if config.CloudBoxId != "" {
|
||||
return config.CloudBoxId
|
||||
}
|
||||
return config.Region
|
||||
}
|
||||
|
||||
// getDefaultOssConfig gets the default configuration.
|
||||
func getDefaultOssConfig() *Config {
|
||||
config := Config{}
|
||||
|
||||
config.Endpoint = ""
|
||||
config.AccessKeyID = ""
|
||||
config.AccessKeySecret = ""
|
||||
config.RetryTimes = 5
|
||||
config.IsDebug = false
|
||||
config.UserAgent = userAgent()
|
||||
config.Timeout = 60 // Seconds
|
||||
config.SecurityToken = ""
|
||||
config.IsCname = false
|
||||
config.IsPathStyle = false
|
||||
|
||||
config.HTTPTimeout.ConnectTimeout = time.Second * 30 // 30s
|
||||
config.HTTPTimeout.ReadWriteTimeout = time.Second * 60 // 60s
|
||||
config.HTTPTimeout.HeaderTimeout = time.Second * 60 // 60s
|
||||
config.HTTPTimeout.LongTimeout = time.Second * 300 // 300s
|
||||
config.HTTPTimeout.IdleConnTimeout = time.Second * 50 // 50s
|
||||
config.HTTPMaxConns.MaxIdleConns = 100
|
||||
config.HTTPMaxConns.MaxIdleConnsPerHost = 100
|
||||
|
||||
config.IsUseProxy = false
|
||||
config.ProxyHost = ""
|
||||
config.IsAuthProxy = false
|
||||
config.ProxyUser = ""
|
||||
config.ProxyPassword = ""
|
||||
|
||||
config.MD5Threshold = 16 * 1024 * 1024 // 16MB
|
||||
config.IsEnableMD5 = false
|
||||
config.IsEnableCRC = true
|
||||
|
||||
config.LogLevel = LogOff
|
||||
config.Logger = log.New(os.Stdout, "", log.LstdFlags)
|
||||
|
||||
provider := &defaultCredentialsProvider{config: &config}
|
||||
config.CredentialsProvider = provider
|
||||
|
||||
config.AuthVersion = AuthV1
|
||||
config.RedirectEnabled = true
|
||||
config.InsecureSkipVerify = false
|
||||
|
||||
config.Product = "oss"
|
||||
|
||||
config.VerifyObjectStrict = true
|
||||
|
||||
return &config
|
||||
}
|
||||
+1021
File diff suppressed because it is too large
Load Diff
+273
@@ -0,0 +1,273 @@
|
||||
package oss
|
||||
|
||||
import "os"
|
||||
|
||||
// ACLType bucket/object ACL
|
||||
type ACLType string
|
||||
|
||||
const (
|
||||
// ACLPrivate definition : private read and write
|
||||
ACLPrivate ACLType = "private"
|
||||
|
||||
// ACLPublicRead definition : public read and private write
|
||||
ACLPublicRead ACLType = "public-read"
|
||||
|
||||
// ACLPublicReadWrite definition : public read and public write
|
||||
ACLPublicReadWrite ACLType = "public-read-write"
|
||||
|
||||
// ACLDefault Object. It's only applicable for object.
|
||||
ACLDefault ACLType = "default"
|
||||
)
|
||||
|
||||
// bucket versioning status
|
||||
type VersioningStatus string
|
||||
|
||||
const (
|
||||
// Versioning Status definition: Enabled
|
||||
VersionEnabled VersioningStatus = "Enabled"
|
||||
|
||||
// Versioning Status definition: Suspended
|
||||
VersionSuspended VersioningStatus = "Suspended"
|
||||
)
|
||||
|
||||
// MetadataDirectiveType specifying whether use the metadata of source object when copying object.
|
||||
type MetadataDirectiveType string
|
||||
|
||||
const (
|
||||
// MetaCopy the target object's metadata is copied from the source one
|
||||
MetaCopy MetadataDirectiveType = "COPY"
|
||||
|
||||
// MetaReplace the target object's metadata is created as part of the copy request (not same as the source one)
|
||||
MetaReplace MetadataDirectiveType = "REPLACE"
|
||||
)
|
||||
|
||||
// TaggingDirectiveType specifying whether use the tagging of source object when copying object.
|
||||
type TaggingDirectiveType string
|
||||
|
||||
const (
|
||||
// TaggingCopy the target object's tagging is copied from the source one
|
||||
TaggingCopy TaggingDirectiveType = "COPY"
|
||||
|
||||
// TaggingReplace the target object's tagging is created as part of the copy request (not same as the source one)
|
||||
TaggingReplace TaggingDirectiveType = "REPLACE"
|
||||
)
|
||||
|
||||
// AlgorithmType specifying the server side encryption algorithm name
|
||||
type AlgorithmType string
|
||||
|
||||
const (
|
||||
KMSAlgorithm AlgorithmType = "KMS"
|
||||
AESAlgorithm AlgorithmType = "AES256"
|
||||
SM4Algorithm AlgorithmType = "SM4"
|
||||
)
|
||||
|
||||
// StorageClassType bucket storage type
|
||||
type StorageClassType string
|
||||
|
||||
const (
|
||||
// StorageStandard standard
|
||||
StorageStandard StorageClassType = "Standard"
|
||||
|
||||
// StorageIA infrequent access
|
||||
StorageIA StorageClassType = "IA"
|
||||
|
||||
// StorageArchive archive
|
||||
StorageArchive StorageClassType = "Archive"
|
||||
|
||||
// StorageColdArchive cold archive
|
||||
StorageColdArchive StorageClassType = "ColdArchive"
|
||||
|
||||
// StorageDeepColdArchive deep cold archive
|
||||
StorageDeepColdArchive StorageClassType = "DeepColdArchive"
|
||||
)
|
||||
|
||||
//RedundancyType bucket data Redundancy type
|
||||
type DataRedundancyType string
|
||||
|
||||
const (
|
||||
// RedundancyLRS Local redundancy, default value
|
||||
RedundancyLRS DataRedundancyType = "LRS"
|
||||
|
||||
// RedundancyZRS Same city redundancy
|
||||
RedundancyZRS DataRedundancyType = "ZRS"
|
||||
)
|
||||
|
||||
//ObjecthashFuncType
|
||||
type ObjecthashFuncType string
|
||||
|
||||
const (
|
||||
HashFuncSha1 ObjecthashFuncType = "SHA-1"
|
||||
HashFuncSha256 ObjecthashFuncType = "SHA-256"
|
||||
)
|
||||
|
||||
// PayerType the type of request payer
|
||||
type PayerType string
|
||||
|
||||
const (
|
||||
// Requester the requester who send the request
|
||||
Requester PayerType = "Requester"
|
||||
|
||||
// BucketOwner the requester who send the request
|
||||
BucketOwner PayerType = "BucketOwner"
|
||||
)
|
||||
|
||||
//RestoreMode the restore mode for coldArchive object
|
||||
type RestoreMode string
|
||||
|
||||
const (
|
||||
//RestoreExpedited object will be restored in 1 hour
|
||||
RestoreExpedited RestoreMode = "Expedited"
|
||||
|
||||
//RestoreStandard object will be restored in 2-5 hours
|
||||
RestoreStandard RestoreMode = "Standard"
|
||||
|
||||
//RestoreBulk object will be restored in 5-10 hours
|
||||
RestoreBulk RestoreMode = "Bulk"
|
||||
)
|
||||
|
||||
// HTTPMethod HTTP request method
|
||||
type HTTPMethod string
|
||||
|
||||
const (
|
||||
// HTTPGet HTTP GET
|
||||
HTTPGet HTTPMethod = "GET"
|
||||
|
||||
// HTTPPut HTTP PUT
|
||||
HTTPPut HTTPMethod = "PUT"
|
||||
|
||||
// HTTPHead HTTP HEAD
|
||||
HTTPHead HTTPMethod = "HEAD"
|
||||
|
||||
// HTTPPost HTTP POST
|
||||
HTTPPost HTTPMethod = "POST"
|
||||
|
||||
// HTTPDelete HTTP DELETE
|
||||
HTTPDelete HTTPMethod = "DELETE"
|
||||
)
|
||||
|
||||
// HTTP headers
|
||||
const (
|
||||
HTTPHeaderAcceptEncoding string = "Accept-Encoding"
|
||||
HTTPHeaderAuthorization = "Authorization"
|
||||
HTTPHeaderCacheControl = "Cache-Control"
|
||||
HTTPHeaderContentDisposition = "Content-Disposition"
|
||||
HTTPHeaderContentEncoding = "Content-Encoding"
|
||||
HTTPHeaderContentLength = "Content-Length"
|
||||
HTTPHeaderContentMD5 = "Content-MD5"
|
||||
HTTPHeaderContentType = "Content-Type"
|
||||
HTTPHeaderContentLanguage = "Content-Language"
|
||||
HTTPHeaderDate = "Date"
|
||||
HTTPHeaderEtag = "ETag"
|
||||
HTTPHeaderExpires = "Expires"
|
||||
HTTPHeaderHost = "Host"
|
||||
HTTPHeaderLastModified = "Last-Modified"
|
||||
HTTPHeaderRange = "Range"
|
||||
HTTPHeaderLocation = "Location"
|
||||
HTTPHeaderOrigin = "Origin"
|
||||
HTTPHeaderServer = "Server"
|
||||
HTTPHeaderUserAgent = "User-Agent"
|
||||
HTTPHeaderIfModifiedSince = "If-Modified-Since"
|
||||
HTTPHeaderIfUnmodifiedSince = "If-Unmodified-Since"
|
||||
HTTPHeaderIfMatch = "If-Match"
|
||||
HTTPHeaderIfNoneMatch = "If-None-Match"
|
||||
HTTPHeaderACReqMethod = "Access-Control-Request-Method"
|
||||
HTTPHeaderACReqHeaders = "Access-Control-Request-Headers"
|
||||
|
||||
HTTPHeaderOssACL = "X-Oss-Acl"
|
||||
HTTPHeaderOssMetaPrefix = "X-Oss-Meta-"
|
||||
HTTPHeaderOssObjectACL = "X-Oss-Object-Acl"
|
||||
HTTPHeaderOssSecurityToken = "X-Oss-Security-Token"
|
||||
HTTPHeaderOssServerSideEncryption = "X-Oss-Server-Side-Encryption"
|
||||
HTTPHeaderOssServerSideEncryptionKeyID = "X-Oss-Server-Side-Encryption-Key-Id"
|
||||
HTTPHeaderOssServerSideDataEncryption = "X-Oss-Server-Side-Data-Encryption"
|
||||
HTTPHeaderSSECAlgorithm = "X-Oss-Server-Side-Encryption-Customer-Algorithm"
|
||||
HTTPHeaderSSECKey = "X-Oss-Server-Side-Encryption-Customer-Key"
|
||||
HTTPHeaderSSECKeyMd5 = "X-Oss-Server-Side-Encryption-Customer-Key-MD5"
|
||||
HTTPHeaderOssCopySource = "X-Oss-Copy-Source"
|
||||
HTTPHeaderOssCopySourceRange = "X-Oss-Copy-Source-Range"
|
||||
HTTPHeaderOssCopySourceIfMatch = "X-Oss-Copy-Source-If-Match"
|
||||
HTTPHeaderOssCopySourceIfNoneMatch = "X-Oss-Copy-Source-If-None-Match"
|
||||
HTTPHeaderOssCopySourceIfModifiedSince = "X-Oss-Copy-Source-If-Modified-Since"
|
||||
HTTPHeaderOssCopySourceIfUnmodifiedSince = "X-Oss-Copy-Source-If-Unmodified-Since"
|
||||
HTTPHeaderOssMetadataDirective = "X-Oss-Metadata-Directive"
|
||||
HTTPHeaderOssNextAppendPosition = "X-Oss-Next-Append-Position"
|
||||
HTTPHeaderOssRequestID = "X-Oss-Request-Id"
|
||||
HTTPHeaderOssCRC64 = "X-Oss-Hash-Crc64ecma"
|
||||
HTTPHeaderOssSymlinkTarget = "X-Oss-Symlink-Target"
|
||||
HTTPHeaderOssStorageClass = "X-Oss-Storage-Class"
|
||||
HTTPHeaderOssCallback = "X-Oss-Callback"
|
||||
HTTPHeaderOssCallbackVar = "X-Oss-Callback-Var"
|
||||
HTTPHeaderOssRequester = "X-Oss-Request-Payer"
|
||||
HTTPHeaderOssTagging = "X-Oss-Tagging"
|
||||
HTTPHeaderOssTaggingDirective = "X-Oss-Tagging-Directive"
|
||||
HTTPHeaderOssTrafficLimit = "X-Oss-Traffic-Limit"
|
||||
HTTPHeaderOssForbidOverWrite = "X-Oss-Forbid-Overwrite"
|
||||
HTTPHeaderOssRangeBehavior = "X-Oss-Range-Behavior"
|
||||
HTTPHeaderOssTaskID = "X-Oss-Task-Id"
|
||||
HTTPHeaderOssHashCtx = "X-Oss-Hash-Ctx"
|
||||
HTTPHeaderOssMd5Ctx = "X-Oss-Md5-Ctx"
|
||||
HTTPHeaderAllowSameActionOverLap = "X-Oss-Allow-Same-Action-Overlap"
|
||||
HttpHeaderOssDate = "X-Oss-Date"
|
||||
HttpHeaderOssContentSha256 = "X-Oss-Content-Sha256"
|
||||
HttpHeaderOssNotification = "X-Oss-Notification"
|
||||
HTTPHeaderOssEc = "X-Oss-Ec"
|
||||
HTTPHeaderOssErr = "X-Oss-Err"
|
||||
)
|
||||
|
||||
// HTTP Param
|
||||
const (
|
||||
HTTPParamExpires = "Expires"
|
||||
HTTPParamAccessKeyID = "OSSAccessKeyId"
|
||||
HTTPParamSignature = "Signature"
|
||||
HTTPParamSecurityToken = "security-token"
|
||||
HTTPParamPlaylistName = "playlistName"
|
||||
|
||||
HTTPParamSignatureVersion = "x-oss-signature-version"
|
||||
HTTPParamExpiresV2 = "x-oss-expires"
|
||||
HTTPParamAccessKeyIDV2 = "x-oss-access-key-id"
|
||||
HTTPParamSignatureV2 = "x-oss-signature"
|
||||
HTTPParamAdditionalHeadersV2 = "x-oss-additional-headers"
|
||||
HTTPParamCredential = "x-oss-credential"
|
||||
HTTPParamDate = "x-oss-date"
|
||||
HTTPParamOssSecurityToken = "x-oss-security-token"
|
||||
)
|
||||
|
||||
// Other constants
|
||||
const (
|
||||
MaxPartSize = 5 * 1024 * 1024 * 1024 // Max part size, 5GB
|
||||
MinPartSize = 100 * 1024 // Min part size, 100KB
|
||||
|
||||
FilePermMode = os.FileMode(0664) // Default file permission
|
||||
|
||||
TempFilePrefix = "oss-go-temp-" // Temp file prefix
|
||||
TempFileSuffix = ".temp" // Temp file suffix
|
||||
|
||||
CheckpointFileSuffix = ".cp" // Checkpoint file suffix
|
||||
|
||||
NullVersion = "null"
|
||||
|
||||
DefaultContentSha256 = "UNSIGNED-PAYLOAD" // for v4 signature
|
||||
|
||||
Version = "v3.0.2" // Go SDK version
|
||||
)
|
||||
|
||||
// FrameType
|
||||
const (
|
||||
DataFrameType = 8388609
|
||||
ContinuousFrameType = 8388612
|
||||
EndFrameType = 8388613
|
||||
MetaEndFrameCSVType = 8388614
|
||||
MetaEndFrameJSONType = 8388615
|
||||
)
|
||||
|
||||
// AuthVersion the version of auth
|
||||
type AuthVersionType string
|
||||
|
||||
const (
|
||||
// AuthV1 v1
|
||||
AuthV1 AuthVersionType = "v1"
|
||||
// AuthV2 v2
|
||||
AuthV2 AuthVersionType = "v2"
|
||||
// AuthV4 v4
|
||||
AuthV4 AuthVersionType = "v4"
|
||||
)
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"hash"
|
||||
"hash/crc64"
|
||||
)
|
||||
|
||||
// digest represents the partial evaluation of a checksum.
|
||||
type digest struct {
|
||||
crc uint64
|
||||
tab *crc64.Table
|
||||
}
|
||||
|
||||
// NewCRC creates a new hash.Hash64 computing the CRC64 checksum
|
||||
// using the polynomial represented by the Table.
|
||||
func NewCRC(tab *crc64.Table, init uint64) hash.Hash64 { return &digest{init, tab} }
|
||||
|
||||
// Size returns the number of bytes sum will return.
|
||||
func (d *digest) Size() int { return crc64.Size }
|
||||
|
||||
// BlockSize returns the hash's underlying block size.
|
||||
// The Write method must be able to accept any amount
|
||||
// of data, but it may operate more efficiently if all writes
|
||||
// are a multiple of the block size.
|
||||
func (d *digest) BlockSize() int { return 1 }
|
||||
|
||||
// Reset resets the hash to its initial state.
|
||||
func (d *digest) Reset() { d.crc = 0 }
|
||||
|
||||
// Write (via the embedded io.Writer interface) adds more data to the running hash.
|
||||
// It never returns an error.
|
||||
func (d *digest) Write(p []byte) (n int, err error) {
|
||||
d.crc = crc64.Update(d.crc, d.tab, p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Sum64 returns CRC64 value.
|
||||
func (d *digest) Sum64() uint64 { return d.crc }
|
||||
|
||||
// Sum returns hash value.
|
||||
func (d *digest) Sum(in []byte) []byte {
|
||||
s := d.Sum64()
|
||||
return append(in, byte(s>>56), byte(s>>48), byte(s>>40), byte(s>>32), byte(s>>24), byte(s>>16), byte(s>>8), byte(s))
|
||||
}
|
||||
|
||||
// gf2Dim dimension of GF(2) vectors (length of CRC)
|
||||
const gf2Dim int = 64
|
||||
|
||||
func gf2MatrixTimes(mat []uint64, vec uint64) uint64 {
|
||||
var sum uint64
|
||||
for i := 0; vec != 0; i++ {
|
||||
if vec&1 != 0 {
|
||||
sum ^= mat[i]
|
||||
}
|
||||
|
||||
vec >>= 1
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func gf2MatrixSquare(square []uint64, mat []uint64) {
|
||||
for n := 0; n < gf2Dim; n++ {
|
||||
square[n] = gf2MatrixTimes(mat, mat[n])
|
||||
}
|
||||
}
|
||||
|
||||
// CRC64Combine combines CRC64
|
||||
func CRC64Combine(crc1 uint64, crc2 uint64, len2 uint64) uint64 {
|
||||
var even [gf2Dim]uint64 // Even-power-of-two zeros operator
|
||||
var odd [gf2Dim]uint64 // Odd-power-of-two zeros operator
|
||||
|
||||
// Degenerate case
|
||||
if len2 == 0 {
|
||||
return crc1
|
||||
}
|
||||
|
||||
// Put operator for one zero bit in odd
|
||||
odd[0] = crc64.ECMA // CRC64 polynomial
|
||||
var row uint64 = 1
|
||||
for n := 1; n < gf2Dim; n++ {
|
||||
odd[n] = row
|
||||
row <<= 1
|
||||
}
|
||||
|
||||
// Put operator for two zero bits in even
|
||||
gf2MatrixSquare(even[:], odd[:])
|
||||
|
||||
// Put operator for four zero bits in odd
|
||||
gf2MatrixSquare(odd[:], even[:])
|
||||
|
||||
// Apply len2 zeros to crc1, first square will put the operator for one zero byte, eight zero bits, in even
|
||||
for {
|
||||
// Apply zeros operator for this bit of len2
|
||||
gf2MatrixSquare(even[:], odd[:])
|
||||
|
||||
if len2&1 != 0 {
|
||||
crc1 = gf2MatrixTimes(even[:], crc1)
|
||||
}
|
||||
|
||||
len2 >>= 1
|
||||
|
||||
// If no more bits set, then done
|
||||
if len2 == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Another iteration of the loop with odd and even swapped
|
||||
gf2MatrixSquare(odd[:], even[:])
|
||||
if len2&1 != 0 {
|
||||
crc1 = gf2MatrixTimes(odd[:], crc1)
|
||||
}
|
||||
len2 >>= 1
|
||||
|
||||
// If no more bits set, then done
|
||||
if len2 == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Return combined CRC
|
||||
crc1 ^= crc2
|
||||
return crc1
|
||||
}
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/crc64"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadFile downloads files with multipart download.
|
||||
//
|
||||
// objectKey the object key.
|
||||
// filePath the local file to download from objectKey in OSS.
|
||||
// partSize the part size in bytes.
|
||||
// options object's constraints, check out GetObject for the reference.
|
||||
//
|
||||
// error it's nil when the call succeeds, otherwise it's an error object.
|
||||
//
|
||||
func (bucket Bucket) DownloadFile(objectKey, filePath string, partSize int64, options ...Option) error {
|
||||
if partSize < 1 {
|
||||
return errors.New("oss: part size smaller than 1")
|
||||
}
|
||||
|
||||
uRange, err := GetRangeConfig(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cpConf := getCpConfig(options)
|
||||
routines := getRoutines(options)
|
||||
|
||||
var strVersionId string
|
||||
versionId, _ := FindOption(options, "versionId", nil)
|
||||
if versionId != nil {
|
||||
strVersionId = versionId.(string)
|
||||
}
|
||||
|
||||
if cpConf != nil && cpConf.IsEnable {
|
||||
cpFilePath := getDownloadCpFilePath(cpConf, bucket.BucketName, objectKey, strVersionId, filePath)
|
||||
if cpFilePath != "" {
|
||||
return bucket.downloadFileWithCp(objectKey, filePath, partSize, options, cpFilePath, routines, uRange)
|
||||
}
|
||||
}
|
||||
|
||||
return bucket.downloadFile(objectKey, filePath, partSize, options, routines, uRange)
|
||||
}
|
||||
|
||||
func getDownloadCpFilePath(cpConf *cpConfig, srcBucket, srcObject, versionId, destFile string) string {
|
||||
if cpConf.FilePath == "" && cpConf.DirPath != "" {
|
||||
src := fmt.Sprintf("oss://%v/%v", srcBucket, srcObject)
|
||||
absPath, _ := filepath.Abs(destFile)
|
||||
cpFileName := getCpFileName(src, absPath, versionId)
|
||||
cpConf.FilePath = cpConf.DirPath + string(os.PathSeparator) + cpFileName
|
||||
}
|
||||
return cpConf.FilePath
|
||||
}
|
||||
|
||||
// downloadWorkerArg is download worker's parameters
|
||||
type downloadWorkerArg struct {
|
||||
bucket *Bucket
|
||||
key string
|
||||
filePath string
|
||||
options []Option
|
||||
hook downloadPartHook
|
||||
enableCRC bool
|
||||
}
|
||||
|
||||
// downloadPartHook is hook for test
|
||||
type downloadPartHook func(part downloadPart) error
|
||||
|
||||
var downloadPartHooker downloadPartHook = defaultDownloadPartHook
|
||||
|
||||
func defaultDownloadPartHook(part downloadPart) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultDownloadProgressListener defines default ProgressListener, shields the ProgressListener in options of GetObject.
|
||||
type defaultDownloadProgressListener struct {
|
||||
}
|
||||
|
||||
// ProgressChanged no-ops
|
||||
func (listener *defaultDownloadProgressListener) ProgressChanged(event *ProgressEvent) {
|
||||
}
|
||||
|
||||
// downloadWorker
|
||||
func downloadWorker(id int, arg downloadWorkerArg, jobs <-chan downloadPart, results chan<- downloadPart, failed chan<- error, die <-chan bool) {
|
||||
for part := range jobs {
|
||||
if err := arg.hook(part); err != nil {
|
||||
failed <- err
|
||||
break
|
||||
}
|
||||
|
||||
// Resolve options
|
||||
r := Range(part.Start, part.End)
|
||||
p := Progress(&defaultDownloadProgressListener{})
|
||||
|
||||
var respHeader http.Header
|
||||
opts := make([]Option, len(arg.options)+3)
|
||||
// Append orderly, can not be reversed!
|
||||
opts = append(opts, arg.options...)
|
||||
opts = append(opts, r, p, GetResponseHeader(&respHeader))
|
||||
|
||||
rd, err := arg.bucket.GetObject(arg.key, opts...)
|
||||
if err != nil {
|
||||
failed <- err
|
||||
break
|
||||
}
|
||||
defer rd.Close()
|
||||
|
||||
var crcCalc hash.Hash64
|
||||
if arg.enableCRC {
|
||||
crcCalc = crc64.New(CrcTable())
|
||||
contentLen := part.End - part.Start + 1
|
||||
rd = ioutil.NopCloser(TeeReader(rd, crcCalc, contentLen, nil, nil))
|
||||
}
|
||||
defer rd.Close()
|
||||
|
||||
select {
|
||||
case <-die:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
fd, err := os.OpenFile(arg.filePath, os.O_WRONLY, FilePermMode)
|
||||
if err != nil {
|
||||
failed <- err
|
||||
break
|
||||
}
|
||||
|
||||
_, err = fd.Seek(part.Start-part.Offset, os.SEEK_SET)
|
||||
if err != nil {
|
||||
fd.Close()
|
||||
failed <- err
|
||||
break
|
||||
}
|
||||
|
||||
startT := time.Now().UnixNano() / 1000 / 1000 / 1000
|
||||
_, err = io.Copy(fd, rd)
|
||||
endT := time.Now().UnixNano() / 1000 / 1000 / 1000
|
||||
if err != nil {
|
||||
arg.bucket.Client.Config.WriteLog(Debug, "download part error,cost:%d second,part number:%d,request id:%s,error:%s.\n", endT-startT, part.Index, GetRequestId(respHeader), err.Error())
|
||||
fd.Close()
|
||||
failed <- err
|
||||
break
|
||||
}
|
||||
|
||||
if arg.enableCRC {
|
||||
part.CRC64 = crcCalc.Sum64()
|
||||
}
|
||||
|
||||
fd.Close()
|
||||
results <- part
|
||||
}
|
||||
}
|
||||
|
||||
// downloadScheduler
|
||||
func downloadScheduler(jobs chan downloadPart, parts []downloadPart) {
|
||||
for _, part := range parts {
|
||||
jobs <- part
|
||||
}
|
||||
close(jobs)
|
||||
}
|
||||
|
||||
// downloadPart defines download part
|
||||
type downloadPart struct {
|
||||
Index int // Part number, starting from 0
|
||||
Start int64 // Start index
|
||||
End int64 // End index
|
||||
Offset int64 // Offset
|
||||
CRC64 uint64 // CRC check value of part
|
||||
}
|
||||
|
||||
// getDownloadParts gets download parts
|
||||
func getDownloadParts(objectSize, partSize int64, uRange *UnpackedRange) []downloadPart {
|
||||
parts := []downloadPart{}
|
||||
part := downloadPart{}
|
||||
i := 0
|
||||
start, end := AdjustRange(uRange, objectSize)
|
||||
for offset := start; offset < end; offset += partSize {
|
||||
part.Index = i
|
||||
part.Start = offset
|
||||
part.End = GetPartEnd(offset, end, partSize)
|
||||
part.Offset = start
|
||||
part.CRC64 = 0
|
||||
parts = append(parts, part)
|
||||
i++
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// getObjectBytes gets object bytes length
|
||||
func getObjectBytes(parts []downloadPart) int64 {
|
||||
var ob int64
|
||||
for _, part := range parts {
|
||||
ob += (part.End - part.Start + 1)
|
||||
}
|
||||
return ob
|
||||
}
|
||||
|
||||
// combineCRCInParts caculates the total CRC of continuous parts
|
||||
func combineCRCInParts(dps []downloadPart) uint64 {
|
||||
if dps == nil || len(dps) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
crc := dps[0].CRC64
|
||||
for i := 1; i < len(dps); i++ {
|
||||
crc = CRC64Combine(crc, dps[i].CRC64, (uint64)(dps[i].End-dps[i].Start+1))
|
||||
}
|
||||
|
||||
return crc
|
||||
}
|
||||
|
||||
// downloadFile downloads file concurrently without checkpoint.
|
||||
func (bucket Bucket) downloadFile(objectKey, filePath string, partSize int64, options []Option, routines int, uRange *UnpackedRange) error {
|
||||
tempFilePath := filePath + TempFileSuffix
|
||||
listener := GetProgressListener(options)
|
||||
|
||||
// If the file does not exist, create one. If exists, the download will overwrite it.
|
||||
fd, err := os.OpenFile(tempFilePath, os.O_WRONLY|os.O_CREATE, FilePermMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fd.Close()
|
||||
|
||||
// Get the object detailed meta for object whole size
|
||||
// must delete header:range to get whole object size
|
||||
skipOptions := DeleteOption(options, HTTPHeaderRange)
|
||||
meta, err := bucket.GetObjectDetailedMeta(objectKey, skipOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objectSize, err := strconv.ParseInt(meta.Get(HTTPHeaderContentLength), 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enableCRC := false
|
||||
expectedCRC := (uint64)(0)
|
||||
if bucket.GetConfig().IsEnableCRC && meta.Get(HTTPHeaderOssCRC64) != "" {
|
||||
if uRange == nil || (!uRange.HasStart && !uRange.HasEnd) {
|
||||
enableCRC = true
|
||||
expectedCRC, _ = strconv.ParseUint(meta.Get(HTTPHeaderOssCRC64), 10, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the parts of the file
|
||||
parts := getDownloadParts(objectSize, partSize, uRange)
|
||||
jobs := make(chan downloadPart, len(parts))
|
||||
results := make(chan downloadPart, len(parts))
|
||||
failed := make(chan error)
|
||||
die := make(chan bool)
|
||||
|
||||
var completedBytes int64
|
||||
totalBytes := getObjectBytes(parts)
|
||||
event := newProgressEvent(TransferStartedEvent, 0, totalBytes, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
// Start the download workers
|
||||
arg := downloadWorkerArg{&bucket, objectKey, tempFilePath, options, downloadPartHooker, enableCRC}
|
||||
for w := 1; w <= routines; w++ {
|
||||
go downloadWorker(w, arg, jobs, results, failed, die)
|
||||
}
|
||||
|
||||
// Download parts concurrently
|
||||
go downloadScheduler(jobs, parts)
|
||||
|
||||
// Waiting for parts download finished
|
||||
completed := 0
|
||||
for completed < len(parts) {
|
||||
select {
|
||||
case part := <-results:
|
||||
completed++
|
||||
downBytes := (part.End - part.Start + 1)
|
||||
completedBytes += downBytes
|
||||
parts[part.Index].CRC64 = part.CRC64
|
||||
event = newProgressEvent(TransferDataEvent, completedBytes, totalBytes, downBytes)
|
||||
publishProgress(listener, event)
|
||||
case err := <-failed:
|
||||
close(die)
|
||||
event = newProgressEvent(TransferFailedEvent, completedBytes, totalBytes, 0)
|
||||
publishProgress(listener, event)
|
||||
return err
|
||||
}
|
||||
|
||||
if completed >= len(parts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
event = newProgressEvent(TransferCompletedEvent, completedBytes, totalBytes, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
if enableCRC {
|
||||
actualCRC := combineCRCInParts(parts)
|
||||
err = CheckDownloadCRC(actualCRC, expectedCRC)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return os.Rename(tempFilePath, filePath)
|
||||
}
|
||||
|
||||
// ----- Concurrent download with chcekpoint -----
|
||||
|
||||
const downloadCpMagic = "92611BED-89E2-46B6-89E5-72F273D4B0A3"
|
||||
|
||||
type downloadCheckpoint struct {
|
||||
Magic string // Magic
|
||||
MD5 string // Checkpoint content MD5
|
||||
FilePath string // Local file
|
||||
Object string // Key
|
||||
ObjStat objectStat // Object status
|
||||
Parts []downloadPart // All download parts
|
||||
PartStat []bool // Parts' download status
|
||||
Start int64 // Start point of the file
|
||||
End int64 // End point of the file
|
||||
enableCRC bool // Whether has CRC check
|
||||
CRC uint64 // CRC check value
|
||||
}
|
||||
|
||||
type objectStat struct {
|
||||
Size int64 // Object size
|
||||
LastModified string // Last modified time
|
||||
Etag string // Etag
|
||||
}
|
||||
|
||||
// isValid flags of checkpoint data is valid. It returns true when the data is valid and the checkpoint is valid and the object is not updated.
|
||||
func (cp downloadCheckpoint) isValid(meta http.Header, uRange *UnpackedRange) (bool, error) {
|
||||
// Compare the CP's Magic and the MD5
|
||||
cpb := cp
|
||||
cpb.MD5 = ""
|
||||
js, _ := json.Marshal(cpb)
|
||||
sum := md5.Sum(js)
|
||||
b64 := base64.StdEncoding.EncodeToString(sum[:])
|
||||
|
||||
if cp.Magic != downloadCpMagic || b64 != cp.MD5 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
objectSize, err := strconv.ParseInt(meta.Get(HTTPHeaderContentLength), 10, 64)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Compare the object size, last modified time and etag
|
||||
if cp.ObjStat.Size != objectSize ||
|
||||
cp.ObjStat.LastModified != meta.Get(HTTPHeaderLastModified) ||
|
||||
cp.ObjStat.Etag != meta.Get(HTTPHeaderEtag) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check the download range
|
||||
if uRange != nil {
|
||||
start, end := AdjustRange(uRange, objectSize)
|
||||
if start != cp.Start || end != cp.End {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// load checkpoint from local file
|
||||
func (cp *downloadCheckpoint) load(filePath string) error {
|
||||
contents, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(contents, cp)
|
||||
return err
|
||||
}
|
||||
|
||||
// dump funciton dumps to file
|
||||
func (cp *downloadCheckpoint) dump(filePath string) error {
|
||||
bcp := *cp
|
||||
|
||||
// Calculate MD5
|
||||
bcp.MD5 = ""
|
||||
js, err := json.Marshal(bcp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum := md5.Sum(js)
|
||||
b64 := base64.StdEncoding.EncodeToString(sum[:])
|
||||
bcp.MD5 = b64
|
||||
|
||||
// Serialize
|
||||
js, err = json.Marshal(bcp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Dump
|
||||
return ioutil.WriteFile(filePath, js, FilePermMode)
|
||||
}
|
||||
|
||||
// todoParts gets unfinished parts
|
||||
func (cp downloadCheckpoint) todoParts() []downloadPart {
|
||||
dps := []downloadPart{}
|
||||
for i, ps := range cp.PartStat {
|
||||
if !ps {
|
||||
dps = append(dps, cp.Parts[i])
|
||||
}
|
||||
}
|
||||
return dps
|
||||
}
|
||||
|
||||
// getCompletedBytes gets completed size
|
||||
func (cp downloadCheckpoint) getCompletedBytes() int64 {
|
||||
var completedBytes int64
|
||||
for i, part := range cp.Parts {
|
||||
if cp.PartStat[i] {
|
||||
completedBytes += (part.End - part.Start + 1)
|
||||
}
|
||||
}
|
||||
return completedBytes
|
||||
}
|
||||
|
||||
// prepare initiates download tasks
|
||||
func (cp *downloadCheckpoint) prepare(meta http.Header, bucket *Bucket, objectKey, filePath string, partSize int64, uRange *UnpackedRange) error {
|
||||
// CP
|
||||
cp.Magic = downloadCpMagic
|
||||
cp.FilePath = filePath
|
||||
cp.Object = objectKey
|
||||
|
||||
objectSize, err := strconv.ParseInt(meta.Get(HTTPHeaderContentLength), 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cp.ObjStat.Size = objectSize
|
||||
cp.ObjStat.LastModified = meta.Get(HTTPHeaderLastModified)
|
||||
cp.ObjStat.Etag = meta.Get(HTTPHeaderEtag)
|
||||
|
||||
if bucket.GetConfig().IsEnableCRC && meta.Get(HTTPHeaderOssCRC64) != "" {
|
||||
if uRange == nil || (!uRange.HasStart && !uRange.HasEnd) {
|
||||
cp.enableCRC = true
|
||||
cp.CRC, _ = strconv.ParseUint(meta.Get(HTTPHeaderOssCRC64), 10, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Parts
|
||||
cp.Parts = getDownloadParts(objectSize, partSize, uRange)
|
||||
cp.PartStat = make([]bool, len(cp.Parts))
|
||||
for i := range cp.PartStat {
|
||||
cp.PartStat[i] = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *downloadCheckpoint) complete(cpFilePath, downFilepath string) error {
|
||||
err := os.Rename(downFilepath, cp.FilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(cpFilePath)
|
||||
}
|
||||
|
||||
// downloadFileWithCp downloads files with checkpoint.
|
||||
func (bucket Bucket) downloadFileWithCp(objectKey, filePath string, partSize int64, options []Option, cpFilePath string, routines int, uRange *UnpackedRange) error {
|
||||
tempFilePath := filePath + TempFileSuffix
|
||||
listener := GetProgressListener(options)
|
||||
|
||||
// Load checkpoint data.
|
||||
dcp := downloadCheckpoint{}
|
||||
err := dcp.load(cpFilePath)
|
||||
if err != nil {
|
||||
os.Remove(cpFilePath)
|
||||
}
|
||||
|
||||
// Get the object detailed meta for object whole size
|
||||
// must delete header:range to get whole object size
|
||||
skipOptions := DeleteOption(options, HTTPHeaderRange)
|
||||
meta, err := bucket.GetObjectDetailedMeta(objectKey, skipOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load error or data invalid. Re-initialize the download.
|
||||
valid, err := dcp.isValid(meta, uRange)
|
||||
if err != nil || !valid {
|
||||
if err = dcp.prepare(meta, &bucket, objectKey, filePath, partSize, uRange); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(cpFilePath)
|
||||
}
|
||||
|
||||
// Create the file if not exists. Otherwise the parts download will overwrite it.
|
||||
fd, err := os.OpenFile(tempFilePath, os.O_WRONLY|os.O_CREATE, FilePermMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fd.Close()
|
||||
|
||||
// Unfinished parts
|
||||
parts := dcp.todoParts()
|
||||
jobs := make(chan downloadPart, len(parts))
|
||||
results := make(chan downloadPart, len(parts))
|
||||
failed := make(chan error)
|
||||
die := make(chan bool)
|
||||
|
||||
completedBytes := dcp.getCompletedBytes()
|
||||
event := newProgressEvent(TransferStartedEvent, completedBytes, dcp.ObjStat.Size, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
// Start the download workers routine
|
||||
arg := downloadWorkerArg{&bucket, objectKey, tempFilePath, options, downloadPartHooker, dcp.enableCRC}
|
||||
for w := 1; w <= routines; w++ {
|
||||
go downloadWorker(w, arg, jobs, results, failed, die)
|
||||
}
|
||||
|
||||
// Concurrently downloads parts
|
||||
go downloadScheduler(jobs, parts)
|
||||
|
||||
// Wait for the parts download finished
|
||||
completed := 0
|
||||
for completed < len(parts) {
|
||||
select {
|
||||
case part := <-results:
|
||||
completed++
|
||||
dcp.PartStat[part.Index] = true
|
||||
dcp.Parts[part.Index].CRC64 = part.CRC64
|
||||
dcp.dump(cpFilePath)
|
||||
downBytes := (part.End - part.Start + 1)
|
||||
completedBytes += downBytes
|
||||
event = newProgressEvent(TransferDataEvent, completedBytes, dcp.ObjStat.Size, downBytes)
|
||||
publishProgress(listener, event)
|
||||
case err := <-failed:
|
||||
close(die)
|
||||
event = newProgressEvent(TransferFailedEvent, completedBytes, dcp.ObjStat.Size, 0)
|
||||
publishProgress(listener, event)
|
||||
return err
|
||||
}
|
||||
|
||||
if completed >= len(parts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
event = newProgressEvent(TransferCompletedEvent, completedBytes, dcp.ObjStat.Size, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
if dcp.enableCRC {
|
||||
actualCRC := combineCRCInParts(dcp.Parts)
|
||||
err = CheckDownloadCRC(actualCRC, dcp.CRC)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return dcp.complete(cpFilePath, tempFilePath)
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ServiceError contains fields of the error response from Oss Service REST API.
|
||||
type ServiceError struct {
|
||||
XMLName xml.Name `xml:"Error"`
|
||||
Code string `xml:"Code"` // The error code returned from OSS to the caller
|
||||
Message string `xml:"Message"` // The detail error message from OSS
|
||||
RequestID string `xml:"RequestId"` // The UUID used to uniquely identify the request
|
||||
HostID string `xml:"HostId"` // The OSS server cluster's Id
|
||||
Endpoint string `xml:"Endpoint"`
|
||||
Ec string `xml:"EC"`
|
||||
RawMessage string // The raw messages from OSS
|
||||
StatusCode int // HTTP status code
|
||||
|
||||
}
|
||||
|
||||
// Error implements interface error
|
||||
func (e ServiceError) Error() string {
|
||||
errorStr := fmt.Sprintf("oss: service returned error: StatusCode=%d, ErrorCode=%s, ErrorMessage=\"%s\", RequestId=%s", e.StatusCode, e.Code, e.Message, e.RequestID)
|
||||
if len(e.Endpoint) > 0 {
|
||||
errorStr = fmt.Sprintf("%s, Endpoint=%s", errorStr, e.Endpoint)
|
||||
}
|
||||
if len(e.Ec) > 0 {
|
||||
errorStr = fmt.Sprintf("%s, Ec=%s", errorStr, e.Ec)
|
||||
}
|
||||
return errorStr
|
||||
}
|
||||
|
||||
// UnexpectedStatusCodeError is returned when a storage service responds with neither an error
|
||||
// nor with an HTTP status code indicating success.
|
||||
type UnexpectedStatusCodeError struct {
|
||||
allowed []int // The expected HTTP stats code returned from OSS
|
||||
got int // The actual HTTP status code from OSS
|
||||
}
|
||||
|
||||
// Error implements interface error
|
||||
func (e UnexpectedStatusCodeError) Error() string {
|
||||
s := func(i int) string { return fmt.Sprintf("%d %s", i, http.StatusText(i)) }
|
||||
|
||||
got := s(e.got)
|
||||
expected := []string{}
|
||||
for _, v := range e.allowed {
|
||||
expected = append(expected, s(v))
|
||||
}
|
||||
return fmt.Sprintf("oss: status code from service response is %s; was expecting %s",
|
||||
got, strings.Join(expected, " or "))
|
||||
}
|
||||
|
||||
// Got is the actual status code returned by oss.
|
||||
func (e UnexpectedStatusCodeError) Got() int {
|
||||
return e.got
|
||||
}
|
||||
|
||||
// CheckRespCode returns UnexpectedStatusError if the given response code is not
|
||||
// one of the allowed status codes; otherwise nil.
|
||||
func CheckRespCode(respCode int, allowed []int) error {
|
||||
for _, v := range allowed {
|
||||
if respCode == v {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return UnexpectedStatusCodeError{allowed, respCode}
|
||||
}
|
||||
|
||||
// CheckCallbackResp return error if the given response code is not 200
|
||||
func CheckCallbackResp(resp *Response) error {
|
||||
var err error
|
||||
contentLengthStr := resp.Headers.Get("Content-Length")
|
||||
contentLength, _ := strconv.Atoi(contentLengthStr)
|
||||
var bodyBytes []byte
|
||||
if contentLength > 0 {
|
||||
bodyBytes, _ = ioutil.ReadAll(resp.Body)
|
||||
}
|
||||
if len(bodyBytes) > 0 {
|
||||
srvErr, errIn := serviceErrFromXML(bodyBytes, resp.StatusCode,
|
||||
resp.Headers.Get(HTTPHeaderOssRequestID))
|
||||
if errIn != nil {
|
||||
if len(resp.Headers.Get(HTTPHeaderOssEc)) > 0 {
|
||||
err = fmt.Errorf("unknown response body, status code = %d, RequestId = %s, ec = %s", resp.StatusCode, resp.Headers.Get(HTTPHeaderOssRequestID), resp.Headers.Get(HTTPHeaderOssEc))
|
||||
} else {
|
||||
err = fmt.Errorf("unknown response body, status code= %d, RequestId = %s", resp.StatusCode, resp.Headers.Get(HTTPHeaderOssRequestID))
|
||||
}
|
||||
} else {
|
||||
err = srvErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func tryConvertServiceError(data []byte, resp *Response, def error) (err error) {
|
||||
err = def
|
||||
if len(data) > 0 {
|
||||
srvErr, errIn := serviceErrFromXML(data, resp.StatusCode, resp.Headers.Get(HTTPHeaderOssRequestID))
|
||||
if errIn == nil {
|
||||
err = srvErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CRCCheckError is returned when crc check is inconsistent between client and server
|
||||
type CRCCheckError struct {
|
||||
clientCRC uint64 // Calculated CRC64 in client
|
||||
serverCRC uint64 // Calculated CRC64 in server
|
||||
operation string // Upload operations such as PutObject/AppendObject/UploadPart, etc
|
||||
requestID string // The request id of this operation
|
||||
}
|
||||
|
||||
// Error implements interface error
|
||||
func (e CRCCheckError) Error() string {
|
||||
return fmt.Sprintf("oss: the crc of %s is inconsistent, client %d but server %d; request id is %s",
|
||||
e.operation, e.clientCRC, e.serverCRC, e.requestID)
|
||||
}
|
||||
|
||||
func CheckDownloadCRC(clientCRC, serverCRC uint64) error {
|
||||
if clientCRC == serverCRC {
|
||||
return nil
|
||||
}
|
||||
return CRCCheckError{clientCRC, serverCRC, "DownloadFile", ""}
|
||||
}
|
||||
|
||||
func CheckCRC(resp *Response, operation string) error {
|
||||
if resp.Headers.Get(HTTPHeaderOssCRC64) == "" || resp.ClientCRC == resp.ServerCRC {
|
||||
return nil
|
||||
}
|
||||
return CRCCheckError{resp.ClientCRC, resp.ServerCRC, operation, resp.Headers.Get(HTTPHeaderOssRequestID)}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
//go:build !go1.7
|
||||
// +build !go1.7
|
||||
|
||||
// "golang.org/x/time/rate" is depended on golang context package go1.7 onward
|
||||
// this file is only for build,not supports limit upload speed
|
||||
package oss
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
perTokenBandwidthSize int = 1024
|
||||
)
|
||||
|
||||
type OssLimiter struct {
|
||||
}
|
||||
|
||||
type LimitSpeedReader struct {
|
||||
io.ReadCloser
|
||||
reader io.Reader
|
||||
ossLimiter *OssLimiter
|
||||
}
|
||||
|
||||
func GetOssLimiter(uploadSpeed int) (ossLimiter *OssLimiter, err error) {
|
||||
err = fmt.Errorf("rate.Limiter is not supported below version go1.7")
|
||||
return nil, err
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
//go:build go1.7
|
||||
// +build go1.7
|
||||
|
||||
package oss
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
perTokenBandwidthSize int = 1024
|
||||
)
|
||||
|
||||
// OssLimiter wrapper rate.Limiter
|
||||
type OssLimiter struct {
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
// GetOssLimiter create OssLimiter
|
||||
// uploadSpeed KB/s
|
||||
func GetOssLimiter(uploadSpeed int) (ossLimiter *OssLimiter, err error) {
|
||||
limiter := rate.NewLimiter(rate.Limit(uploadSpeed), uploadSpeed)
|
||||
|
||||
// first consume the initial full token,the limiter will behave more accurately
|
||||
limiter.AllowN(time.Now(), uploadSpeed)
|
||||
|
||||
return &OssLimiter{
|
||||
limiter: limiter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LimitSpeedReader for limit bandwidth upload
|
||||
type LimitSpeedReader struct {
|
||||
io.ReadCloser
|
||||
reader io.Reader
|
||||
ossLimiter *OssLimiter
|
||||
}
|
||||
|
||||
// Read
|
||||
func (r *LimitSpeedReader) Read(p []byte) (n int, err error) {
|
||||
n = 0
|
||||
err = nil
|
||||
start := 0
|
||||
burst := r.ossLimiter.limiter.Burst()
|
||||
var end int
|
||||
var tmpN int
|
||||
var tc int
|
||||
for start < len(p) {
|
||||
if start+burst*perTokenBandwidthSize < len(p) {
|
||||
end = start + burst*perTokenBandwidthSize
|
||||
} else {
|
||||
end = len(p)
|
||||
}
|
||||
|
||||
tmpN, err = r.reader.Read(p[start:end])
|
||||
if tmpN > 0 {
|
||||
n += tmpN
|
||||
start = n
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tc = int(math.Ceil(float64(tmpN) / float64(perTokenBandwidthSize)))
|
||||
now := time.Now()
|
||||
re := r.ossLimiter.limiter.ReserveN(now, tc)
|
||||
if !re.OK() {
|
||||
err = fmt.Errorf("LimitSpeedReader.Read() failure,ReserveN error,start:%d,end:%d,burst:%d,perTokenBandwidthSize:%d",
|
||||
start, end, burst, perTokenBandwidthSize)
|
||||
return
|
||||
}
|
||||
timeDelay := re.Delay()
|
||||
time.Sleep(timeDelay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Close ...
|
||||
func (r *LimitSpeedReader) Close() error {
|
||||
rc, ok := r.reader.(io.ReadCloser)
|
||||
if ok {
|
||||
return rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
//
|
||||
// CreateLiveChannel create a live-channel
|
||||
//
|
||||
// channelName the name of the channel
|
||||
// config configuration of the channel
|
||||
//
|
||||
// CreateLiveChannelResult the result of create live-channel
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) CreateLiveChannel(channelName string, config LiveChannelConfiguration) (CreateLiveChannelResult, error) {
|
||||
var out CreateLiveChannelResult
|
||||
|
||||
bs, err := xml.Marshal(config)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
buffer := new(bytes.Buffer)
|
||||
buffer.Write(bs)
|
||||
|
||||
params := map[string]interface{}{}
|
||||
params["live"] = nil
|
||||
resp, err := bucket.do("PUT", channelName, params, nil, buffer, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
//
|
||||
// PutLiveChannelStatus Set the status of the live-channel: enabled/disabled
|
||||
//
|
||||
// channelName the name of the channel
|
||||
// status enabled/disabled
|
||||
//
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) PutLiveChannelStatus(channelName, status string) error {
|
||||
params := map[string]interface{}{}
|
||||
params["live"] = nil
|
||||
params["status"] = status
|
||||
|
||||
resp, err := bucket.do("PUT", channelName, params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return CheckRespCode(resp.StatusCode, []int{http.StatusOK})
|
||||
}
|
||||
|
||||
// PostVodPlaylist create an playlist based on the specified playlist name, startTime and endTime
|
||||
//
|
||||
// channelName the name of the channel
|
||||
// playlistName the name of the playlist, must end with ".m3u8"
|
||||
// startTime the start time of the playlist
|
||||
// endTime the endtime of the playlist
|
||||
//
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) PostVodPlaylist(channelName, playlistName string, startTime, endTime time.Time) error {
|
||||
params := map[string]interface{}{}
|
||||
params["vod"] = nil
|
||||
params["startTime"] = strconv.FormatInt(startTime.Unix(), 10)
|
||||
params["endTime"] = strconv.FormatInt(endTime.Unix(), 10)
|
||||
|
||||
key := fmt.Sprintf("%s/%s", channelName, playlistName)
|
||||
resp, err := bucket.do("POST", key, params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return CheckRespCode(resp.StatusCode, []int{http.StatusOK})
|
||||
}
|
||||
|
||||
// GetVodPlaylist get the playlist based on the specified channelName, startTime and endTime
|
||||
//
|
||||
// channelName the name of the channel
|
||||
// startTime the start time of the playlist
|
||||
// endTime the endtime of the playlist
|
||||
//
|
||||
// io.ReadCloser reader instance for reading data from response. It must be called close() after the usage and only valid when error is nil.
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) GetVodPlaylist(channelName string, startTime, endTime time.Time) (io.ReadCloser, error) {
|
||||
params := map[string]interface{}{}
|
||||
params["vod"] = nil
|
||||
params["startTime"] = strconv.FormatInt(startTime.Unix(), 10)
|
||||
params["endTime"] = strconv.FormatInt(endTime.Unix(), 10)
|
||||
|
||||
resp, err := bucket.do("GET", channelName, params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
//
|
||||
// GetLiveChannelStat Get the state of the live-channel
|
||||
//
|
||||
// channelName the name of the channel
|
||||
//
|
||||
// LiveChannelStat the state of the live-channel
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) GetLiveChannelStat(channelName string) (LiveChannelStat, error) {
|
||||
var out LiveChannelStat
|
||||
params := map[string]interface{}{}
|
||||
params["live"] = nil
|
||||
params["comp"] = "stat"
|
||||
|
||||
resp, err := bucket.do("GET", channelName, params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
//
|
||||
// GetLiveChannelInfo Get the configuration info of the live-channel
|
||||
//
|
||||
// channelName the name of the channel
|
||||
//
|
||||
// LiveChannelConfiguration the configuration info of the live-channel
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) GetLiveChannelInfo(channelName string) (LiveChannelConfiguration, error) {
|
||||
var out LiveChannelConfiguration
|
||||
params := map[string]interface{}{}
|
||||
params["live"] = nil
|
||||
|
||||
resp, err := bucket.do("GET", channelName, params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
//
|
||||
// GetLiveChannelHistory Get push records of live-channel
|
||||
//
|
||||
// channelName the name of the channel
|
||||
//
|
||||
// LiveChannelHistory push records
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) GetLiveChannelHistory(channelName string) (LiveChannelHistory, error) {
|
||||
var out LiveChannelHistory
|
||||
params := map[string]interface{}{}
|
||||
params["live"] = nil
|
||||
params["comp"] = "history"
|
||||
|
||||
resp, err := bucket.do("GET", channelName, params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
//
|
||||
// ListLiveChannel list the live-channels
|
||||
//
|
||||
// options Prefix: filter by the name start with the value of "Prefix"
|
||||
// MaxKeys: the maximum count returned
|
||||
// Marker: cursor from which starting list
|
||||
//
|
||||
// ListLiveChannelResult live-channel list
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) ListLiveChannel(options ...Option) (ListLiveChannelResult, error) {
|
||||
var out ListLiveChannelResult
|
||||
|
||||
params, err := GetRawParams(options)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
params["live"] = nil
|
||||
|
||||
resp, err := bucket.doInner("GET", "", params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
//
|
||||
// DeleteLiveChannel Delete the live-channel. When a client trying to stream the live-channel, the operation will fail. it will only delete the live-channel itself and the object generated by the live-channel will not be deleted.
|
||||
//
|
||||
// channelName the name of the channel
|
||||
//
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) DeleteLiveChannel(channelName string) error {
|
||||
params := map[string]interface{}{}
|
||||
params["live"] = nil
|
||||
|
||||
if channelName == "" {
|
||||
return fmt.Errorf("invalid argument: channel name is empty")
|
||||
}
|
||||
|
||||
resp, err := bucket.do("DELETE", channelName, params, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return CheckRespCode(resp.StatusCode, []int{http.StatusNoContent})
|
||||
}
|
||||
|
||||
//
|
||||
// SignRtmpURL Generate a RTMP push-stream signature URL for the trusted user to push the RTMP stream to the live-channel.
|
||||
//
|
||||
// channelName the name of the channel
|
||||
// playlistName the name of the playlist, must end with ".m3u8"
|
||||
// expires expiration (in seconds)
|
||||
//
|
||||
// string singed rtmp push stream url
|
||||
// error nil if success, otherwise error
|
||||
//
|
||||
func (bucket Bucket) SignRtmpURL(channelName, playlistName string, expires int64) (string, error) {
|
||||
if expires <= 0 {
|
||||
return "", fmt.Errorf("invalid argument: %d, expires must greater than 0", expires)
|
||||
}
|
||||
expiration := time.Now().Unix() + expires
|
||||
|
||||
return bucket.Client.Conn.signRtmpURL(bucket.BucketName, channelName, playlistName, expiration), nil
|
||||
}
|
||||
+594
@@ -0,0 +1,594 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var extToMimeType = map[string]string{
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
|
||||
".potx": "application/vnd.openxmlformats-officedocument.presentationml.template",
|
||||
".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
|
||||
".xlam": "application/vnd.ms-excel.addin.macroEnabled.12",
|
||||
".xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
|
||||
".apk": "application/vnd.android.package-archive",
|
||||
".hqx": "application/mac-binhex40",
|
||||
".cpt": "application/mac-compactpro",
|
||||
".doc": "application/msword",
|
||||
".ogg": "application/ogg",
|
||||
".pdf": "application/pdf",
|
||||
".rtf": "text/rtf",
|
||||
".mif": "application/vnd.mif",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".odc": "application/vnd.oasis.opendocument.chart",
|
||||
".odb": "application/vnd.oasis.opendocument.database",
|
||||
".odf": "application/vnd.oasis.opendocument.formula",
|
||||
".odg": "application/vnd.oasis.opendocument.graphics",
|
||||
".otg": "application/vnd.oasis.opendocument.graphics-template",
|
||||
".odi": "application/vnd.oasis.opendocument.image",
|
||||
".odp": "application/vnd.oasis.opendocument.presentation",
|
||||
".otp": "application/vnd.oasis.opendocument.presentation-template",
|
||||
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
".ots": "application/vnd.oasis.opendocument.spreadsheet-template",
|
||||
".odt": "application/vnd.oasis.opendocument.text",
|
||||
".odm": "application/vnd.oasis.opendocument.text-master",
|
||||
".ott": "application/vnd.oasis.opendocument.text-template",
|
||||
".oth": "application/vnd.oasis.opendocument.text-web",
|
||||
".sxw": "application/vnd.sun.xml.writer",
|
||||
".stw": "application/vnd.sun.xml.writer.template",
|
||||
".sxc": "application/vnd.sun.xml.calc",
|
||||
".stc": "application/vnd.sun.xml.calc.template",
|
||||
".sxd": "application/vnd.sun.xml.draw",
|
||||
".std": "application/vnd.sun.xml.draw.template",
|
||||
".sxi": "application/vnd.sun.xml.impress",
|
||||
".sti": "application/vnd.sun.xml.impress.template",
|
||||
".sxg": "application/vnd.sun.xml.writer.global",
|
||||
".sxm": "application/vnd.sun.xml.math",
|
||||
".sis": "application/vnd.symbian.install",
|
||||
".wbxml": "application/vnd.wap.wbxml",
|
||||
".wmlc": "application/vnd.wap.wmlc",
|
||||
".wmlsc": "application/vnd.wap.wmlscriptc",
|
||||
".bcpio": "application/x-bcpio",
|
||||
".torrent": "application/x-bittorrent",
|
||||
".bz2": "application/x-bzip2",
|
||||
".vcd": "application/x-cdlink",
|
||||
".pgn": "application/x-chess-pgn",
|
||||
".cpio": "application/x-cpio",
|
||||
".csh": "application/x-csh",
|
||||
".dvi": "application/x-dvi",
|
||||
".spl": "application/x-futuresplash",
|
||||
".gtar": "application/x-gtar",
|
||||
".hdf": "application/x-hdf",
|
||||
".jar": "application/x-java-archive",
|
||||
".jnlp": "application/x-java-jnlp-file",
|
||||
".js": "application/x-javascript",
|
||||
".ksp": "application/x-kspread",
|
||||
".chrt": "application/x-kchart",
|
||||
".kil": "application/x-killustrator",
|
||||
".latex": "application/x-latex",
|
||||
".rpm": "application/x-rpm",
|
||||
".sh": "application/x-sh",
|
||||
".shar": "application/x-shar",
|
||||
".swf": "application/x-shockwave-flash",
|
||||
".sit": "application/x-stuffit",
|
||||
".sv4cpio": "application/x-sv4cpio",
|
||||
".sv4crc": "application/x-sv4crc",
|
||||
".tar": "application/x-tar",
|
||||
".tcl": "application/x-tcl",
|
||||
".tex": "application/x-tex",
|
||||
".man": "application/x-troff-man",
|
||||
".me": "application/x-troff-me",
|
||||
".ms": "application/x-troff-ms",
|
||||
".ustar": "application/x-ustar",
|
||||
".src": "application/x-wais-source",
|
||||
".zip": "application/zip",
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".ra": "audio/x-pn-realaudio",
|
||||
".wav": "audio/x-wav",
|
||||
".wma": "audio/x-ms-wma",
|
||||
".wax": "audio/x-ms-wax",
|
||||
".pdb": "chemical/x-pdb",
|
||||
".xyz": "chemical/x-xyz",
|
||||
".bmp": "image/bmp",
|
||||
".gif": "image/gif",
|
||||
".ief": "image/ief",
|
||||
".png": "image/png",
|
||||
".wbmp": "image/vnd.wap.wbmp",
|
||||
".ras": "image/x-cmu-raster",
|
||||
".pnm": "image/x-portable-anymap",
|
||||
".pbm": "image/x-portable-bitmap",
|
||||
".pgm": "image/x-portable-graymap",
|
||||
".ppm": "image/x-portable-pixmap",
|
||||
".rgb": "image/x-rgb",
|
||||
".xbm": "image/x-xbitmap",
|
||||
".xpm": "image/x-xpixmap",
|
||||
".xwd": "image/x-xwindowdump",
|
||||
".css": "text/css",
|
||||
".rtx": "text/richtext",
|
||||
".tsv": "text/tab-separated-values",
|
||||
".jad": "text/vnd.sun.j2me.app-descriptor",
|
||||
".wml": "text/vnd.wap.wml",
|
||||
".wmls": "text/vnd.wap.wmlscript",
|
||||
".etx": "text/x-setext",
|
||||
".mxu": "video/vnd.mpegurl",
|
||||
".flv": "video/x-flv",
|
||||
".wm": "video/x-ms-wm",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".wmx": "video/x-ms-wmx",
|
||||
".wvx": "video/x-ms-wvx",
|
||||
".avi": "video/x-msvideo",
|
||||
".movie": "video/x-sgi-movie",
|
||||
".ice": "x-conference/x-cooltalk",
|
||||
".3gp": "video/3gpp",
|
||||
".ai": "application/postscript",
|
||||
".aif": "audio/x-aiff",
|
||||
".aifc": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".asc": "text/plain",
|
||||
".atom": "application/atom+xml",
|
||||
".au": "audio/basic",
|
||||
".bin": "application/octet-stream",
|
||||
".cdf": "application/x-netcdf",
|
||||
".cgm": "image/cgm",
|
||||
".class": "application/octet-stream",
|
||||
".dcr": "application/x-director",
|
||||
".dif": "video/x-dv",
|
||||
".dir": "application/x-director",
|
||||
".djv": "image/vnd.djvu",
|
||||
".djvu": "image/vnd.djvu",
|
||||
".dll": "application/octet-stream",
|
||||
".dmg": "application/octet-stream",
|
||||
".dms": "application/octet-stream",
|
||||
".dtd": "application/xml-dtd",
|
||||
".dv": "video/x-dv",
|
||||
".dxr": "application/x-director",
|
||||
".eps": "application/postscript",
|
||||
".exe": "application/octet-stream",
|
||||
".ez": "application/andrew-inset",
|
||||
".gram": "application/srgs",
|
||||
".grxml": "application/srgs+xml",
|
||||
".gz": "application/x-gzip",
|
||||
".htm": "text/html",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".ics": "text/calendar",
|
||||
".ifb": "text/calendar",
|
||||
".iges": "model/iges",
|
||||
".igs": "model/iges",
|
||||
".jp2": "image/jp2",
|
||||
".jpe": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".kar": "audio/midi",
|
||||
".lha": "application/octet-stream",
|
||||
".lzh": "application/octet-stream",
|
||||
".m4a": "audio/mp4a-latm",
|
||||
".m4p": "audio/mp4a-latm",
|
||||
".m4u": "video/vnd.mpegurl",
|
||||
".m4v": "video/x-m4v",
|
||||
".mac": "image/x-macpaint",
|
||||
".mathml": "application/mathml+xml",
|
||||
".mesh": "model/mesh",
|
||||
".mid": "audio/midi",
|
||||
".midi": "audio/midi",
|
||||
".mov": "video/quicktime",
|
||||
".mp2": "audio/mpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mpe": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".mpga": "audio/mpeg",
|
||||
".msh": "model/mesh",
|
||||
".nc": "application/x-netcdf",
|
||||
".oda": "application/oda",
|
||||
".ogv": "video/ogv",
|
||||
".pct": "image/pict",
|
||||
".pic": "image/pict",
|
||||
".pict": "image/pict",
|
||||
".pnt": "image/x-macpaint",
|
||||
".pntg": "image/x-macpaint",
|
||||
".ps": "application/postscript",
|
||||
".qt": "video/quicktime",
|
||||
".qti": "image/x-quicktime",
|
||||
".qtif": "image/x-quicktime",
|
||||
".ram": "audio/x-pn-realaudio",
|
||||
".rdf": "application/rdf+xml",
|
||||
".rm": "application/vnd.rn-realmedia",
|
||||
".roff": "application/x-troff",
|
||||
".sgm": "text/sgml",
|
||||
".sgml": "text/sgml",
|
||||
".silo": "model/mesh",
|
||||
".skd": "application/x-koan",
|
||||
".skm": "application/x-koan",
|
||||
".skp": "application/x-koan",
|
||||
".skt": "application/x-koan",
|
||||
".smi": "application/smil",
|
||||
".smil": "application/smil",
|
||||
".snd": "audio/basic",
|
||||
".so": "application/octet-stream",
|
||||
".svg": "image/svg+xml",
|
||||
".t": "application/x-troff",
|
||||
".texi": "application/x-texinfo",
|
||||
".texinfo": "application/x-texinfo",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".tr": "application/x-troff",
|
||||
".txt": "text/plain",
|
||||
".vrml": "model/vrml",
|
||||
".vxml": "application/voicexml+xml",
|
||||
".webm": "video/webm",
|
||||
".wrl": "model/vrml",
|
||||
".xht": "application/xhtml+xml",
|
||||
".xhtml": "application/xhtml+xml",
|
||||
".xml": "application/xml",
|
||||
".xsl": "application/xml",
|
||||
".xslt": "application/xslt+xml",
|
||||
".xul": "application/vnd.mozilla.xul+xml",
|
||||
".webp": "image/webp",
|
||||
".323": "text/h323",
|
||||
".aab": "application/x-authoware-bin",
|
||||
".aam": "application/x-authoware-map",
|
||||
".aas": "application/x-authoware-seg",
|
||||
".acx": "application/internet-property-stream",
|
||||
".als": "audio/X-Alpha5",
|
||||
".amc": "application/x-mpeg",
|
||||
".ani": "application/octet-stream",
|
||||
".asd": "application/astound",
|
||||
".asf": "video/x-ms-asf",
|
||||
".asn": "application/astound",
|
||||
".asp": "application/x-asap",
|
||||
".asr": "video/x-ms-asf",
|
||||
".asx": "video/x-ms-asf",
|
||||
".avb": "application/octet-stream",
|
||||
".awb": "audio/amr-wb",
|
||||
".axs": "application/olescript",
|
||||
".bas": "text/plain",
|
||||
".bin ": "application/octet-stream",
|
||||
".bld": "application/bld",
|
||||
".bld2": "application/bld2",
|
||||
".bpk": "application/octet-stream",
|
||||
".c": "text/plain",
|
||||
".cal": "image/x-cals",
|
||||
".cat": "application/vnd.ms-pkiseccat",
|
||||
".ccn": "application/x-cnc",
|
||||
".cco": "application/x-cocoa",
|
||||
".cer": "application/x-x509-ca-cert",
|
||||
".cgi": "magnus-internal/cgi",
|
||||
".chat": "application/x-chat",
|
||||
".clp": "application/x-msclip",
|
||||
".cmx": "image/x-cmx",
|
||||
".co": "application/x-cult3d-object",
|
||||
".cod": "image/cis-cod",
|
||||
".conf": "text/plain",
|
||||
".cpp": "text/plain",
|
||||
".crd": "application/x-mscardfile",
|
||||
".crl": "application/pkix-crl",
|
||||
".crt": "application/x-x509-ca-cert",
|
||||
".csm": "chemical/x-csml",
|
||||
".csml": "chemical/x-csml",
|
||||
".cur": "application/octet-stream",
|
||||
".dcm": "x-lml/x-evm",
|
||||
".dcx": "image/x-dcx",
|
||||
".der": "application/x-x509-ca-cert",
|
||||
".dhtml": "text/html",
|
||||
".dot": "application/msword",
|
||||
".dwf": "drawing/x-dwf",
|
||||
".dwg": "application/x-autocad",
|
||||
".dxf": "application/x-autocad",
|
||||
".ebk": "application/x-expandedbook",
|
||||
".emb": "chemical/x-embl-dl-nucleotide",
|
||||
".embl": "chemical/x-embl-dl-nucleotide",
|
||||
".epub": "application/epub+zip",
|
||||
".eri": "image/x-eri",
|
||||
".es": "audio/echospeech",
|
||||
".esl": "audio/echospeech",
|
||||
".etc": "application/x-earthtime",
|
||||
".evm": "x-lml/x-evm",
|
||||
".evy": "application/envoy",
|
||||
".fh4": "image/x-freehand",
|
||||
".fh5": "image/x-freehand",
|
||||
".fhc": "image/x-freehand",
|
||||
".fif": "application/fractals",
|
||||
".flr": "x-world/x-vrml",
|
||||
".fm": "application/x-maker",
|
||||
".fpx": "image/x-fpx",
|
||||
".fvi": "video/isivideo",
|
||||
".gau": "chemical/x-gaussian-input",
|
||||
".gca": "application/x-gca-compressed",
|
||||
".gdb": "x-lml/x-gdb",
|
||||
".gps": "application/x-gps",
|
||||
".h": "text/plain",
|
||||
".hdm": "text/x-hdml",
|
||||
".hdml": "text/x-hdml",
|
||||
".hlp": "application/winhlp",
|
||||
".hta": "application/hta",
|
||||
".htc": "text/x-component",
|
||||
".hts": "text/html",
|
||||
".htt": "text/webviewhtml",
|
||||
".ifm": "image/gif",
|
||||
".ifs": "image/ifs",
|
||||
".iii": "application/x-iphone",
|
||||
".imy": "audio/melody",
|
||||
".ins": "application/x-internet-signup",
|
||||
".ips": "application/x-ipscript",
|
||||
".ipx": "application/x-ipix",
|
||||
".isp": "application/x-internet-signup",
|
||||
".it": "audio/x-mod",
|
||||
".itz": "audio/x-mod",
|
||||
".ivr": "i-world/i-vrml",
|
||||
".j2k": "image/j2k",
|
||||
".jam": "application/x-jam",
|
||||
".java": "text/plain",
|
||||
".jfif": "image/pipeg",
|
||||
".jpz": "image/jpeg",
|
||||
".jwc": "application/jwc",
|
||||
".kjx": "application/x-kjx",
|
||||
".lak": "x-lml/x-lak",
|
||||
".lcc": "application/fastman",
|
||||
".lcl": "application/x-digitalloca",
|
||||
".lcr": "application/x-digitalloca",
|
||||
".lgh": "application/lgh",
|
||||
".lml": "x-lml/x-lml",
|
||||
".lmlpack": "x-lml/x-lmlpack",
|
||||
".log": "text/plain",
|
||||
".lsf": "video/x-la-asf",
|
||||
".lsx": "video/x-la-asf",
|
||||
".m13": "application/x-msmediaview",
|
||||
".m14": "application/x-msmediaview",
|
||||
".m15": "audio/x-mod",
|
||||
".m3url": "audio/x-mpegurl",
|
||||
".m4b": "audio/mp4a-latm",
|
||||
".ma1": "audio/ma1",
|
||||
".ma2": "audio/ma2",
|
||||
".ma3": "audio/ma3",
|
||||
".ma5": "audio/ma5",
|
||||
".map": "magnus-internal/imagemap",
|
||||
".mbd": "application/mbedlet",
|
||||
".mct": "application/x-mascot",
|
||||
".mdb": "application/x-msaccess",
|
||||
".mdz": "audio/x-mod",
|
||||
".mel": "text/x-vmel",
|
||||
".mht": "message/rfc822",
|
||||
".mhtml": "message/rfc822",
|
||||
".mi": "application/x-mif",
|
||||
".mil": "image/x-cals",
|
||||
".mio": "audio/x-mio",
|
||||
".mmf": "application/x-skt-lbs",
|
||||
".mng": "video/x-mng",
|
||||
".mny": "application/x-msmoney",
|
||||
".moc": "application/x-mocha",
|
||||
".mocha": "application/x-mocha",
|
||||
".mod": "audio/x-mod",
|
||||
".mof": "application/x-yumekara",
|
||||
".mol": "chemical/x-mdl-molfile",
|
||||
".mop": "chemical/x-mopac-input",
|
||||
".mpa": "video/mpeg",
|
||||
".mpc": "application/vnd.mpohun.certificate",
|
||||
".mpg4": "video/mp4",
|
||||
".mpn": "application/vnd.mophun.application",
|
||||
".mpp": "application/vnd.ms-project",
|
||||
".mps": "application/x-mapserver",
|
||||
".mpv2": "video/mpeg",
|
||||
".mrl": "text/x-mrml",
|
||||
".mrm": "application/x-mrm",
|
||||
".msg": "application/vnd.ms-outlook",
|
||||
".mts": "application/metastream",
|
||||
".mtx": "application/metastream",
|
||||
".mtz": "application/metastream",
|
||||
".mvb": "application/x-msmediaview",
|
||||
".mzv": "application/metastream",
|
||||
".nar": "application/zip",
|
||||
".nbmp": "image/nbmp",
|
||||
".ndb": "x-lml/x-ndb",
|
||||
".ndwn": "application/ndwn",
|
||||
".nif": "application/x-nif",
|
||||
".nmz": "application/x-scream",
|
||||
".nokia-op-logo": "image/vnd.nok-oplogo-color",
|
||||
".npx": "application/x-netfpx",
|
||||
".nsnd": "audio/nsnd",
|
||||
".nva": "application/x-neva1",
|
||||
".nws": "message/rfc822",
|
||||
".oom": "application/x-AtlasMate-Plugin",
|
||||
".p10": "application/pkcs10",
|
||||
".p12": "application/x-pkcs12",
|
||||
".p7b": "application/x-pkcs7-certificates",
|
||||
".p7c": "application/x-pkcs7-mime",
|
||||
".p7m": "application/x-pkcs7-mime",
|
||||
".p7r": "application/x-pkcs7-certreqresp",
|
||||
".p7s": "application/x-pkcs7-signature",
|
||||
".pac": "audio/x-pac",
|
||||
".pae": "audio/x-epac",
|
||||
".pan": "application/x-pan",
|
||||
".pcx": "image/x-pcx",
|
||||
".pda": "image/x-pda",
|
||||
".pfr": "application/font-tdpfr",
|
||||
".pfx": "application/x-pkcs12",
|
||||
".pko": "application/ynd.ms-pkipko",
|
||||
".pm": "application/x-perl",
|
||||
".pma": "application/x-perfmon",
|
||||
".pmc": "application/x-perfmon",
|
||||
".pmd": "application/x-pmd",
|
||||
".pml": "application/x-perfmon",
|
||||
".pmr": "application/x-perfmon",
|
||||
".pmw": "application/x-perfmon",
|
||||
".pnz": "image/png",
|
||||
".pot,": "application/vnd.ms-powerpoint",
|
||||
".pps": "application/vnd.ms-powerpoint",
|
||||
".pqf": "application/x-cprplayer",
|
||||
".pqi": "application/cprplayer",
|
||||
".prc": "application/x-prc",
|
||||
".prf": "application/pics-rules",
|
||||
".prop": "text/plain",
|
||||
".proxy": "application/x-ns-proxy-autoconfig",
|
||||
".ptlk": "application/listenup",
|
||||
".pub": "application/x-mspublisher",
|
||||
".pvx": "video/x-pv-pvx",
|
||||
".qcp": "audio/vnd.qcelp",
|
||||
".r3t": "text/vnd.rn-realtext3d",
|
||||
".rar": "application/octet-stream",
|
||||
".rc": "text/plain",
|
||||
".rf": "image/vnd.rn-realflash",
|
||||
".rlf": "application/x-richlink",
|
||||
".rmf": "audio/x-rmf",
|
||||
".rmi": "audio/mid",
|
||||
".rmm": "audio/x-pn-realaudio",
|
||||
".rmvb": "audio/x-pn-realaudio",
|
||||
".rnx": "application/vnd.rn-realplayer",
|
||||
".rp": "image/vnd.rn-realpix",
|
||||
".rt": "text/vnd.rn-realtext",
|
||||
".rte": "x-lml/x-gps",
|
||||
".rtg": "application/metastream",
|
||||
".rv": "video/vnd.rn-realvideo",
|
||||
".rwc": "application/x-rogerwilco",
|
||||
".s3m": "audio/x-mod",
|
||||
".s3z": "audio/x-mod",
|
||||
".sca": "application/x-supercard",
|
||||
".scd": "application/x-msschedule",
|
||||
".sct": "text/scriptlet",
|
||||
".sdf": "application/e-score",
|
||||
".sea": "application/x-stuffit",
|
||||
".setpay": "application/set-payment-initiation",
|
||||
".setreg": "application/set-registration-initiation",
|
||||
".shtml": "text/html",
|
||||
".shtm": "text/html",
|
||||
".shw": "application/presentations",
|
||||
".si6": "image/si6",
|
||||
".si7": "image/vnd.stiwap.sis",
|
||||
".si9": "image/vnd.lgtwap.sis",
|
||||
".slc": "application/x-salsa",
|
||||
".smd": "audio/x-smd",
|
||||
".smp": "application/studiom",
|
||||
".smz": "audio/x-smd",
|
||||
".spc": "application/x-pkcs7-certificates",
|
||||
".spr": "application/x-sprite",
|
||||
".sprite": "application/x-sprite",
|
||||
".sdp": "application/sdp",
|
||||
".spt": "application/x-spt",
|
||||
".sst": "application/vnd.ms-pkicertstore",
|
||||
".stk": "application/hyperstudio",
|
||||
".stl": "application/vnd.ms-pkistl",
|
||||
".stm": "text/html",
|
||||
".svf": "image/vnd",
|
||||
".svh": "image/svh",
|
||||
".svr": "x-world/x-svr",
|
||||
".swfl": "application/x-shockwave-flash",
|
||||
".tad": "application/octet-stream",
|
||||
".talk": "text/x-speech",
|
||||
".taz": "application/x-tar",
|
||||
".tbp": "application/x-timbuktu",
|
||||
".tbt": "application/x-timbuktu",
|
||||
".tgz": "application/x-compressed",
|
||||
".thm": "application/vnd.eri.thm",
|
||||
".tki": "application/x-tkined",
|
||||
".tkined": "application/x-tkined",
|
||||
".toc": "application/toc",
|
||||
".toy": "image/toy",
|
||||
".trk": "x-lml/x-gps",
|
||||
".trm": "application/x-msterminal",
|
||||
".tsi": "audio/tsplayer",
|
||||
".tsp": "application/dsptype",
|
||||
".ttf": "application/octet-stream",
|
||||
".ttz": "application/t-time",
|
||||
".uls": "text/iuls",
|
||||
".ult": "audio/x-mod",
|
||||
".uu": "application/x-uuencode",
|
||||
".uue": "application/x-uuencode",
|
||||
".vcf": "text/x-vcard",
|
||||
".vdo": "video/vdo",
|
||||
".vib": "audio/vib",
|
||||
".viv": "video/vivo",
|
||||
".vivo": "video/vivo",
|
||||
".vmd": "application/vocaltec-media-desc",
|
||||
".vmf": "application/vocaltec-media-file",
|
||||
".vmi": "application/x-dreamcast-vms-info",
|
||||
".vms": "application/x-dreamcast-vms",
|
||||
".vox": "audio/voxware",
|
||||
".vqe": "audio/x-twinvq-plugin",
|
||||
".vqf": "audio/x-twinvq",
|
||||
".vql": "audio/x-twinvq",
|
||||
".vre": "x-world/x-vream",
|
||||
".vrt": "x-world/x-vrt",
|
||||
".vrw": "x-world/x-vream",
|
||||
".vts": "workbook/formulaone",
|
||||
".wcm": "application/vnd.ms-works",
|
||||
".wdb": "application/vnd.ms-works",
|
||||
".web": "application/vnd.xara",
|
||||
".wi": "image/wavelet",
|
||||
".wis": "application/x-InstallShield",
|
||||
".wks": "application/vnd.ms-works",
|
||||
".wmd": "application/x-ms-wmd",
|
||||
".wmf": "application/x-msmetafile",
|
||||
".wmlscript": "text/vnd.wap.wmlscript",
|
||||
".wmz": "application/x-ms-wmz",
|
||||
".wpng": "image/x-up-wpng",
|
||||
".wps": "application/vnd.ms-works",
|
||||
".wpt": "x-lml/x-gps",
|
||||
".wri": "application/x-mswrite",
|
||||
".wrz": "x-world/x-vrml",
|
||||
".ws": "text/vnd.wap.wmlscript",
|
||||
".wsc": "application/vnd.wap.wmlscriptc",
|
||||
".wv": "video/wavelet",
|
||||
".wxl": "application/x-wxl",
|
||||
".x-gzip": "application/x-gzip",
|
||||
".xaf": "x-world/x-vrml",
|
||||
".xar": "application/vnd.xara",
|
||||
".xdm": "application/x-xdma",
|
||||
".xdma": "application/x-xdma",
|
||||
".xdw": "application/vnd.fujixerox.docuworks",
|
||||
".xhtm": "application/xhtml+xml",
|
||||
".xla": "application/vnd.ms-excel",
|
||||
".xlc": "application/vnd.ms-excel",
|
||||
".xll": "application/x-excel",
|
||||
".xlm": "application/vnd.ms-excel",
|
||||
".xlt": "application/vnd.ms-excel",
|
||||
".xlw": "application/vnd.ms-excel",
|
||||
".xm": "audio/x-mod",
|
||||
".xmz": "audio/x-mod",
|
||||
".xof": "x-world/x-vrml",
|
||||
".xpi": "application/x-xpinstall",
|
||||
".xsit": "text/xml",
|
||||
".yz1": "application/x-yz1",
|
||||
".z": "application/x-compress",
|
||||
".zac": "application/x-zaurus-zac",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
// TypeByExtension returns the MIME type associated with the file extension ext.
|
||||
// gets the file's MIME type for HTTP header Content-Type
|
||||
func TypeByExtension(filePath string) string {
|
||||
typ := mime.TypeByExtension(path.Ext(filePath))
|
||||
if typ == "" {
|
||||
typ = extToMimeType[strings.ToLower(path.Ext(filePath))]
|
||||
} else {
|
||||
if strings.HasPrefix(typ, "text/") && strings.Contains(typ, "charset=") {
|
||||
typ = removeCharsetInMimeType(typ)
|
||||
}
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
// Remove charset from mime type
|
||||
func removeCharsetInMimeType(typ string) (str string) {
|
||||
temArr := strings.Split(typ, ";")
|
||||
var builder strings.Builder
|
||||
for i, s := range temArr {
|
||||
tmpStr := strings.Trim(s, " ")
|
||||
if strings.Contains(tmpStr, "charset=") {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
builder.WriteString(s)
|
||||
} else {
|
||||
builder.WriteString("; " + s)
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Response defines HTTP response from OSS
|
||||
type Response struct {
|
||||
StatusCode int
|
||||
Headers http.Header
|
||||
Body io.ReadCloser
|
||||
ClientCRC uint64
|
||||
ServerCRC uint64
|
||||
}
|
||||
|
||||
func (r *Response) Read(p []byte) (n int, err error) {
|
||||
return r.Body.Read(p)
|
||||
}
|
||||
|
||||
// Close close http reponse body
|
||||
func (r *Response) Close() error {
|
||||
return r.Body.Close()
|
||||
}
|
||||
|
||||
// PutObjectRequest is the request of DoPutObject
|
||||
type PutObjectRequest struct {
|
||||
ObjectKey string
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
// GetObjectRequest is the request of DoGetObject
|
||||
type GetObjectRequest struct {
|
||||
ObjectKey string
|
||||
}
|
||||
|
||||
// GetObjectResult is the result of DoGetObject
|
||||
type GetObjectResult struct {
|
||||
Response *Response
|
||||
ClientCRC hash.Hash64
|
||||
ServerCRC uint64
|
||||
}
|
||||
|
||||
// AppendObjectRequest is the requtest of DoAppendObject
|
||||
type AppendObjectRequest struct {
|
||||
ObjectKey string
|
||||
Reader io.Reader
|
||||
Position int64
|
||||
}
|
||||
|
||||
// AppendObjectResult is the result of DoAppendObject
|
||||
type AppendObjectResult struct {
|
||||
NextPosition int64
|
||||
CRC uint64
|
||||
}
|
||||
|
||||
// UploadPartRequest is the request of DoUploadPart
|
||||
type UploadPartRequest struct {
|
||||
InitResult *InitiateMultipartUploadResult
|
||||
Reader io.Reader
|
||||
PartSize int64
|
||||
PartNumber int
|
||||
}
|
||||
|
||||
// UploadPartResult is the result of DoUploadPart
|
||||
type UploadPartResult struct {
|
||||
Part UploadPart
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CopyFile is multipart copy object
|
||||
//
|
||||
// srcBucketName source bucket name
|
||||
// srcObjectKey source object name
|
||||
// destObjectKey target object name in the form of bucketname.objectkey
|
||||
// partSize the part size in byte.
|
||||
// options object's contraints. Check out function InitiateMultipartUpload.
|
||||
//
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
//
|
||||
func (bucket Bucket) CopyFile(srcBucketName, srcObjectKey, destObjectKey string, partSize int64, options ...Option) error {
|
||||
destBucketName := bucket.BucketName
|
||||
if partSize < MinPartSize || partSize > MaxPartSize {
|
||||
return errors.New("oss: part size invalid range (1024KB, 5GB]")
|
||||
}
|
||||
|
||||
cpConf := getCpConfig(options)
|
||||
routines := getRoutines(options)
|
||||
|
||||
var strVersionId string
|
||||
versionId, _ := FindOption(options, "versionId", nil)
|
||||
if versionId != nil {
|
||||
strVersionId = versionId.(string)
|
||||
}
|
||||
|
||||
if cpConf != nil && cpConf.IsEnable {
|
||||
cpFilePath := getCopyCpFilePath(cpConf, srcBucketName, srcObjectKey, destBucketName, destObjectKey, strVersionId)
|
||||
if cpFilePath != "" {
|
||||
return bucket.copyFileWithCp(srcBucketName, srcObjectKey, destBucketName, destObjectKey, partSize, options, cpFilePath, routines)
|
||||
}
|
||||
}
|
||||
|
||||
return bucket.copyFile(srcBucketName, srcObjectKey, destBucketName, destObjectKey,
|
||||
partSize, options, routines)
|
||||
}
|
||||
|
||||
func getCopyCpFilePath(cpConf *cpConfig, srcBucket, srcObject, destBucket, destObject, versionId string) string {
|
||||
if cpConf.FilePath == "" && cpConf.DirPath != "" {
|
||||
dest := fmt.Sprintf("oss://%v/%v", destBucket, destObject)
|
||||
src := fmt.Sprintf("oss://%v/%v", srcBucket, srcObject)
|
||||
cpFileName := getCpFileName(src, dest, versionId)
|
||||
cpConf.FilePath = cpConf.DirPath + string(os.PathSeparator) + cpFileName
|
||||
}
|
||||
return cpConf.FilePath
|
||||
}
|
||||
|
||||
// ----- Concurrently copy without checkpoint ---------
|
||||
|
||||
// copyWorkerArg defines the copy worker arguments
|
||||
type copyWorkerArg struct {
|
||||
bucket *Bucket
|
||||
imur InitiateMultipartUploadResult
|
||||
srcBucketName string
|
||||
srcObjectKey string
|
||||
options []Option
|
||||
hook copyPartHook
|
||||
}
|
||||
|
||||
// copyPartHook is the hook for testing purpose
|
||||
type copyPartHook func(part copyPart) error
|
||||
|
||||
var copyPartHooker copyPartHook = defaultCopyPartHook
|
||||
|
||||
func defaultCopyPartHook(part copyPart) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyWorker copies worker
|
||||
func copyWorker(id int, arg copyWorkerArg, jobs <-chan copyPart, results chan<- UploadPart, failed chan<- error, die <-chan bool) {
|
||||
for chunk := range jobs {
|
||||
if err := arg.hook(chunk); err != nil {
|
||||
failed <- err
|
||||
break
|
||||
}
|
||||
chunkSize := chunk.End - chunk.Start + 1
|
||||
part, err := arg.bucket.UploadPartCopy(arg.imur, arg.srcBucketName, arg.srcObjectKey,
|
||||
chunk.Start, chunkSize, chunk.Number, arg.options...)
|
||||
if err != nil {
|
||||
failed <- err
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-die:
|
||||
return
|
||||
default:
|
||||
}
|
||||
results <- part
|
||||
}
|
||||
}
|
||||
|
||||
// copyScheduler
|
||||
func copyScheduler(jobs chan copyPart, parts []copyPart) {
|
||||
for _, part := range parts {
|
||||
jobs <- part
|
||||
}
|
||||
close(jobs)
|
||||
}
|
||||
|
||||
// copyPart structure
|
||||
type copyPart struct {
|
||||
Number int // Part number (from 1 to 10,000)
|
||||
Start int64 // The start index in the source file.
|
||||
End int64 // The end index in the source file
|
||||
}
|
||||
|
||||
// getCopyParts calculates copy parts
|
||||
func getCopyParts(objectSize, partSize int64) []copyPart {
|
||||
parts := []copyPart{}
|
||||
part := copyPart{}
|
||||
i := 0
|
||||
for offset := int64(0); offset < objectSize; offset += partSize {
|
||||
part.Number = i + 1
|
||||
part.Start = offset
|
||||
part.End = GetPartEnd(offset, objectSize, partSize)
|
||||
parts = append(parts, part)
|
||||
i++
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// getSrcObjectBytes gets the source file size
|
||||
func getSrcObjectBytes(parts []copyPart) int64 {
|
||||
var ob int64
|
||||
for _, part := range parts {
|
||||
ob += (part.End - part.Start + 1)
|
||||
}
|
||||
return ob
|
||||
}
|
||||
|
||||
// copyFile is a concurrently copy without checkpoint
|
||||
func (bucket Bucket) copyFile(srcBucketName, srcObjectKey, destBucketName, destObjectKey string,
|
||||
partSize int64, options []Option, routines int) error {
|
||||
descBucket, err := bucket.Client.Bucket(destBucketName)
|
||||
srcBucket, err := bucket.Client.Bucket(srcBucketName)
|
||||
listener := GetProgressListener(options)
|
||||
|
||||
// choice valid options
|
||||
headerOptions := ChoiceHeadObjectOption(options)
|
||||
partOptions := ChoiceTransferPartOption(options)
|
||||
completeOptions := ChoiceCompletePartOption(options)
|
||||
abortOptions := ChoiceAbortPartOption(options)
|
||||
|
||||
meta, err := srcBucket.GetObjectDetailedMeta(srcObjectKey, headerOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objectSize, err := strconv.ParseInt(meta.Get(HTTPHeaderContentLength), 10, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get copy parts
|
||||
parts := getCopyParts(objectSize, partSize)
|
||||
// Initialize the multipart upload
|
||||
imur, err := descBucket.InitiateMultipartUpload(destObjectKey, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jobs := make(chan copyPart, len(parts))
|
||||
results := make(chan UploadPart, len(parts))
|
||||
failed := make(chan error)
|
||||
die := make(chan bool)
|
||||
|
||||
var completedBytes int64
|
||||
totalBytes := getSrcObjectBytes(parts)
|
||||
event := newProgressEvent(TransferStartedEvent, 0, totalBytes, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
// Start to copy workers
|
||||
arg := copyWorkerArg{descBucket, imur, srcBucketName, srcObjectKey, partOptions, copyPartHooker}
|
||||
for w := 1; w <= routines; w++ {
|
||||
go copyWorker(w, arg, jobs, results, failed, die)
|
||||
}
|
||||
|
||||
// Start the scheduler
|
||||
go copyScheduler(jobs, parts)
|
||||
|
||||
// Wait for the parts finished.
|
||||
completed := 0
|
||||
ups := make([]UploadPart, len(parts))
|
||||
for completed < len(parts) {
|
||||
select {
|
||||
case part := <-results:
|
||||
completed++
|
||||
ups[part.PartNumber-1] = part
|
||||
copyBytes := (parts[part.PartNumber-1].End - parts[part.PartNumber-1].Start + 1)
|
||||
completedBytes += copyBytes
|
||||
event = newProgressEvent(TransferDataEvent, completedBytes, totalBytes, copyBytes)
|
||||
publishProgress(listener, event)
|
||||
case err := <-failed:
|
||||
close(die)
|
||||
descBucket.AbortMultipartUpload(imur, abortOptions...)
|
||||
event = newProgressEvent(TransferFailedEvent, completedBytes, totalBytes, 0)
|
||||
publishProgress(listener, event)
|
||||
return err
|
||||
}
|
||||
|
||||
if completed >= len(parts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
event = newProgressEvent(TransferCompletedEvent, completedBytes, totalBytes, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
// Complete the multipart upload
|
||||
_, err = descBucket.CompleteMultipartUpload(imur, ups, completeOptions...)
|
||||
if err != nil {
|
||||
bucket.AbortMultipartUpload(imur, abortOptions...)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- Concurrently copy with checkpoint -----
|
||||
|
||||
const copyCpMagic = "84F1F18C-FF1D-403B-A1D8-9DEB5F65910A"
|
||||
|
||||
type copyCheckpoint struct {
|
||||
Magic string // Magic
|
||||
MD5 string // CP content MD5
|
||||
SrcBucketName string // Source bucket
|
||||
SrcObjectKey string // Source object
|
||||
DestBucketName string // Target bucket
|
||||
DestObjectKey string // Target object
|
||||
CopyID string // Copy ID
|
||||
ObjStat objectStat // Object stat
|
||||
Parts []copyPart // Copy parts
|
||||
CopyParts []UploadPart // The uploaded parts
|
||||
PartStat []bool // The part status
|
||||
}
|
||||
|
||||
// isValid checks if the data is valid which means CP is valid and object is not updated.
|
||||
func (cp copyCheckpoint) isValid(meta http.Header) (bool, error) {
|
||||
// Compare CP's magic number and the MD5.
|
||||
cpb := cp
|
||||
cpb.MD5 = ""
|
||||
js, _ := json.Marshal(cpb)
|
||||
sum := md5.Sum(js)
|
||||
b64 := base64.StdEncoding.EncodeToString(sum[:])
|
||||
|
||||
if cp.Magic != downloadCpMagic || b64 != cp.MD5 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
objectSize, err := strconv.ParseInt(meta.Get(HTTPHeaderContentLength), 10, 64)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Compare the object size and last modified time and etag.
|
||||
if cp.ObjStat.Size != objectSize ||
|
||||
cp.ObjStat.LastModified != meta.Get(HTTPHeaderLastModified) ||
|
||||
cp.ObjStat.Etag != meta.Get(HTTPHeaderEtag) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// load loads from the checkpoint file
|
||||
func (cp *copyCheckpoint) load(filePath string) error {
|
||||
contents, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(contents, cp)
|
||||
return err
|
||||
}
|
||||
|
||||
// update updates the parts status
|
||||
func (cp *copyCheckpoint) update(part UploadPart) {
|
||||
cp.CopyParts[part.PartNumber-1] = part
|
||||
cp.PartStat[part.PartNumber-1] = true
|
||||
}
|
||||
|
||||
// dump dumps the CP to the file
|
||||
func (cp *copyCheckpoint) dump(filePath string) error {
|
||||
bcp := *cp
|
||||
|
||||
// Calculate MD5
|
||||
bcp.MD5 = ""
|
||||
js, err := json.Marshal(bcp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum := md5.Sum(js)
|
||||
b64 := base64.StdEncoding.EncodeToString(sum[:])
|
||||
bcp.MD5 = b64
|
||||
|
||||
// Serialization
|
||||
js, err = json.Marshal(bcp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Dump
|
||||
return ioutil.WriteFile(filePath, js, FilePermMode)
|
||||
}
|
||||
|
||||
// todoParts returns unfinished parts
|
||||
func (cp copyCheckpoint) todoParts() []copyPart {
|
||||
dps := []copyPart{}
|
||||
for i, ps := range cp.PartStat {
|
||||
if !ps {
|
||||
dps = append(dps, cp.Parts[i])
|
||||
}
|
||||
}
|
||||
return dps
|
||||
}
|
||||
|
||||
// getCompletedBytes returns finished bytes count
|
||||
func (cp copyCheckpoint) getCompletedBytes() int64 {
|
||||
var completedBytes int64
|
||||
for i, part := range cp.Parts {
|
||||
if cp.PartStat[i] {
|
||||
completedBytes += (part.End - part.Start + 1)
|
||||
}
|
||||
}
|
||||
return completedBytes
|
||||
}
|
||||
|
||||
// prepare initializes the multipart upload
|
||||
func (cp *copyCheckpoint) prepare(meta http.Header, srcBucket *Bucket, srcObjectKey string, destBucket *Bucket, destObjectKey string,
|
||||
partSize int64, options []Option) error {
|
||||
// CP
|
||||
cp.Magic = copyCpMagic
|
||||
cp.SrcBucketName = srcBucket.BucketName
|
||||
cp.SrcObjectKey = srcObjectKey
|
||||
cp.DestBucketName = destBucket.BucketName
|
||||
cp.DestObjectKey = destObjectKey
|
||||
|
||||
objectSize, err := strconv.ParseInt(meta.Get(HTTPHeaderContentLength), 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cp.ObjStat.Size = objectSize
|
||||
cp.ObjStat.LastModified = meta.Get(HTTPHeaderLastModified)
|
||||
cp.ObjStat.Etag = meta.Get(HTTPHeaderEtag)
|
||||
|
||||
// Parts
|
||||
cp.Parts = getCopyParts(objectSize, partSize)
|
||||
cp.PartStat = make([]bool, len(cp.Parts))
|
||||
for i := range cp.PartStat {
|
||||
cp.PartStat[i] = false
|
||||
}
|
||||
cp.CopyParts = make([]UploadPart, len(cp.Parts))
|
||||
|
||||
// Init copy
|
||||
imur, err := destBucket.InitiateMultipartUpload(destObjectKey, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cp.CopyID = imur.UploadID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *copyCheckpoint) complete(bucket *Bucket, parts []UploadPart, cpFilePath string, options []Option) error {
|
||||
imur := InitiateMultipartUploadResult{Bucket: cp.DestBucketName,
|
||||
Key: cp.DestObjectKey, UploadID: cp.CopyID}
|
||||
_, err := bucket.CompleteMultipartUpload(imur, parts, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(cpFilePath)
|
||||
return err
|
||||
}
|
||||
|
||||
// copyFileWithCp is concurrently copy with checkpoint
|
||||
func (bucket Bucket) copyFileWithCp(srcBucketName, srcObjectKey, destBucketName, destObjectKey string,
|
||||
partSize int64, options []Option, cpFilePath string, routines int) error {
|
||||
descBucket, err := bucket.Client.Bucket(destBucketName)
|
||||
srcBucket, err := bucket.Client.Bucket(srcBucketName)
|
||||
listener := GetProgressListener(options)
|
||||
|
||||
// Load CP data
|
||||
ccp := copyCheckpoint{}
|
||||
err = ccp.load(cpFilePath)
|
||||
if err != nil {
|
||||
os.Remove(cpFilePath)
|
||||
}
|
||||
|
||||
// choice valid options
|
||||
headerOptions := ChoiceHeadObjectOption(options)
|
||||
partOptions := ChoiceTransferPartOption(options)
|
||||
completeOptions := ChoiceCompletePartOption(options)
|
||||
|
||||
meta, err := srcBucket.GetObjectDetailedMeta(srcObjectKey, headerOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load error or the CP data is invalid---reinitialize
|
||||
valid, err := ccp.isValid(meta)
|
||||
if err != nil || !valid {
|
||||
if err = ccp.prepare(meta, srcBucket, srcObjectKey, descBucket, destObjectKey, partSize, options); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(cpFilePath)
|
||||
}
|
||||
|
||||
// Unfinished parts
|
||||
parts := ccp.todoParts()
|
||||
imur := InitiateMultipartUploadResult{
|
||||
Bucket: destBucketName,
|
||||
Key: destObjectKey,
|
||||
UploadID: ccp.CopyID}
|
||||
|
||||
jobs := make(chan copyPart, len(parts))
|
||||
results := make(chan UploadPart, len(parts))
|
||||
failed := make(chan error)
|
||||
die := make(chan bool)
|
||||
|
||||
completedBytes := ccp.getCompletedBytes()
|
||||
event := newProgressEvent(TransferStartedEvent, completedBytes, ccp.ObjStat.Size, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
// Start the worker coroutines
|
||||
arg := copyWorkerArg{descBucket, imur, srcBucketName, srcObjectKey, partOptions, copyPartHooker}
|
||||
for w := 1; w <= routines; w++ {
|
||||
go copyWorker(w, arg, jobs, results, failed, die)
|
||||
}
|
||||
|
||||
// Start the scheduler
|
||||
go copyScheduler(jobs, parts)
|
||||
|
||||
// Wait for the parts completed.
|
||||
completed := 0
|
||||
for completed < len(parts) {
|
||||
select {
|
||||
case part := <-results:
|
||||
completed++
|
||||
ccp.update(part)
|
||||
ccp.dump(cpFilePath)
|
||||
copyBytes := (parts[part.PartNumber-1].End - parts[part.PartNumber-1].Start + 1)
|
||||
completedBytes += copyBytes
|
||||
event = newProgressEvent(TransferDataEvent, completedBytes, ccp.ObjStat.Size, copyBytes)
|
||||
publishProgress(listener, event)
|
||||
case err := <-failed:
|
||||
close(die)
|
||||
event = newProgressEvent(TransferFailedEvent, completedBytes, ccp.ObjStat.Size, 0)
|
||||
publishProgress(listener, event)
|
||||
return err
|
||||
}
|
||||
|
||||
if completed >= len(parts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
event = newProgressEvent(TransferCompletedEvent, completedBytes, ccp.ObjStat.Size, 0)
|
||||
publishProgress(listener, event)
|
||||
|
||||
return ccp.complete(descBucket, ccp.CopyParts, cpFilePath, completeOptions)
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// InitiateMultipartUpload initializes multipart upload
|
||||
//
|
||||
// objectKey object name
|
||||
// options the object constricts for upload. The valid options are CacheControl, ContentDisposition, ContentEncoding, Expires,
|
||||
//
|
||||
// ServerSideEncryption, Meta, check out the following link:
|
||||
// https://www.alibabacloud.com/help/en/object-storage-service/latest/initiatemultipartupload
|
||||
//
|
||||
// InitiateMultipartUploadResult the return value of the InitiateMultipartUpload, which is used for calls later on such as UploadPartFromFile,UploadPartCopy.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) InitiateMultipartUpload(objectKey string, options ...Option) (InitiateMultipartUploadResult, error) {
|
||||
var imur InitiateMultipartUploadResult
|
||||
opts := AddContentType(options, objectKey)
|
||||
params, _ := GetRawParams(options)
|
||||
paramKeys := []string{"sequential", "withHashContext", "x-oss-enable-md5", "x-oss-enable-sha1", "x-oss-enable-sha256"}
|
||||
ConvertEmptyValueToNil(params, paramKeys)
|
||||
params["uploads"] = nil
|
||||
|
||||
resp, err := bucket.do("POST", objectKey, params, opts, nil, nil)
|
||||
if err != nil {
|
||||
return imur, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &imur)
|
||||
return imur, err
|
||||
}
|
||||
|
||||
// UploadPart uploads parts
|
||||
//
|
||||
// After initializing a Multipart Upload, the upload Id and object key could be used for uploading the parts.
|
||||
// Each part has its part number (ranges from 1 to 10,000). And for each upload Id, the part number identifies the position of the part in the whole file.
|
||||
// And thus with the same part number and upload Id, another part upload will overwrite the data.
|
||||
// Except the last one, minimal part size is 100KB. There's no limit on the last part size.
|
||||
//
|
||||
// imur the returned value of InitiateMultipartUpload.
|
||||
// reader io.Reader the reader for the part's data.
|
||||
// size the part size.
|
||||
// partNumber the part number (ranges from 1 to 10,000). Invalid part number will lead to InvalidArgument error.
|
||||
//
|
||||
// UploadPart the return value of the upload part. It consists of PartNumber and ETag. It's valid when error is nil.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) UploadPart(imur InitiateMultipartUploadResult, reader io.Reader,
|
||||
partSize int64, partNumber int, options ...Option) (UploadPart, error) {
|
||||
request := &UploadPartRequest{
|
||||
InitResult: &imur,
|
||||
Reader: reader,
|
||||
PartSize: partSize,
|
||||
PartNumber: partNumber,
|
||||
}
|
||||
|
||||
result, err := bucket.DoUploadPart(request, options)
|
||||
|
||||
return result.Part, err
|
||||
}
|
||||
|
||||
// UploadPartFromFile uploads part from the file.
|
||||
//
|
||||
// imur the return value of a successful InitiateMultipartUpload.
|
||||
// filePath the local file path to upload.
|
||||
// startPosition the start position in the local file.
|
||||
// partSize the part size.
|
||||
// partNumber the part number (from 1 to 10,000)
|
||||
//
|
||||
// UploadPart the return value consists of PartNumber and ETag.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) UploadPartFromFile(imur InitiateMultipartUploadResult, filePath string,
|
||||
startPosition, partSize int64, partNumber int, options ...Option) (UploadPart, error) {
|
||||
var part = UploadPart{}
|
||||
fd, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return part, err
|
||||
}
|
||||
defer fd.Close()
|
||||
fd.Seek(startPosition, os.SEEK_SET)
|
||||
|
||||
request := &UploadPartRequest{
|
||||
InitResult: &imur,
|
||||
Reader: fd,
|
||||
PartSize: partSize,
|
||||
PartNumber: partNumber,
|
||||
}
|
||||
|
||||
result, err := bucket.DoUploadPart(request, options)
|
||||
|
||||
return result.Part, err
|
||||
}
|
||||
|
||||
// DoUploadPart does the actual part upload.
|
||||
//
|
||||
// request part upload request
|
||||
//
|
||||
// UploadPartResult the result of uploading part.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) DoUploadPart(request *UploadPartRequest, options []Option) (*UploadPartResult, error) {
|
||||
listener := GetProgressListener(options)
|
||||
options = append(options, ContentLength(request.PartSize))
|
||||
params := map[string]interface{}{}
|
||||
params["partNumber"] = strconv.Itoa(request.PartNumber)
|
||||
params["uploadId"] = request.InitResult.UploadID
|
||||
resp, err := bucket.do("PUT", request.InitResult.Key, params, options,
|
||||
&io.LimitedReader{R: request.Reader, N: request.PartSize}, listener)
|
||||
if err != nil {
|
||||
return &UploadPartResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
part := UploadPart{
|
||||
ETag: resp.Headers.Get(HTTPHeaderEtag),
|
||||
PartNumber: request.PartNumber,
|
||||
}
|
||||
|
||||
if bucket.GetConfig().IsEnableCRC {
|
||||
err = CheckCRC(resp, "DoUploadPart")
|
||||
if err != nil {
|
||||
return &UploadPartResult{part}, err
|
||||
}
|
||||
}
|
||||
|
||||
return &UploadPartResult{part}, nil
|
||||
}
|
||||
|
||||
// UploadPartCopy uploads part copy
|
||||
//
|
||||
// imur the return value of InitiateMultipartUpload
|
||||
// copySrc source Object name
|
||||
// startPosition the part's start index in the source file
|
||||
// partSize the part size
|
||||
// partNumber the part number, ranges from 1 to 10,000. If it exceeds the range OSS returns InvalidArgument error.
|
||||
// options the constraints of source object for the copy. The copy happens only when these contraints are met. Otherwise it returns error.
|
||||
//
|
||||
// CopySourceIfNoneMatch, CopySourceIfModifiedSince CopySourceIfUnmodifiedSince, check out the following link for the detail
|
||||
// https://www.alibabacloud.com/help/en/object-storage-service/latest/uploadpartcopy
|
||||
//
|
||||
// UploadPart the return value consists of PartNumber and ETag.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) UploadPartCopy(imur InitiateMultipartUploadResult, srcBucketName, srcObjectKey string,
|
||||
startPosition, partSize int64, partNumber int, options ...Option) (UploadPart, error) {
|
||||
var out UploadPartCopyResult
|
||||
var part UploadPart
|
||||
var opts []Option
|
||||
|
||||
//first find version id
|
||||
versionIdKey := "versionId"
|
||||
versionId, _ := FindOption(options, versionIdKey, nil)
|
||||
if versionId == nil {
|
||||
opts = []Option{CopySource(srcBucketName, url.QueryEscape(srcObjectKey)),
|
||||
CopySourceRange(startPosition, partSize)}
|
||||
} else {
|
||||
opts = []Option{CopySourceVersion(srcBucketName, url.QueryEscape(srcObjectKey), versionId.(string)),
|
||||
CopySourceRange(startPosition, partSize)}
|
||||
options = DeleteOption(options, versionIdKey)
|
||||
}
|
||||
|
||||
opts = append(opts, options...)
|
||||
|
||||
params := map[string]interface{}{}
|
||||
params["partNumber"] = strconv.Itoa(partNumber)
|
||||
params["uploadId"] = imur.UploadID
|
||||
resp, err := bucket.do("PUT", imur.Key, params, opts, nil, nil)
|
||||
if err != nil {
|
||||
return part, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
if err != nil {
|
||||
return part, err
|
||||
}
|
||||
part.ETag = out.ETag
|
||||
part.PartNumber = partNumber
|
||||
|
||||
return part, nil
|
||||
}
|
||||
|
||||
// CompleteMultipartUpload completes the multipart upload.
|
||||
//
|
||||
// imur the return value of InitiateMultipartUpload.
|
||||
// parts the array of return value of UploadPart/UploadPartFromFile/UploadPartCopy.
|
||||
//
|
||||
// CompleteMultipartUploadResponse the return value when the call succeeds. Only valid when the error is nil.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) CompleteMultipartUpload(imur InitiateMultipartUploadResult,
|
||||
parts []UploadPart, options ...Option) (CompleteMultipartUploadResult, error) {
|
||||
var out CompleteMultipartUploadResult
|
||||
|
||||
sort.Sort(UploadParts(parts))
|
||||
cxml := completeMultipartUploadXML{}
|
||||
cxml.Part = parts
|
||||
bs, err := xml.Marshal(cxml)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
buffer := new(bytes.Buffer)
|
||||
buffer.Write(bs)
|
||||
|
||||
params := map[string]interface{}{}
|
||||
params["uploadId"] = imur.UploadID
|
||||
resp, err := bucket.do("POST", imur.Key, params, options, buffer, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = CheckRespCode(resp.StatusCode, []int{http.StatusOK})
|
||||
if len(body) > 0 {
|
||||
if err != nil {
|
||||
err = tryConvertServiceError(body, resp, err)
|
||||
} else {
|
||||
callback, _ := FindOption(options, HTTPHeaderOssCallback, nil)
|
||||
if callback == nil {
|
||||
err = xml.Unmarshal(body, &out)
|
||||
} else {
|
||||
rb, _ := FindOption(options, responseBody, nil)
|
||||
if rb != nil {
|
||||
if rbody, ok := rb.(*[]byte); ok {
|
||||
*rbody = body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// AbortMultipartUpload aborts the multipart upload.
|
||||
//
|
||||
// imur the return value of InitiateMultipartUpload.
|
||||
//
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) AbortMultipartUpload(imur InitiateMultipartUploadResult, options ...Option) error {
|
||||
params := map[string]interface{}{}
|
||||
params["uploadId"] = imur.UploadID
|
||||
resp, err := bucket.do("DELETE", imur.Key, params, options, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return CheckRespCode(resp.StatusCode, []int{http.StatusNoContent})
|
||||
}
|
||||
|
||||
// ListUploadedParts lists the uploaded parts.
|
||||
//
|
||||
// imur the return value of InitiateMultipartUpload.
|
||||
//
|
||||
// ListUploadedPartsResponse the return value if it succeeds, only valid when error is nil.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) ListUploadedParts(imur InitiateMultipartUploadResult, options ...Option) (ListUploadedPartsResult, error) {
|
||||
var out ListUploadedPartsResult
|
||||
options = append(options, EncodingType("url"))
|
||||
|
||||
params := map[string]interface{}{}
|
||||
params, err := GetRawParams(options)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
params["uploadId"] = imur.UploadID
|
||||
resp, err := bucket.do("GET", imur.Key, params, options, nil, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = decodeListUploadedPartsResult(&out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListMultipartUploads lists all ongoing multipart upload tasks
|
||||
//
|
||||
// options listObject's filter. Prefix specifies the returned object's prefix; KeyMarker specifies the returned object's start point in lexicographic order;
|
||||
//
|
||||
// MaxKeys specifies the max entries to return; Delimiter is the character for grouping object keys.
|
||||
//
|
||||
// ListMultipartUploadResponse the return value if it succeeds, only valid when error is nil.
|
||||
// error it's nil if the operation succeeds, otherwise it's an error object.
|
||||
func (bucket Bucket) ListMultipartUploads(options ...Option) (ListMultipartUploadResult, error) {
|
||||
var out ListMultipartUploadResult
|
||||
|
||||
options = append(options, EncodingType("url"))
|
||||
params, err := GetRawParams(options)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
params["uploads"] = nil
|
||||
|
||||
resp, err := bucket.doInner("GET", "", params, options, nil, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = xmlUnmarshal(resp.Body, &out)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = decodeListMultipartUploadResult(&out)
|
||||
return out, err
|
||||
}
|
||||
+735
@@ -0,0 +1,735 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type optionType string
|
||||
|
||||
const (
|
||||
optionParam optionType = "HTTPParameter" // URL parameter
|
||||
optionHTTP optionType = "HTTPHeader" // HTTP header
|
||||
optionContext optionType = "HTTPContext" // context
|
||||
optionArg optionType = "FuncArgument" // Function argument
|
||||
|
||||
)
|
||||
|
||||
const (
|
||||
deleteObjectsQuiet = "delete-objects-quiet"
|
||||
routineNum = "x-routine-num"
|
||||
checkpointConfig = "x-cp-config"
|
||||
initCRC64 = "init-crc64"
|
||||
progressListener = "x-progress-listener"
|
||||
storageClass = "storage-class"
|
||||
responseHeader = "x-response-header"
|
||||
redundancyType = "redundancy-type"
|
||||
objectHashFunc = "object-hash-func"
|
||||
responseBody = "x-response-body"
|
||||
contextArg = "x-context-arg"
|
||||
)
|
||||
|
||||
type (
|
||||
optionValue struct {
|
||||
Value interface{}
|
||||
Type optionType
|
||||
}
|
||||
|
||||
// Option HTTP option
|
||||
Option func(map[string]optionValue) error
|
||||
)
|
||||
|
||||
// ACL is an option to set X-Oss-Acl header
|
||||
func ACL(acl ACLType) Option {
|
||||
return setHeader(HTTPHeaderOssACL, string(acl))
|
||||
}
|
||||
|
||||
// ContentType is an option to set Content-Type header
|
||||
func ContentType(value string) Option {
|
||||
return setHeader(HTTPHeaderContentType, value)
|
||||
}
|
||||
|
||||
// ContentLength is an option to set Content-Length header
|
||||
func ContentLength(length int64) Option {
|
||||
return setHeader(HTTPHeaderContentLength, strconv.FormatInt(length, 10))
|
||||
}
|
||||
|
||||
// CacheControl is an option to set Cache-Control header
|
||||
func CacheControl(value string) Option {
|
||||
return setHeader(HTTPHeaderCacheControl, value)
|
||||
}
|
||||
|
||||
// ContentDisposition is an option to set Content-Disposition header
|
||||
func ContentDisposition(value string) Option {
|
||||
return setHeader(HTTPHeaderContentDisposition, value)
|
||||
}
|
||||
|
||||
// ContentEncoding is an option to set Content-Encoding header
|
||||
func ContentEncoding(value string) Option {
|
||||
return setHeader(HTTPHeaderContentEncoding, value)
|
||||
}
|
||||
|
||||
// ContentLanguage is an option to set Content-Language header
|
||||
func ContentLanguage(value string) Option {
|
||||
return setHeader(HTTPHeaderContentLanguage, value)
|
||||
}
|
||||
|
||||
// ContentMD5 is an option to set Content-MD5 header
|
||||
func ContentMD5(value string) Option {
|
||||
return setHeader(HTTPHeaderContentMD5, value)
|
||||
}
|
||||
|
||||
// Expires is an option to set Expires header
|
||||
func Expires(t time.Time) Option {
|
||||
return setHeader(HTTPHeaderExpires, t.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// Meta is an option to set Meta header
|
||||
func Meta(key, value string) Option {
|
||||
return setHeader(HTTPHeaderOssMetaPrefix+key, value)
|
||||
}
|
||||
|
||||
// Range is an option to set Range header, [start, end]
|
||||
func Range(start, end int64) Option {
|
||||
return setHeader(HTTPHeaderRange, fmt.Sprintf("bytes=%d-%d", start, end))
|
||||
}
|
||||
|
||||
// NormalizedRange is an option to set Range header, such as 1024-2048 or 1024- or -2048
|
||||
func NormalizedRange(nr string) Option {
|
||||
return setHeader(HTTPHeaderRange, fmt.Sprintf("bytes=%s", strings.TrimSpace(nr)))
|
||||
}
|
||||
|
||||
// AcceptEncoding is an option to set Accept-Encoding header
|
||||
func AcceptEncoding(value string) Option {
|
||||
return setHeader(HTTPHeaderAcceptEncoding, value)
|
||||
}
|
||||
|
||||
// IfModifiedSince is an option to set If-Modified-Since header
|
||||
func IfModifiedSince(t time.Time) Option {
|
||||
return setHeader(HTTPHeaderIfModifiedSince, t.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// IfUnmodifiedSince is an option to set If-Unmodified-Since header
|
||||
func IfUnmodifiedSince(t time.Time) Option {
|
||||
return setHeader(HTTPHeaderIfUnmodifiedSince, t.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// IfMatch is an option to set If-Match header
|
||||
func IfMatch(value string) Option {
|
||||
return setHeader(HTTPHeaderIfMatch, value)
|
||||
}
|
||||
|
||||
// IfNoneMatch is an option to set IfNoneMatch header
|
||||
func IfNoneMatch(value string) Option {
|
||||
return setHeader(HTTPHeaderIfNoneMatch, value)
|
||||
}
|
||||
|
||||
// CopySource is an option to set X-Oss-Copy-Source header
|
||||
func CopySource(sourceBucket, sourceObject string) Option {
|
||||
return setHeader(HTTPHeaderOssCopySource, "/"+sourceBucket+"/"+sourceObject)
|
||||
}
|
||||
|
||||
// CopySourceVersion is an option to set X-Oss-Copy-Source header,include versionId
|
||||
func CopySourceVersion(sourceBucket, sourceObject string, versionId string) Option {
|
||||
return setHeader(HTTPHeaderOssCopySource, "/"+sourceBucket+"/"+sourceObject+"?"+"versionId="+versionId)
|
||||
}
|
||||
|
||||
// CopySourceRange is an option to set X-Oss-Copy-Source header
|
||||
func CopySourceRange(startPosition, partSize int64) Option {
|
||||
val := "bytes=" + strconv.FormatInt(startPosition, 10) + "-" +
|
||||
strconv.FormatInt((startPosition+partSize-1), 10)
|
||||
return setHeader(HTTPHeaderOssCopySourceRange, val)
|
||||
}
|
||||
|
||||
// CopySourceIfMatch is an option to set X-Oss-Copy-Source-If-Match header
|
||||
func CopySourceIfMatch(value string) Option {
|
||||
return setHeader(HTTPHeaderOssCopySourceIfMatch, value)
|
||||
}
|
||||
|
||||
// CopySourceIfNoneMatch is an option to set X-Oss-Copy-Source-If-None-Match header
|
||||
func CopySourceIfNoneMatch(value string) Option {
|
||||
return setHeader(HTTPHeaderOssCopySourceIfNoneMatch, value)
|
||||
}
|
||||
|
||||
// CopySourceIfModifiedSince is an option to set X-Oss-CopySource-If-Modified-Since header
|
||||
func CopySourceIfModifiedSince(t time.Time) Option {
|
||||
return setHeader(HTTPHeaderOssCopySourceIfModifiedSince, t.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// CopySourceIfUnmodifiedSince is an option to set X-Oss-Copy-Source-If-Unmodified-Since header
|
||||
func CopySourceIfUnmodifiedSince(t time.Time) Option {
|
||||
return setHeader(HTTPHeaderOssCopySourceIfUnmodifiedSince, t.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// MetadataDirective is an option to set X-Oss-Metadata-Directive header
|
||||
func MetadataDirective(directive MetadataDirectiveType) Option {
|
||||
return setHeader(HTTPHeaderOssMetadataDirective, string(directive))
|
||||
}
|
||||
|
||||
// ServerSideEncryption is an option to set X-Oss-Server-Side-Encryption header
|
||||
func ServerSideEncryption(value string) Option {
|
||||
return setHeader(HTTPHeaderOssServerSideEncryption, value)
|
||||
}
|
||||
|
||||
// ServerSideEncryptionKeyID is an option to set X-Oss-Server-Side-Encryption-Key-Id header
|
||||
func ServerSideEncryptionKeyID(value string) Option {
|
||||
return setHeader(HTTPHeaderOssServerSideEncryptionKeyID, value)
|
||||
}
|
||||
|
||||
// ServerSideDataEncryption is an option to set X-Oss-Server-Side-Data-Encryption header
|
||||
func ServerSideDataEncryption(value string) Option {
|
||||
return setHeader(HTTPHeaderOssServerSideDataEncryption, value)
|
||||
}
|
||||
|
||||
// SSECAlgorithm is an option to set X-Oss-Server-Side-Encryption-Customer-Algorithm header
|
||||
func SSECAlgorithm(value string) Option {
|
||||
return setHeader(HTTPHeaderSSECAlgorithm, value)
|
||||
}
|
||||
|
||||
// SSECKey is an option to set X-Oss-Server-Side-Encryption-Customer-Key header
|
||||
func SSECKey(value string) Option {
|
||||
return setHeader(HTTPHeaderSSECKey, value)
|
||||
}
|
||||
|
||||
// SSECKeyMd5 is an option to set X-Oss-Server-Side-Encryption-Customer-Key-Md5 header
|
||||
func SSECKeyMd5(value string) Option {
|
||||
return setHeader(HTTPHeaderSSECKeyMd5, value)
|
||||
}
|
||||
|
||||
// ObjectACL is an option to set X-Oss-Object-Acl header
|
||||
func ObjectACL(acl ACLType) Option {
|
||||
return setHeader(HTTPHeaderOssObjectACL, string(acl))
|
||||
}
|
||||
|
||||
// symlinkTarget is an option to set X-Oss-Symlink-Target
|
||||
func symlinkTarget(targetObjectKey string) Option {
|
||||
return setHeader(HTTPHeaderOssSymlinkTarget, targetObjectKey)
|
||||
}
|
||||
|
||||
// Origin is an option to set Origin header
|
||||
func Origin(value string) Option {
|
||||
return setHeader(HTTPHeaderOrigin, value)
|
||||
}
|
||||
|
||||
// ObjectStorageClass is an option to set the storage class of object
|
||||
func ObjectStorageClass(storageClass StorageClassType) Option {
|
||||
return setHeader(HTTPHeaderOssStorageClass, string(storageClass))
|
||||
}
|
||||
|
||||
// Callback is an option to set callback values
|
||||
func Callback(callback string) Option {
|
||||
return setHeader(HTTPHeaderOssCallback, callback)
|
||||
}
|
||||
|
||||
// CallbackVar is an option to set callback user defined values
|
||||
func CallbackVar(callbackVar string) Option {
|
||||
return setHeader(HTTPHeaderOssCallbackVar, callbackVar)
|
||||
}
|
||||
|
||||
// RequestPayer is an option to set payer who pay for the request
|
||||
func RequestPayer(payerType PayerType) Option {
|
||||
return setHeader(HTTPHeaderOssRequester, strings.ToLower(string(payerType)))
|
||||
}
|
||||
|
||||
// RequestPayerParam is an option to set payer who pay for the request
|
||||
func RequestPayerParam(payerType PayerType) Option {
|
||||
return addParam(strings.ToLower(HTTPHeaderOssRequester), strings.ToLower(string(payerType)))
|
||||
}
|
||||
|
||||
// SetTagging is an option to set object tagging
|
||||
func SetTagging(tagging Tagging) Option {
|
||||
if len(tagging.Tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
taggingValue := ""
|
||||
for index, tag := range tagging.Tags {
|
||||
if index != 0 {
|
||||
taggingValue += "&"
|
||||
}
|
||||
taggingValue += url.QueryEscape(tag.Key) + "=" + url.QueryEscape(tag.Value)
|
||||
}
|
||||
return setHeader(HTTPHeaderOssTagging, taggingValue)
|
||||
}
|
||||
|
||||
// TaggingDirective is an option to set X-Oss-Metadata-Directive header
|
||||
func TaggingDirective(directive TaggingDirectiveType) Option {
|
||||
return setHeader(HTTPHeaderOssTaggingDirective, string(directive))
|
||||
}
|
||||
|
||||
// ACReqMethod is an option to set Access-Control-Request-Method header
|
||||
func ACReqMethod(value string) Option {
|
||||
return setHeader(HTTPHeaderACReqMethod, value)
|
||||
}
|
||||
|
||||
// ACReqHeaders is an option to set Access-Control-Request-Headers header
|
||||
func ACReqHeaders(value string) Option {
|
||||
return setHeader(HTTPHeaderACReqHeaders, value)
|
||||
}
|
||||
|
||||
// TrafficLimitHeader is an option to set X-Oss-Traffic-Limit
|
||||
func TrafficLimitHeader(value int64) Option {
|
||||
return setHeader(HTTPHeaderOssTrafficLimit, strconv.FormatInt(value, 10))
|
||||
}
|
||||
|
||||
// UserAgentHeader is an option to set HTTPHeaderUserAgent
|
||||
func UserAgentHeader(ua string) Option {
|
||||
return setHeader(HTTPHeaderUserAgent, ua)
|
||||
}
|
||||
|
||||
// ForbidOverWrite is an option to set X-Oss-Forbid-Overwrite
|
||||
func ForbidOverWrite(forbidWrite bool) Option {
|
||||
if forbidWrite {
|
||||
return setHeader(HTTPHeaderOssForbidOverWrite, "true")
|
||||
} else {
|
||||
return setHeader(HTTPHeaderOssForbidOverWrite, "false")
|
||||
}
|
||||
}
|
||||
|
||||
// RangeBehavior is an option to set Range value, such as "standard"
|
||||
func RangeBehavior(value string) Option {
|
||||
return setHeader(HTTPHeaderOssRangeBehavior, value)
|
||||
}
|
||||
|
||||
func PartHashCtxHeader(value string) Option {
|
||||
return setHeader(HTTPHeaderOssHashCtx, value)
|
||||
}
|
||||
|
||||
func PartMd5CtxHeader(value string) Option {
|
||||
return setHeader(HTTPHeaderOssMd5Ctx, value)
|
||||
}
|
||||
|
||||
func PartHashCtxParam(value string) Option {
|
||||
return addParam("x-oss-hash-ctx", value)
|
||||
}
|
||||
|
||||
func PartMd5CtxParam(value string) Option {
|
||||
return addParam("x-oss-md5-ctx", value)
|
||||
}
|
||||
|
||||
// Delimiter is an option to set delimiler parameter
|
||||
func Delimiter(value string) Option {
|
||||
return addParam("delimiter", value)
|
||||
}
|
||||
|
||||
// Marker is an option to set marker parameter
|
||||
func Marker(value string) Option {
|
||||
return addParam("marker", value)
|
||||
}
|
||||
|
||||
// MaxKeys is an option to set maxkeys parameter
|
||||
func MaxKeys(value int) Option {
|
||||
return addParam("max-keys", strconv.Itoa(value))
|
||||
}
|
||||
|
||||
// Prefix is an option to set prefix parameter
|
||||
func Prefix(value string) Option {
|
||||
return addParam("prefix", value)
|
||||
}
|
||||
|
||||
// EncodingType is an option to set encoding-type parameter
|
||||
func EncodingType(value string) Option {
|
||||
return addParam("encoding-type", value)
|
||||
}
|
||||
|
||||
// MaxUploads is an option to set max-uploads parameter
|
||||
func MaxUploads(value int) Option {
|
||||
return addParam("max-uploads", strconv.Itoa(value))
|
||||
}
|
||||
|
||||
// KeyMarker is an option to set key-marker parameter
|
||||
func KeyMarker(value string) Option {
|
||||
return addParam("key-marker", value)
|
||||
}
|
||||
|
||||
// VersionIdMarker is an option to set version-id-marker parameter
|
||||
func VersionIdMarker(value string) Option {
|
||||
return addParam("version-id-marker", value)
|
||||
}
|
||||
|
||||
// VersionId is an option to set versionId parameter
|
||||
func VersionId(value string) Option {
|
||||
return addParam("versionId", value)
|
||||
}
|
||||
|
||||
// TagKey is an option to set tag key parameter
|
||||
func TagKey(value string) Option {
|
||||
return addParam("tag-key", value)
|
||||
}
|
||||
|
||||
// TagValue is an option to set tag value parameter
|
||||
func TagValue(value string) Option {
|
||||
return addParam("tag-value", value)
|
||||
}
|
||||
|
||||
// UploadIDMarker is an option to set upload-id-marker parameter
|
||||
func UploadIDMarker(value string) Option {
|
||||
return addParam("upload-id-marker", value)
|
||||
}
|
||||
|
||||
// MaxParts is an option to set max-parts parameter
|
||||
func MaxParts(value int) Option {
|
||||
return addParam("max-parts", strconv.Itoa(value))
|
||||
}
|
||||
|
||||
// PartNumberMarker is an option to set part-number-marker parameter
|
||||
func PartNumberMarker(value int) Option {
|
||||
return addParam("part-number-marker", strconv.Itoa(value))
|
||||
}
|
||||
|
||||
// Sequential is an option to set sequential parameter for InitiateMultipartUpload
|
||||
func Sequential() Option {
|
||||
return addParam("sequential", "")
|
||||
}
|
||||
|
||||
// WithHashContext is an option to set withHashContext parameter for InitiateMultipartUpload
|
||||
func WithHashContext() Option {
|
||||
return addParam("withHashContext", "")
|
||||
}
|
||||
|
||||
// EnableMd5 is an option to set x-oss-enable-md5 parameter for InitiateMultipartUpload
|
||||
func EnableMd5() Option {
|
||||
return addParam("x-oss-enable-md5", "")
|
||||
}
|
||||
|
||||
// EnableSha1 is an option to set x-oss-enable-sha1 parameter for InitiateMultipartUpload
|
||||
func EnableSha1() Option {
|
||||
return addParam("x-oss-enable-sha1", "")
|
||||
}
|
||||
|
||||
// EnableSha256 is an option to set x-oss-enable-sha256 parameter for InitiateMultipartUpload
|
||||
func EnableSha256() Option {
|
||||
return addParam("x-oss-enable-sha256", "")
|
||||
}
|
||||
|
||||
// ListType is an option to set List-type parameter for ListObjectsV2
|
||||
func ListType(value int) Option {
|
||||
return addParam("list-type", strconv.Itoa(value))
|
||||
}
|
||||
|
||||
// StartAfter is an option to set start-after parameter for ListObjectsV2
|
||||
func StartAfter(value string) Option {
|
||||
return addParam("start-after", value)
|
||||
}
|
||||
|
||||
// ContinuationToken is an option to set Continuation-token parameter for ListObjectsV2
|
||||
func ContinuationToken(value string) Option {
|
||||
if value == "" {
|
||||
return addParam("continuation-token", nil)
|
||||
}
|
||||
return addParam("continuation-token", value)
|
||||
}
|
||||
|
||||
// FetchOwner is an option to set Fetch-owner parameter for ListObjectsV2
|
||||
func FetchOwner(value bool) Option {
|
||||
if value {
|
||||
return addParam("fetch-owner", "true")
|
||||
}
|
||||
return addParam("fetch-owner", "false")
|
||||
}
|
||||
|
||||
// DeleteObjectsQuiet false:DeleteObjects in verbose mode; true:DeleteObjects in quite mode. Default is false.
|
||||
func DeleteObjectsQuiet(isQuiet bool) Option {
|
||||
return addArg(deleteObjectsQuiet, isQuiet)
|
||||
}
|
||||
|
||||
// StorageClass bucket storage class
|
||||
func StorageClass(value StorageClassType) Option {
|
||||
return addArg(storageClass, value)
|
||||
}
|
||||
|
||||
// RedundancyType bucket data redundancy type
|
||||
func RedundancyType(value DataRedundancyType) Option {
|
||||
return addArg(redundancyType, value)
|
||||
}
|
||||
|
||||
// RedundancyType bucket data redundancy type
|
||||
func ObjectHashFunc(value ObjecthashFuncType) Option {
|
||||
return addArg(objectHashFunc, value)
|
||||
}
|
||||
|
||||
// WithContext returns an option that sets the context for requests.
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return addArg(contextArg, ctx)
|
||||
}
|
||||
|
||||
// Checkpoint configuration
|
||||
type cpConfig struct {
|
||||
IsEnable bool
|
||||
FilePath string
|
||||
DirPath string
|
||||
}
|
||||
|
||||
// Checkpoint sets the isEnable flag and checkpoint file path for DownloadFile/UploadFile.
|
||||
func Checkpoint(isEnable bool, filePath string) Option {
|
||||
return addArg(checkpointConfig, &cpConfig{IsEnable: isEnable, FilePath: filePath})
|
||||
}
|
||||
|
||||
// CheckpointDir sets the isEnable flag and checkpoint dir path for DownloadFile/UploadFile.
|
||||
func CheckpointDir(isEnable bool, dirPath string) Option {
|
||||
return addArg(checkpointConfig, &cpConfig{IsEnable: isEnable, DirPath: dirPath})
|
||||
}
|
||||
|
||||
// Routines DownloadFile/UploadFile routine count
|
||||
func Routines(n int) Option {
|
||||
return addArg(routineNum, n)
|
||||
}
|
||||
|
||||
// InitCRC Init AppendObject CRC
|
||||
func InitCRC(initCRC uint64) Option {
|
||||
return addArg(initCRC64, initCRC)
|
||||
}
|
||||
|
||||
// Progress set progress listener
|
||||
func Progress(listener ProgressListener) Option {
|
||||
return addArg(progressListener, listener)
|
||||
}
|
||||
|
||||
// GetResponseHeader for get response http header
|
||||
func GetResponseHeader(respHeader *http.Header) Option {
|
||||
return addArg(responseHeader, respHeader)
|
||||
}
|
||||
|
||||
// CallbackResult for get response of call back
|
||||
func CallbackResult(body *[]byte) Option {
|
||||
return addArg(responseBody, body)
|
||||
}
|
||||
|
||||
// ResponseContentType is an option to set response-content-type param
|
||||
func ResponseContentType(value string) Option {
|
||||
return addParam("response-content-type", value)
|
||||
}
|
||||
|
||||
// ResponseContentLanguage is an option to set response-content-language param
|
||||
func ResponseContentLanguage(value string) Option {
|
||||
return addParam("response-content-language", value)
|
||||
}
|
||||
|
||||
// ResponseExpires is an option to set response-expires param
|
||||
func ResponseExpires(value string) Option {
|
||||
return addParam("response-expires", value)
|
||||
}
|
||||
|
||||
// ResponseCacheControl is an option to set response-cache-control param
|
||||
func ResponseCacheControl(value string) Option {
|
||||
return addParam("response-cache-control", value)
|
||||
}
|
||||
|
||||
// ResponseContentDisposition is an option to set response-content-disposition param
|
||||
func ResponseContentDisposition(value string) Option {
|
||||
return addParam("response-content-disposition", value)
|
||||
}
|
||||
|
||||
// ResponseContentEncoding is an option to set response-content-encoding param
|
||||
func ResponseContentEncoding(value string) Option {
|
||||
return addParam("response-content-encoding", value)
|
||||
}
|
||||
|
||||
// Process is an option to set x-oss-process param
|
||||
func Process(value string) Option {
|
||||
return addParam("x-oss-process", value)
|
||||
}
|
||||
|
||||
// TrafficLimitParam is a option to set x-oss-traffic-limit
|
||||
func TrafficLimitParam(value int64) Option {
|
||||
return addParam("x-oss-traffic-limit", strconv.FormatInt(value, 10))
|
||||
}
|
||||
|
||||
// SetHeader Allow users to set personalized http headers
|
||||
func SetHeader(key string, value interface{}) Option {
|
||||
return setHeader(key, value)
|
||||
}
|
||||
|
||||
// AddParam Allow users to set personalized http params
|
||||
func AddParam(key string, value interface{}) Option {
|
||||
return addParam(key, value)
|
||||
}
|
||||
|
||||
func setHeader(key string, value interface{}) Option {
|
||||
return func(params map[string]optionValue) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
params[key] = optionValue{value, optionHTTP}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func addParam(key string, value interface{}) Option {
|
||||
return func(params map[string]optionValue) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
params[key] = optionValue{value, optionParam}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func addArg(key string, value interface{}) Option {
|
||||
return func(params map[string]optionValue) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
params[key] = optionValue{value, optionArg}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleOptions(headers map[string]string, options []Option) error {
|
||||
params := map[string]optionValue{}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
if err := option(params); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range params {
|
||||
if v.Type == optionHTTP {
|
||||
headers[k] = v.Value.(string)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetRawParams(options []Option) (map[string]interface{}, error) {
|
||||
// Option
|
||||
params := map[string]optionValue{}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
if err := option(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paramsm := map[string]interface{}{}
|
||||
// Serialize
|
||||
for k, v := range params {
|
||||
if v.Type == optionParam {
|
||||
vs := params[k]
|
||||
paramsm[k] = vs.Value.(string)
|
||||
}
|
||||
}
|
||||
|
||||
return paramsm, nil
|
||||
}
|
||||
|
||||
func FindOption(options []Option, param string, defaultVal interface{}) (interface{}, error) {
|
||||
params := map[string]optionValue{}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
if err := option(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if val, ok := params[param]; ok {
|
||||
return val.Value, nil
|
||||
}
|
||||
return defaultVal, nil
|
||||
}
|
||||
|
||||
func IsOptionSet(options []Option, option string) (bool, interface{}, error) {
|
||||
params := map[string]optionValue{}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
if err := option(params); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if val, ok := params[option]; ok {
|
||||
return true, val.Value, nil
|
||||
}
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
func DeleteOption(options []Option, strKey string) []Option {
|
||||
var outOption []Option
|
||||
params := map[string]optionValue{}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(params)
|
||||
_, exist := params[strKey]
|
||||
if !exist {
|
||||
outOption = append(outOption, option)
|
||||
} else {
|
||||
delete(params, strKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return outOption
|
||||
}
|
||||
|
||||
func GetRequestId(header http.Header) string {
|
||||
return header.Get("x-oss-request-id")
|
||||
}
|
||||
|
||||
func GetVersionId(header http.Header) string {
|
||||
return header.Get("x-oss-version-id")
|
||||
}
|
||||
|
||||
func GetCopySrcVersionId(header http.Header) string {
|
||||
return header.Get("x-oss-copy-source-version-id")
|
||||
}
|
||||
|
||||
func GetDeleteMark(header http.Header) bool {
|
||||
value := header.Get("x-oss-delete-marker")
|
||||
if strings.ToUpper(value) == "TRUE" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetQosDelayTime(header http.Header) string {
|
||||
return header.Get("x-oss-qos-delay-time")
|
||||
}
|
||||
|
||||
// ForbidOverWrite is an option to set X-Oss-Forbid-Overwrite
|
||||
func AllowSameActionOverLap(enabled bool) Option {
|
||||
if enabled {
|
||||
return setHeader(HTTPHeaderAllowSameActionOverLap, "true")
|
||||
} else {
|
||||
return setHeader(HTTPHeaderAllowSameActionOverLap, "false")
|
||||
}
|
||||
}
|
||||
|
||||
func GetCallbackBody(options []Option, resp *Response, callbackSet bool) error {
|
||||
var err error
|
||||
|
||||
// get response body
|
||||
if callbackSet {
|
||||
err = setBody(options, resp)
|
||||
} else {
|
||||
callback, _ := FindOption(options, HTTPHeaderOssCallback, nil)
|
||||
if callback != nil {
|
||||
err = setBody(options, resp)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func setBody(options []Option, resp *Response) error {
|
||||
respBody, _ := FindOption(options, responseBody, nil)
|
||||
if respBody != nil && resp != nil {
|
||||
pRespBody := respBody.(*[]byte)
|
||||
pBody, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pBody != nil {
|
||||
*pRespBody = pBody
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// ProgressEventType defines transfer progress event type
|
||||
type ProgressEventType int
|
||||
|
||||
const (
|
||||
// TransferStartedEvent transfer started, set TotalBytes
|
||||
TransferStartedEvent ProgressEventType = 1 + iota
|
||||
// TransferDataEvent transfer data, set ConsumedBytes and TotalBytes
|
||||
TransferDataEvent
|
||||
// TransferCompletedEvent transfer completed
|
||||
TransferCompletedEvent
|
||||
// TransferFailedEvent transfer encounters an error
|
||||
TransferFailedEvent
|
||||
)
|
||||
|
||||
// ProgressEvent defines progress event
|
||||
type ProgressEvent struct {
|
||||
ConsumedBytes int64
|
||||
TotalBytes int64
|
||||
RwBytes int64
|
||||
EventType ProgressEventType
|
||||
}
|
||||
|
||||
// ProgressListener listens progress change
|
||||
type ProgressListener interface {
|
||||
ProgressChanged(event *ProgressEvent)
|
||||
}
|
||||
|
||||
// -------------------- Private --------------------
|
||||
|
||||
func newProgressEvent(eventType ProgressEventType, consumed, total int64, rwBytes int64) *ProgressEvent {
|
||||
return &ProgressEvent{
|
||||
ConsumedBytes: consumed,
|
||||
TotalBytes: total,
|
||||
RwBytes: rwBytes,
|
||||
EventType: eventType}
|
||||
}
|
||||
|
||||
// publishProgress
|
||||
func publishProgress(listener ProgressListener, event *ProgressEvent) {
|
||||
if listener != nil && event != nil {
|
||||
listener.ProgressChanged(event)
|
||||
}
|
||||
}
|
||||
|
||||
type readerTracker struct {
|
||||
completedBytes int64
|
||||
}
|
||||
|
||||
type teeReader struct {
|
||||
reader io.Reader
|
||||
writer io.Writer
|
||||
listener ProgressListener
|
||||
consumedBytes int64
|
||||
totalBytes int64
|
||||
tracker *readerTracker
|
||||
}
|
||||
|
||||
// TeeReader returns a Reader that writes to w what it reads from r.
|
||||
// All reads from r performed through it are matched with
|
||||
// corresponding writes to w. There is no internal buffering -
|
||||
// the write must complete before the read completes.
|
||||
// Any error encountered while writing is reported as a read error.
|
||||
func TeeReader(reader io.Reader, writer io.Writer, totalBytes int64, listener ProgressListener, tracker *readerTracker) io.ReadCloser {
|
||||
return &teeReader{
|
||||
reader: reader,
|
||||
writer: writer,
|
||||
listener: listener,
|
||||
consumedBytes: 0,
|
||||
totalBytes: totalBytes,
|
||||
tracker: tracker,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *teeReader) Read(p []byte) (n int, err error) {
|
||||
n, err = t.reader.Read(p)
|
||||
|
||||
// Read encountered error
|
||||
if err != nil && err != io.EOF {
|
||||
event := newProgressEvent(TransferFailedEvent, t.consumedBytes, t.totalBytes, 0)
|
||||
publishProgress(t.listener, event)
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
t.consumedBytes += int64(n)
|
||||
// CRC
|
||||
if t.writer != nil {
|
||||
if n, err := t.writer.Write(p[:n]); err != nil {
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
// Progress
|
||||
if t.listener != nil {
|
||||
event := newProgressEvent(TransferDataEvent, t.consumedBytes, t.totalBytes, int64(n))
|
||||
publishProgress(t.listener, event)
|
||||
}
|
||||
// Track
|
||||
if t.tracker != nil {
|
||||
t.tracker.completedBytes = t.consumedBytes
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *teeReader) Close() error {
|
||||
if rc, ok := t.reader.(io.ReadCloser); ok {
|
||||
return rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//go:build !go1.7
|
||||
// +build !go1.7
|
||||
|
||||
package oss
|
||||
|
||||
import "net/http"
|
||||
|
||||
// http.ErrUseLastResponse only is defined go1.7 onward
|
||||
|
||||
func disableHTTPRedirect(client *http.Client) {
|
||||
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
//go:build go1.7
|
||||
// +build go1.7
|
||||
|
||||
package oss
|
||||
|
||||
import "net/http"
|
||||
|
||||
// http.ErrUseLastResponse only is defined go1.7 onward
|
||||
func disableHTTPRedirect(client *http.Client) {
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateSelectCsvObjectMeta is Creating csv object meta
|
||||
//
|
||||
// key the object key.
|
||||
// csvMeta the csv file meta
|
||||
// options the options for create csv Meta of the object.
|
||||
//
|
||||
// MetaEndFrameCSV the csv file meta info
|
||||
// error it's nil if no error, otherwise it's an error object.
|
||||
//
|
||||
func (bucket Bucket) CreateSelectCsvObjectMeta(key string, csvMeta CsvMetaRequest, options ...Option) (MetaEndFrameCSV, error) {
|
||||
var endFrame MetaEndFrameCSV
|
||||
params := map[string]interface{}{}
|
||||
params["x-oss-process"] = "csv/meta"
|
||||
|
||||
csvMeta.encodeBase64()
|
||||
bs, err := xml.Marshal(csvMeta)
|
||||
if err != nil {
|
||||
return endFrame, err
|
||||
}
|
||||
buffer := new(bytes.Buffer)
|
||||
buffer.Write(bs)
|
||||
|
||||
resp, err := bucket.DoPostSelectObject(key, params, buffer, options...)
|
||||
if err != nil {
|
||||
return endFrame, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, err = ioutil.ReadAll(resp)
|
||||
|
||||
return resp.Frame.MetaEndFrameCSV, err
|
||||
}
|
||||
|
||||
// CreateSelectJsonObjectMeta is Creating json object meta
|
||||
//
|
||||
// key the object key.
|
||||
// csvMeta the json file meta
|
||||
// options the options for create json Meta of the object.
|
||||
//
|
||||
// MetaEndFrameJSON the json file meta info
|
||||
// error it's nil if no error, otherwise it's an error object.
|
||||
//
|
||||
func (bucket Bucket) CreateSelectJsonObjectMeta(key string, jsonMeta JsonMetaRequest, options ...Option) (MetaEndFrameJSON, error) {
|
||||
var endFrame MetaEndFrameJSON
|
||||
params := map[string]interface{}{}
|
||||
params["x-oss-process"] = "json/meta"
|
||||
|
||||
bs, err := xml.Marshal(jsonMeta)
|
||||
if err != nil {
|
||||
return endFrame, err
|
||||
}
|
||||
buffer := new(bytes.Buffer)
|
||||
buffer.Write(bs)
|
||||
|
||||
resp, err := bucket.DoPostSelectObject(key, params, buffer, options...)
|
||||
if err != nil {
|
||||
return endFrame, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, err = ioutil.ReadAll(resp)
|
||||
|
||||
return resp.Frame.MetaEndFrameJSON, err
|
||||
}
|
||||
|
||||
// SelectObject is the select object api, approve csv and json file.
|
||||
//
|
||||
// key the object key.
|
||||
// selectReq the request data for select object
|
||||
// options the options for select file of the object.
|
||||
//
|
||||
// o.ReadCloser reader instance for reading data from response. It must be called close() after the usage and only valid when error is nil.
|
||||
// error it's nil if no error, otherwise it's an error object.
|
||||
//
|
||||
func (bucket Bucket) SelectObject(key string, selectReq SelectRequest, options ...Option) (io.ReadCloser, error) {
|
||||
params := map[string]interface{}{}
|
||||
if selectReq.InputSerializationSelect.JsonBodyInput.JsonIsEmpty() {
|
||||
params["x-oss-process"] = "csv/select" // default select csv file
|
||||
} else {
|
||||
params["x-oss-process"] = "json/select"
|
||||
}
|
||||
selectReq.encodeBase64()
|
||||
bs, err := xml.Marshal(selectReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buffer := new(bytes.Buffer)
|
||||
buffer.Write(bs)
|
||||
resp, err := bucket.DoPostSelectObject(key, params, buffer, options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selectReq.OutputSerializationSelect.EnablePayloadCrc != nil && *selectReq.OutputSerializationSelect.EnablePayloadCrc == true {
|
||||
resp.Frame.EnablePayloadCrc = true
|
||||
}
|
||||
resp.Frame.OutputRawData = strings.ToUpper(resp.Headers.Get("x-oss-select-output-raw")) == "TRUE"
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// DoPostSelectObject is the SelectObject/CreateMeta api, approve csv and json file.
|
||||
//
|
||||
// key the object key.
|
||||
// params the resource of oss approve csv/meta, json/meta, csv/select, json/select.
|
||||
// buf the request data trans to buffer.
|
||||
// options the options for select file of the object.
|
||||
//
|
||||
// SelectObjectResponse the response of select object.
|
||||
// error it's nil if no error, otherwise it's an error object.
|
||||
//
|
||||
func (bucket Bucket) DoPostSelectObject(key string, params map[string]interface{}, buf *bytes.Buffer, options ...Option) (*SelectObjectResponse, error) {
|
||||
resp, err := bucket.do("POST", key, params, options, buf, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SelectObjectResponse{
|
||||
Body: resp.Body,
|
||||
StatusCode: resp.StatusCode,
|
||||
Frame: SelectObjectResult{},
|
||||
}
|
||||
result.Headers = resp.Headers
|
||||
// result.Frame = SelectObjectResult{}
|
||||
result.ReadTimeOut = bucket.GetConfig().Timeout
|
||||
|
||||
// Progress
|
||||
listener := GetProgressListener(options)
|
||||
|
||||
// CRC32
|
||||
crcCalc := crc32.NewIEEE()
|
||||
result.WriterForCheckCrc32 = crcCalc
|
||||
result.Body = TeeReader(resp.Body, nil, 0, listener, nil)
|
||||
|
||||
err = CheckRespCode(resp.StatusCode, []int{http.StatusPartialContent, http.StatusOK})
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SelectObjectIntoFile is the selectObject to file api
|
||||
//
|
||||
// key the object key.
|
||||
// fileName saving file's name to localstation.
|
||||
// selectReq the request data for select object
|
||||
// options the options for select file of the object.
|
||||
//
|
||||
// error it's nil if no error, otherwise it's an error object.
|
||||
//
|
||||
func (bucket Bucket) SelectObjectIntoFile(key, fileName string, selectReq SelectRequest, options ...Option) error {
|
||||
tempFilePath := fileName + TempFileSuffix
|
||||
|
||||
params := map[string]interface{}{}
|
||||
if selectReq.InputSerializationSelect.JsonBodyInput.JsonIsEmpty() {
|
||||
params["x-oss-process"] = "csv/select" // default select csv file
|
||||
} else {
|
||||
params["x-oss-process"] = "json/select"
|
||||
}
|
||||
selectReq.encodeBase64()
|
||||
bs, err := xml.Marshal(selectReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := new(bytes.Buffer)
|
||||
buffer.Write(bs)
|
||||
resp, err := bucket.DoPostSelectObject(key, params, buffer, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
// If the local file does not exist, create a new one. If it exists, overwrite it.
|
||||
fd, err := os.OpenFile(tempFilePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, FilePermMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy the data to the local file path.
|
||||
_, err = io.Copy(fd, resp)
|
||||
fd.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(tempFilePath, fileName)
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The adapter class for Select object's response.
|
||||
// The response consists of frames. Each frame has the following format:
|
||||
|
||||
// Type | Payload Length | Header Checksum | Payload | Payload Checksum
|
||||
|
||||
// |<4-->| <--4 bytes------><---4 bytes-------><-n/a-----><--4 bytes--------->
|
||||
// And we have three kind of frames.
|
||||
// Data Frame:
|
||||
// Type:8388609
|
||||
// Payload: Offset | Data
|
||||
// <-8 bytes>
|
||||
|
||||
// Continuous Frame
|
||||
// Type:8388612
|
||||
// Payload: Offset (8-bytes)
|
||||
|
||||
// End Frame
|
||||
// Type:8388613
|
||||
// Payload: Offset | total scanned bytes | http status code | error message
|
||||
// <-- 8bytes--><-----8 bytes--------><---4 bytes-------><---variabe--->
|
||||
|
||||
// SelectObjectResponse defines HTTP response from OSS SelectObject
|
||||
type SelectObjectResponse struct {
|
||||
StatusCode int
|
||||
Headers http.Header
|
||||
Body io.ReadCloser
|
||||
Frame SelectObjectResult
|
||||
ReadTimeOut uint
|
||||
ClientCRC32 uint32
|
||||
ServerCRC32 uint32
|
||||
WriterForCheckCrc32 hash.Hash32
|
||||
Finish bool
|
||||
}
|
||||
|
||||
func (sr *SelectObjectResponse) Read(p []byte) (n int, err error) {
|
||||
n, err = sr.readFrames(p)
|
||||
return
|
||||
}
|
||||
|
||||
// Close http reponse body
|
||||
func (sr *SelectObjectResponse) Close() error {
|
||||
return sr.Body.Close()
|
||||
}
|
||||
|
||||
// PostSelectResult is the request of SelectObject
|
||||
type PostSelectResult struct {
|
||||
Response *SelectObjectResponse
|
||||
}
|
||||
|
||||
// readFrames is read Frame
|
||||
func (sr *SelectObjectResponse) readFrames(p []byte) (int, error) {
|
||||
var nn int
|
||||
var err error
|
||||
var checkValid bool
|
||||
if sr.Frame.OutputRawData == true {
|
||||
nn, err = sr.Body.Read(p)
|
||||
return nn, err
|
||||
}
|
||||
|
||||
if sr.Finish {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
for {
|
||||
// if this Frame is Readed, then not reading Header
|
||||
if sr.Frame.OpenLine != true {
|
||||
err = sr.analysisHeader()
|
||||
if err != nil {
|
||||
return nn, err
|
||||
}
|
||||
}
|
||||
|
||||
if sr.Frame.FrameType == DataFrameType {
|
||||
n, err := sr.analysisData(p[nn:])
|
||||
if err != nil {
|
||||
return nn, err
|
||||
}
|
||||
nn += n
|
||||
|
||||
// if this Frame is readed all data, then empty the Frame to read it with next frame
|
||||
if sr.Frame.ConsumedBytesLength == sr.Frame.PayloadLength-8 {
|
||||
checkValid, err = sr.checkPayloadSum()
|
||||
if err != nil || !checkValid {
|
||||
return nn, fmt.Errorf("%s", err.Error())
|
||||
}
|
||||
sr.emptyFrame()
|
||||
}
|
||||
|
||||
if nn == len(p) {
|
||||
return nn, nil
|
||||
}
|
||||
} else if sr.Frame.FrameType == ContinuousFrameType {
|
||||
checkValid, err = sr.checkPayloadSum()
|
||||
if err != nil || !checkValid {
|
||||
return nn, fmt.Errorf("%s", err.Error())
|
||||
}
|
||||
sr.Frame.OpenLine = false
|
||||
} else if sr.Frame.FrameType == EndFrameType {
|
||||
err = sr.analysisEndFrame()
|
||||
if err != nil {
|
||||
return nn, err
|
||||
}
|
||||
checkValid, err = sr.checkPayloadSum()
|
||||
if checkValid {
|
||||
sr.Finish = true
|
||||
}
|
||||
return nn, err
|
||||
} else if sr.Frame.FrameType == MetaEndFrameCSVType {
|
||||
err = sr.analysisMetaEndFrameCSV()
|
||||
if err != nil {
|
||||
return nn, err
|
||||
}
|
||||
checkValid, err = sr.checkPayloadSum()
|
||||
if checkValid {
|
||||
sr.Finish = true
|
||||
}
|
||||
return nn, err
|
||||
} else if sr.Frame.FrameType == MetaEndFrameJSONType {
|
||||
err = sr.analysisMetaEndFrameJSON()
|
||||
if err != nil {
|
||||
return nn, err
|
||||
}
|
||||
checkValid, err = sr.checkPayloadSum()
|
||||
if checkValid {
|
||||
sr.Finish = true
|
||||
}
|
||||
return nn, err
|
||||
}
|
||||
}
|
||||
return nn, nil
|
||||
}
|
||||
|
||||
type chanReadIO struct {
|
||||
readLen int
|
||||
err error
|
||||
}
|
||||
|
||||
func (sr *SelectObjectResponse) readLen(p []byte, timeOut time.Duration) (int, error) {
|
||||
r := sr.Body
|
||||
ch := make(chan chanReadIO, 1)
|
||||
defer close(ch)
|
||||
go func(p []byte) {
|
||||
var needReadLength int
|
||||
readChan := chanReadIO{}
|
||||
needReadLength = len(p)
|
||||
for {
|
||||
n, err := r.Read(p[readChan.readLen:needReadLength])
|
||||
readChan.readLen += n
|
||||
if err != nil {
|
||||
readChan.err = err
|
||||
ch <- readChan
|
||||
return
|
||||
}
|
||||
|
||||
if readChan.readLen == needReadLength {
|
||||
break
|
||||
}
|
||||
}
|
||||
ch <- readChan
|
||||
}(p)
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second * timeOut):
|
||||
return 0, fmt.Errorf("requestId: %s, readLen timeout, timeout is %d(second),need read:%d", sr.Headers.Get(HTTPHeaderOssRequestID), timeOut, len(p))
|
||||
case result := <-ch:
|
||||
return result.readLen, result.err
|
||||
}
|
||||
}
|
||||
|
||||
// analysisHeader is reading selectObject response body's header
|
||||
func (sr *SelectObjectResponse) analysisHeader() error {
|
||||
headFrameByte := make([]byte, 20)
|
||||
_, err := sr.readLen(headFrameByte, time.Duration(sr.ReadTimeOut))
|
||||
if err != nil {
|
||||
return fmt.Errorf("requestId: %s, Read response frame header failure,err:%s", sr.Headers.Get(HTTPHeaderOssRequestID), err.Error())
|
||||
}
|
||||
|
||||
frameTypeByte := headFrameByte[0:4]
|
||||
sr.Frame.Version = frameTypeByte[0]
|
||||
frameTypeByte[0] = 0
|
||||
bytesToInt(frameTypeByte, &sr.Frame.FrameType)
|
||||
|
||||
if sr.Frame.FrameType != DataFrameType && sr.Frame.FrameType != ContinuousFrameType &&
|
||||
sr.Frame.FrameType != EndFrameType && sr.Frame.FrameType != MetaEndFrameCSVType && sr.Frame.FrameType != MetaEndFrameJSONType {
|
||||
return fmt.Errorf("requestId: %s, Unexpected frame type: %d", sr.Headers.Get(HTTPHeaderOssRequestID), sr.Frame.FrameType)
|
||||
}
|
||||
|
||||
payloadLengthByte := headFrameByte[4:8]
|
||||
bytesToInt(payloadLengthByte, &sr.Frame.PayloadLength)
|
||||
headCheckSumByte := headFrameByte[8:12]
|
||||
bytesToInt(headCheckSumByte, &sr.Frame.HeaderCheckSum)
|
||||
byteOffset := headFrameByte[12:20]
|
||||
bytesToInt(byteOffset, &sr.Frame.Offset)
|
||||
sr.Frame.OpenLine = true
|
||||
|
||||
err = sr.writerCheckCrc32(byteOffset)
|
||||
return err
|
||||
}
|
||||
|
||||
// analysisData is reading the DataFrameType data of selectObject response body
|
||||
func (sr *SelectObjectResponse) analysisData(p []byte) (int, error) {
|
||||
var needReadLength int32
|
||||
lenP := int32(len(p))
|
||||
restByteLength := sr.Frame.PayloadLength - 8 - sr.Frame.ConsumedBytesLength
|
||||
if lenP <= restByteLength {
|
||||
needReadLength = lenP
|
||||
} else {
|
||||
needReadLength = restByteLength
|
||||
}
|
||||
n, err := sr.readLen(p[:needReadLength], time.Duration(sr.ReadTimeOut))
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("read frame data error,%s", err.Error())
|
||||
}
|
||||
sr.Frame.ConsumedBytesLength += int32(n)
|
||||
err = sr.writerCheckCrc32(p[:n])
|
||||
return n, err
|
||||
}
|
||||
|
||||
// analysisEndFrame is reading the EndFrameType data of selectObject response body
|
||||
func (sr *SelectObjectResponse) analysisEndFrame() error {
|
||||
var eF EndFrame
|
||||
payLoadBytes := make([]byte, sr.Frame.PayloadLength-8)
|
||||
_, err := sr.readLen(payLoadBytes, time.Duration(sr.ReadTimeOut))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read end frame error:%s", err.Error())
|
||||
}
|
||||
bytesToInt(payLoadBytes[0:8], &eF.TotalScanned)
|
||||
bytesToInt(payLoadBytes[8:12], &eF.HTTPStatusCode)
|
||||
errMsgLength := sr.Frame.PayloadLength - 20
|
||||
eF.ErrorMsg = string(payLoadBytes[12 : errMsgLength+12])
|
||||
sr.Frame.EndFrame.TotalScanned = eF.TotalScanned
|
||||
sr.Frame.EndFrame.HTTPStatusCode = eF.HTTPStatusCode
|
||||
sr.Frame.EndFrame.ErrorMsg = eF.ErrorMsg
|
||||
err = sr.writerCheckCrc32(payLoadBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
// analysisMetaEndFrameCSV is reading the MetaEndFrameCSVType data of selectObject response body
|
||||
func (sr *SelectObjectResponse) analysisMetaEndFrameCSV() error {
|
||||
var mCF MetaEndFrameCSV
|
||||
payLoadBytes := make([]byte, sr.Frame.PayloadLength-8)
|
||||
_, err := sr.readLen(payLoadBytes, time.Duration(sr.ReadTimeOut))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read meta end csv frame error:%s", err.Error())
|
||||
}
|
||||
|
||||
bytesToInt(payLoadBytes[0:8], &mCF.TotalScanned)
|
||||
bytesToInt(payLoadBytes[8:12], &mCF.Status)
|
||||
bytesToInt(payLoadBytes[12:16], &mCF.SplitsCount)
|
||||
bytesToInt(payLoadBytes[16:24], &mCF.RowsCount)
|
||||
bytesToInt(payLoadBytes[24:28], &mCF.ColumnsCount)
|
||||
errMsgLength := sr.Frame.PayloadLength - 36
|
||||
mCF.ErrorMsg = string(payLoadBytes[28 : errMsgLength+28])
|
||||
sr.Frame.MetaEndFrameCSV.ErrorMsg = mCF.ErrorMsg
|
||||
sr.Frame.MetaEndFrameCSV.TotalScanned = mCF.TotalScanned
|
||||
sr.Frame.MetaEndFrameCSV.Status = mCF.Status
|
||||
sr.Frame.MetaEndFrameCSV.SplitsCount = mCF.SplitsCount
|
||||
sr.Frame.MetaEndFrameCSV.RowsCount = mCF.RowsCount
|
||||
sr.Frame.MetaEndFrameCSV.ColumnsCount = mCF.ColumnsCount
|
||||
err = sr.writerCheckCrc32(payLoadBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
// analysisMetaEndFrameJSON is reading the MetaEndFrameJSONType data of selectObject response body
|
||||
func (sr *SelectObjectResponse) analysisMetaEndFrameJSON() error {
|
||||
var mJF MetaEndFrameJSON
|
||||
payLoadBytes := make([]byte, sr.Frame.PayloadLength-8)
|
||||
_, err := sr.readLen(payLoadBytes, time.Duration(sr.ReadTimeOut))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read meta end json frame error:%s", err.Error())
|
||||
}
|
||||
|
||||
bytesToInt(payLoadBytes[0:8], &mJF.TotalScanned)
|
||||
bytesToInt(payLoadBytes[8:12], &mJF.Status)
|
||||
bytesToInt(payLoadBytes[12:16], &mJF.SplitsCount)
|
||||
bytesToInt(payLoadBytes[16:24], &mJF.RowsCount)
|
||||
errMsgLength := sr.Frame.PayloadLength - 32
|
||||
mJF.ErrorMsg = string(payLoadBytes[24 : errMsgLength+24])
|
||||
sr.Frame.MetaEndFrameJSON.ErrorMsg = mJF.ErrorMsg
|
||||
sr.Frame.MetaEndFrameJSON.TotalScanned = mJF.TotalScanned
|
||||
sr.Frame.MetaEndFrameJSON.Status = mJF.Status
|
||||
sr.Frame.MetaEndFrameJSON.SplitsCount = mJF.SplitsCount
|
||||
sr.Frame.MetaEndFrameJSON.RowsCount = mJF.RowsCount
|
||||
|
||||
err = sr.writerCheckCrc32(payLoadBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sr *SelectObjectResponse) checkPayloadSum() (bool, error) {
|
||||
payLoadChecksumByte := make([]byte, 4)
|
||||
n, err := sr.readLen(payLoadChecksumByte, time.Duration(sr.ReadTimeOut))
|
||||
if n == 4 {
|
||||
bytesToInt(payLoadChecksumByte, &sr.Frame.PayloadChecksum)
|
||||
sr.ServerCRC32 = sr.Frame.PayloadChecksum
|
||||
sr.ClientCRC32 = sr.WriterForCheckCrc32.Sum32()
|
||||
if sr.Frame.EnablePayloadCrc == true && sr.ServerCRC32 != 0 && sr.ServerCRC32 != sr.ClientCRC32 {
|
||||
return false, fmt.Errorf("RequestId: %s, Unexpected frame type: %d, client %d but server %d",
|
||||
sr.Headers.Get(HTTPHeaderOssRequestID), sr.Frame.FrameType, sr.ClientCRC32, sr.ServerCRC32)
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
return false, fmt.Errorf("RequestId:%s, read checksum error:%s", sr.Headers.Get(HTTPHeaderOssRequestID), err.Error())
|
||||
}
|
||||
|
||||
func (sr *SelectObjectResponse) writerCheckCrc32(p []byte) (err error) {
|
||||
err = nil
|
||||
if sr.Frame.EnablePayloadCrc == true {
|
||||
_, err = sr.WriterForCheckCrc32.Write(p)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// emptyFrame is emptying SelectObjectResponse Frame information
|
||||
func (sr *SelectObjectResponse) emptyFrame() {
|
||||
crcCalc := crc32.NewIEEE()
|
||||
sr.WriterForCheckCrc32 = crcCalc
|
||||
sr.Finish = false
|
||||
|
||||
sr.Frame.ConsumedBytesLength = 0
|
||||
sr.Frame.OpenLine = false
|
||||
sr.Frame.Version = byte(0)
|
||||
sr.Frame.FrameType = 0
|
||||
sr.Frame.PayloadLength = 0
|
||||
sr.Frame.HeaderCheckSum = 0
|
||||
sr.Frame.Offset = 0
|
||||
sr.Frame.Data = ""
|
||||
|
||||
sr.Frame.EndFrame.TotalScanned = 0
|
||||
sr.Frame.EndFrame.HTTPStatusCode = 0
|
||||
sr.Frame.EndFrame.ErrorMsg = ""
|
||||
|
||||
sr.Frame.MetaEndFrameCSV.TotalScanned = 0
|
||||
sr.Frame.MetaEndFrameCSV.Status = 0
|
||||
sr.Frame.MetaEndFrameCSV.SplitsCount = 0
|
||||
sr.Frame.MetaEndFrameCSV.RowsCount = 0
|
||||
sr.Frame.MetaEndFrameCSV.ColumnsCount = 0
|
||||
sr.Frame.MetaEndFrameCSV.ErrorMsg = ""
|
||||
|
||||
sr.Frame.MetaEndFrameJSON.TotalScanned = 0
|
||||
sr.Frame.MetaEndFrameJSON.Status = 0
|
||||
sr.Frame.MetaEndFrameJSON.SplitsCount = 0
|
||||
sr.Frame.MetaEndFrameJSON.RowsCount = 0
|
||||
sr.Frame.MetaEndFrameJSON.ErrorMsg = ""
|
||||
|
||||
sr.Frame.PayloadChecksum = 0
|
||||
}
|
||||
|
||||
// bytesToInt byte's array trans to int
|
||||
func bytesToInt(b []byte, ret interface{}) {
|
||||
binBuf := bytes.NewBuffer(b)
|
||||
binary.Read(binBuf, binary.BigEndian, ret)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
//go:build !go1.7
|
||||
// +build !go1.7
|
||||
|
||||
package oss
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTransport(conn *Conn, config *Config) *http.Transport {
|
||||
httpTimeOut := conn.config.HTTPTimeout
|
||||
httpMaxConns := conn.config.HTTPMaxConns
|
||||
// New Transport
|
||||
transport := &http.Transport{
|
||||
Dial: func(netw, addr string) (net.Conn, error) {
|
||||
d := net.Dialer{
|
||||
Timeout: httpTimeOut.ConnectTimeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
if config.LocalAddr != nil {
|
||||
d.LocalAddr = config.LocalAddr
|
||||
}
|
||||
conn, err := d.Dial(netw, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newTimeoutConn(conn, httpTimeOut.ReadWriteTimeout, httpTimeOut.LongTimeout), nil
|
||||
},
|
||||
MaxIdleConnsPerHost: httpMaxConns.MaxIdleConnsPerHost,
|
||||
ResponseHeaderTimeout: httpTimeOut.HeaderTimeout,
|
||||
}
|
||||
|
||||
if config.InsecureSkipVerify {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
return transport
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user