From c517feaf5bf02ff5e52158ded1267baa96ece8e4 Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Thu, 19 Jan 2017 09:08:50 +0000 Subject: [PATCH 1/8] restruct go sdk --- README.md | 379 +++++++++++++-------------- examples/cc.jpg | Bin 34444 -> 0 bytes examples/config/config.go | 9 - examples/upform.go | 24 -- examples/upmedia.go | 29 -- examples/upmultipart.go | 19 -- examples/uprest.go | 36 --- upyun/auth.go | 84 ++++++ upyun/fileinfo.go | 61 +++++ upyun/form.go | 144 ++++++++++ upyun/form_test.go | 52 ++++ upyun/http.go | 55 ++++ upyun/io.go | 78 ++++++ upyun/process.go | 132 ++++++++++ upyun/process_test.go | 61 +++++ upyun/purge.go | 58 ++++ upyun/purge_test.go | 24 ++ upyun/rest.go | 490 ++++++++++++++++++++++++++++++++++ upyun/rest_test.go | 222 ++++++++++++++++ upyun/upyun-form-api.go | 110 -------- upyun/upyun-http-core.go | 102 -------- upyun/upyun-media-api.go | 166 ------------ upyun/upyun-multipart-api.go | 301 --------------------- upyun/upyun-rest-api.go | 495 ----------------------------------- upyun/upyun-resume.go | 93 ------- upyun/upyun.go | 189 +++---------- upyun/upyun_test.go | 388 ++++++--------------------- upyun/utils.go | 128 +++++++++ 28 files changed, 1888 insertions(+), 2041 deletions(-) delete mode 100644 examples/cc.jpg delete mode 100644 examples/config/config.go delete mode 100644 examples/upform.go delete mode 100644 examples/upmedia.go delete mode 100644 examples/upmultipart.go delete mode 100644 examples/uprest.go create mode 100644 upyun/auth.go create mode 100644 upyun/fileinfo.go create mode 100644 upyun/form.go create mode 100644 upyun/form_test.go create mode 100644 upyun/http.go create mode 100644 upyun/io.go create mode 100644 upyun/process.go create mode 100644 upyun/process_test.go create mode 100644 upyun/purge.go create mode 100644 upyun/purge_test.go create mode 100644 upyun/rest.go create mode 100644 upyun/rest_test.go delete mode 100644 upyun/upyun-form-api.go delete mode 100644 upyun/upyun-http-core.go delete mode 100644 upyun/upyun-media-api.go delete mode 100644 upyun/upyun-multipart-api.go delete mode 100644 upyun/upyun-rest-api.go delete mode 100644 upyun/upyun-resume.go create mode 100644 upyun/utils.go diff --git a/README.md b/README.md index 0554eb1..d915a1b 100644 --- a/README.md +++ b/README.md @@ -5,174 +5,119 @@ import "github.com/upyun/go-sdk/upyun" UPYUN Go SDK, 集成: -- [UPYUN HTTP REST 接口](http://docs.upyun.com/api/rest_api/) -- [UPYUN HTTP FORM 接口](http://docs.upyun.com/api/form_api/) -- [UPYUN 缓存刷新接口](http://docs.upyun.com/api/purge/) -- [UPYUN 分块上传接口](http://docs.upyun.com/api/multipart_upload/) -- [UPYUN 视频处理接口](http://docs.upyun.com/api/av_pretreatment/) - -Table of Contents -================= - - * [UPYUN Go SDK](#upyun-go-sdk) - * [Examples](#examples) - * [Projects using this SDK](#projects-using-this-sdk) - * [Usage](#usage) - * [UPYUN HTTP REST 接口](#upyun-http-rest-接口) - * [UpYun](#upyun) - * [初始化 UpYun](#初始化-upyun) - * [设置 API 访问域名](#设置-api-访问域名) - * [获取空间存储使用量](#获取空间存储使用量) - * [创建目录](#创建目录) - * [上传](#上传) - * [断点续传](#断点续传) - * [下载](#下载) - * [删除](#删除) - * [获取文件信息](#获取文件信息) - * [获取文件列表](#获取文件列表) - * [UPYUN 缓存刷新接口](#upyun-缓存刷新接口) - * [UPYUN HTTP 表单上传接口](#upyun-http-表单上传接口) - * [UpYunForm](#upyunform) - * [初始化 UpYunForm](#初始化-upyunform) - * [FormAPIResp](#formapiresp) - * [设置 API 访问域名](#设置-api-访问域名-1) - * [上传文件](#上传文件) - * [UPYUN 分块上传接口](#upyun-分块上传接口) - * [UpYunMultiPart](#upyunmultipart) - * [UploadResp](#uploadresp) - * [MergeResp](#mergeresp) - * [初始化 UpYunMultiPart](#初始化-upyunmultipart) - * [上传](#上传-1) - * [UPYUN 音视频处理接口](#upyun-音视频处理接口) - * [UpYunMedia](#upyunmedia) - * [MediaStatusResp](#mediastatusresp) - * [初始化 UpYunMedia](#初始化-upyunmedia) - * [提交任务](#提交任务) - * [查询进度](#查询进度) - -## Examples - -示例代码见 `examples/`。 +- [又拍云 HTTP REST 接口](http://docs.upyun.com/api/rest_api/) +- [又拍云 HTTP FORM 接口](http://docs.upyun.com/api/form_api/) +- [又拍云缓存刷新接口](http://docs.upyun.com/api/purge/) +- [又拍云视频处理接口](http://docs.upyun.com/api/av_pretreatment/) ## Projects using this SDK -- [UPYUN Command Tool](https://github.com/polym/upx) by [polym](https://github.com/polym) +- [又拍云命令行工具](https://github.com/polym/upx) by [polym](https://github.com/polym) ## Usage -### UPYUN HTTP REST 接口 - -#### UpYun +### 快速上手 ```go -type UpYun struct { - Bucket string // 空间名(即服务名称) - Username string // 操作员 - Passwd string // 密码 - ChunkSize int // 块读取大小, 默认32KB +package main + +import ( + "fmt" + "github.com/polym/new/upyun" +) + +func main() { + up := upyun.NewUpYun(&upyun.UpYunConfig{ + Bucket: "demo", + Operator: "op", + Password: "password", + }) + + // 上传文件 + fmt.Println(up.Put(&upyun.PutObjectConfig{ + Path: "/demo.log", + LocalPath: "/tmp/upload", + })) + + // 下载 + fmt.Println(up.Get(&upyun.GetObjectConfig{ + Path: "/demo.log", + LocalPath: "/tmp/download", + })) + + // 列目录 + objsChan := make(chan *upyun.FileInfo, 10) + go func() { + fmt.Println(up.List(&upyun.GetObjectsConfig{ + Path: "/", + ObjectsChan: objsChan, + })) + }() + + for obj := range objsChan { + fmt.Println(obj) + } } ``` -#### 初始化 UpYun + +### 初始化 UpYun ```go -func NewUpYun(bucket, username, passwd string) *UpYun +func NewUpYun(config *UpYunConfig) *UpYun ``` -#### 设置 API 访问域名 +`NewUpYun` 初始化 `UpYun`,`UpYun` 是调用又拍云服务的统一入口,`UpYun` 对所有开放的接口都做了支持。 -```go -// Auto: Auto detected, based on user's internet -// Telecom: (ISP) China Telecom -// Cnc: (ISP) China Unicom -// Ctt: (ISP) China Tietong -const ( - Auto = iota - Telecom - Cnc - Ctt -) +--- -func (u *UpYun) SetEndpoint(ed int) error -``` +### 又拍云 REST API 接口 #### 获取空间存储使用量 ```go -func (u *UpYun) Usage() (int64, error) +func (up *UpYun) Usage() (n int64, err error) ``` #### 创建目录 ```go -func (u *UpYun) Mkdir(key string) error +func (up *UpYun) Mkdir(path string) error ``` #### 上传 ```go -func (u *UpYun) Put(key string, value io.Reader, useMD5 bool, - headers map[string]string) (http.Header, error) +func (up *UpYun) Put(config *PutObjectConfig) (err error) ``` -`key` 为 UPYUN 上的存储路径,`value` 既可以是文件,也可以是 `buffer`,`useMD5` 是否 MD5 校验,`headers` 自定义上传参数,除 [上传参数](https://docs.upyun.com/api/rest_api/#_4),还可以设置 `Content-Length`,支持流式上传。流式上传需要指定 `Contnet-Length`,如需 MD5 校验,需要设置 `Content-MD5`。 - - -#### 断点续传 -```go -func (u *UpYun) ResumePut(key string, value *os.File, useMD5 bool, - headers map[string]string, reporter ResumeReporter) (http.Header, error) -``` - -以断点续传方式上传文件,当文件在上传过程中遭遇网络故障时,将等待 5 秒后,在失败断点处自动重试 3 次。参数 `reporter` 用于报告上传进度。可通过修改全局变量 `ResumeWaitTime` 与 `ResumeRetryCount` 自定义重试等待时间与重试次数。 - - #### 下载 ```go -func (u *UpYun) Get(key string, value io.Writer) (int, error) +func (up *UpYun) Get(config *GetObjectConfig) (fInfo *FileInfo, err error) ``` -此方法返回文件大小 - #### 删除 ```go -// 同步删除 -func (u *UpYun) Delete(key string) error - -// 异步删除文件 -func (u *UpYun) AsyncDelete(key string) error +func (up *UpYun) Delete(config *DeleteObjectConfig) error ``` #### 获取文件信息 ```go -type FileInfo struct { - Size int64 // 文件大小 - Time time.Time // 修改时间 - Name string // 文件名 - Type string // 类型,folder 或者 file -} - -func (u *UpYun) GetInfo(key string) (*FileInfo, error) +func (up *UpYun) GetInfo(path string) (*FileInfo, error) ``` #### 获取文件列表 ```go -// 少量文件 -func (u *UpYun) GetList(key string) ([]*FileInfo, error) - -// 大量文件 -func (u *UpYun) GetLargeList(key string, asc, recursive bool) (chan *FileInfo, chan error) +func (up *UpYun) List(config *GetObjectsConfig) error ``` -`key` 必须为目录。对于目录下有大量文件的,建议使用 `GetLargeList`。 - --- -### UPYUN 缓存刷新接口 +### 又拍云缓存刷新接口 ```go func (u *UpYun) Purge(urls []string) (string, error) @@ -180,154 +125,194 @@ func (u *UpYun) Purge(urls []string) (string, error) --- -### UPYUN HTTP 表单上传接口 - -#### UpYunForm +### 又拍云表单上传接口 ```go -type UpYunForm struct { - Secret string // 表单密钥 - Bucket string // 空间名(即服务名称) -} +func (up *UpYun) FormUpload(config *FormUploadConfig) (*FormUploadResp, error) ``` -#### 初始化 UpYunForm +--- -```go -func NewUpYunForm(bucket, key string) *UpYunForm -``` +### 又拍云处理接口 -#### FormAPIResp +#### 提供处理任务 ```go -type FormAPIResp struct { - Code int `json:"code"` - Msg string `json:"message"` - Url string `json:"url"` - Timestamp int64 `json:"time"` - ImgWidth int `json:"image-width"` - ImgHeight int `json:"image-height"` - ImgFrames int `json:"image-frames"` - ImgType string `json:"image-type"` - Sign string `json:"sign"` -} +func (up *UpYun) CommitTasks(config *CommitTasksConfig) (taskIds []string, err error) ``` -#### 设置 API 访问域名 +`tasksIds` 是提交任务的编号。通过这个编号,可以查询到处理进度以及处理结果等状态。 + +#### 获取处理进度 ```go -func (u *UpYunForm) SetEndpoint(ed int) error +func (up *UpYun) GetProgress(taskIds []string) (result map[string]int, err error) ``` -#### 上传文件 +#### 获取处理结果 ```go -func (uf *UpYunForm) Put(fpath, saveas string, expireAfter int64, - options map[string]string) (*FormAPIResp, error) +func (up *UpYun) GetResult(taskIds []string) (result map[string]interface{}, err error) ``` -`fpath` 上传文件名,`saveas` UPYUN 存储保存路径,`expireAfter` 过期时间长度,`options` 上传参数。 - --- -### UPYUN 分块上传接口 +### 基本类型 -#### UpYunMultiPart +#### UpYun ```go -type UpYunMultiPart struct { - Bucket string // 空间名(即服务名称) - Secret string // 表单密钥 - BlockSize int64 // 分块大小,单位字节, 建议 1024000 +type UpYunConfig struct { + Bucket string // 云存储服务名(空间名) + Operator string // 操作员 + Password string // 密码 + Secret string // 表单上传密钥,已经弃用 + Hosts map[string]string // 自定义 Hosts 映射关系 + UserAgent string // HTTP User-Agent 头,默认 } ``` -#### UploadResp +`UpYunConfig` 提供了初始化 `UpYun` 的参数。 需要注意的是,`Secret` 表单密钥已经弃用,如果一定需要使用,需调用 `Use` + + +#### FileInfo ```go -type UploadResp struct { - // returns after init request - SaveToken string `json:"save_token"` - // token_secert is equal to UPYUN Form API Secret - Secret string `json:"token_secret"` - // UPYUN Bucket Name - Bucket string `json:"bucket_name"` - // Number of Blocks - Blocks int `json:"blocks"` - Status []int `json:"status"` - ExpireAt int64 `json:"expire_at"` +type FileInfo struct { + Name string // 文件名 + Size int64 // 文件大小, 目录大小为 0 + ContentType string // 文件 Content-Type + IsDir bool // 是否为目录 + ETag string // ETag 值 + Time time.Time // 文件修改时间 + + Meta map[string]string // Metadata 数据 + + /* image information */ + ImgType string + ImgWidth int64 + ImgHeight int64 + ImgFrames int64 } ``` -#### MergeResp +#### FormUploadResp ```go -type MergeResp struct { - Path string `json:"path"` - ContentType string `json:"mimetype"` - ContentLength interface{} `json:"file_size"` - LastModify int64 `json:"last_modified"` - Signature string `json:"signature"` - ImageWidth int `json:"image_width"` - ImageHeight int `json:"image_height"` - ImageFrames int `json:"image_frames"` +type FormUploadResp struct { + Code int `json:"code"` // 状态码 + Msg string `json:"message"` // 状态信息 + Url string `json:"url"` // 保存路径 + Timestamp int64 `json:"time"` // 时间戳 + ImgWidth int `json:"image-width"` // 图片宽度 + ImgHeight int `json:"image-height"` // 图片高度 + ImgFrames int `json:"image-frames"` // 图片帧数 + ImgType string `json:"image-type"` // 图片类型 + Sign string `json:"sign"` // 签名 + Taskids []string `json:"task_ids"` // 异步任务 } ``` -#### 初始化 UpYunMultiPart +`FormUploadResp` 为表单上传的返回内容的格式。其中 `Code` 字段为状态码,可以查看 [API 错误码表](https://docs.upyun.com/api/errno/) + +#### PutObjectConfig ```go -func NewUpYunMultiPart(bucket, secret string, blocksize int64) *UpYunMultiPart +type PutObjectConfig struct { + Path string // 云存储中的路径 + LocalPath string // 待上传文件在本地文件系统中的路径 + Reader io.Reader // 待上传的内容 + Headers map[string]string // 请求额外的 HTTP 头 + UseMD5 bool // 是否需要 MD5 校验 + UseResumeUpload bool // 是否使用断点续传 + AppendContent bool // 是否是追加文件内容 + ResumePartSize int64 // 断点续传块大小 + MaxResumePutTries int // 断点续传最大重试次数 +} ``` -#### 上传 +`PutObjectConfig` 提供上传单个文件所需的参数。有几点需要注意: +- `LocalPath` 跟 `Reader` 是一个互斥的关系,如果设置了 `LocalPath`,SDK 就会去读取这个文件,而忽略 `Reader` 中的内容。 +- 如果 `Reader` 是一个流/缓冲等的话,需要通过 `Headers` 参数设置 `Content-Length`,SDK 默认会对 `*os.File` 增加该字段。 +- [断点续传](https://docs.upyun.com/api/rest_api/#_3)的上传内容必须是 `*os.File`, 断点续传会将文件按照 `ResumePartSize` 进行切割,然后按次序一块一块上传,如果遇到网络问题,会进行重试,重试 `MaxResumePutTries` 次,默认无限重试。 +- `AppendContent` 如果是追加文件的话,确保非最后的分片必须为 1M 的整数倍。 +- 如果需要 MD5 校验,SDK 对 `*os.File` 会自动计算 MD5 值,其他类型需要自行通过 `Headers` 参数设置 `Content-MD5` + + +#### GetObjectConfig ```go -func (ump *UpYunMultiPart) Put(fpath, saveas string, - expireAfter int64, options map[string]interface{}) (*MergeResp, error) +type GetObjectConfig struct { + Path string // 云存储中的路径 + Headers map[string]string // 请求额外的 HTTP 头 + LocalPath string // 文件本地保存路径 + Writer io.Writer // 保存内容的容器 +} ``` ---- +`GetObjectConfig` 提供下载单个文件所需的参数。 跟 `PutObjectConfig` 类似,`LocalPath` 跟 `Writer` 是一个互斥的关系,如果设置了 `LocalPath`,SDK 就会把内容写入到这个文件中,而忽略 `Writer`。 -### UPYUN 音视频处理接口 -#### UpYunMedia +#### GetObjectsConfig ```go -type UpYunMedia struct { - Username string // 操作员 - Passwd string // 密码 - Bucket string // 空间名(即服务名称) +type GetObjectsConfig struct { + Path string // 云存储中的路径 + Headers map[string]string // 请求额外的 HTTP 头 + ObjectsChan chan *FileInfo // 对象 Channel + QuitChan chan bool // 停止信号 + MaxListObjects int // 最大列对象个数 + MaxListTries int // 列目录最大重试次数 + MaxListLevel int // 递归最大深度 + DescOrder bool // 是否按降序列取,默认为生序 + + // Has unexported fields. } ``` -#### MediaStatusResp +`GetObjectsConfig` 提供列目录所需的参数。当列目录结束后,SDK 会将 `ObjectChan` 关闭掉。 + + +#### DeleteObjectConfig ```go -type MediaStatusResp struct { - Tasks map[string]interface{} `json:"tasks"` +type DeleteObjectConfig struct { + Path string // 云存储中的路径 + Async bool // 是否使用异步删除 } ``` -#### 初始化 UpYunMedia +`DeleteObjectConfig` 提供删除单个文件/空目录所需的参数。 -```go -func NewUpYunMedia(bucket, user, pass string) *UpYunMedia -``` -#### 提交任务 +#### FormUploadConfig ```go -func (upm *UpYunMedia) PostTasks(src, notify, accept string, - tasks []map[string]interface{}) ([]string, error) +type FormUploadConfig struct { + LocalPath string // 待上传的文件路径 + SaveKey string // 保存路径 + ExpireAfterSec int64 // 签名超时时间 + NotifyUrl string // 结果回调地址 + Apps []map[string]interface{} // 异步处理任务 + Options map[string]interface{} // 更多自定义参数 +} ``` -`src` 音视频文件 UPYUN 存储路径,`notify` 回调URL,`accept` 设置回调格式,可选 `json`,`tasks` 任务列表,返回结果为任务 id 列表。 +`FormUploadConfig` 提供表单上传所需的参数。 + -#### 查询进度 +#### CommitTasksConfig ```go -func (upm *UpYunMedia) GetProgress(task_ids string) (*MediaStatusResp, error) +type CommitTasksConfig struct { + AppName string // 异步任务名称 + NotifyUrl string // 回调地址 + Tasks []interface{} // 任务数组 + + // Naga 相关配置 + Accept string // 回调支持的类型,默认为 json + Source string // 处理原文件路径 +} ``` -`task_ids` 是多个 `task_id` 用 `,` 连接起来。 +`CommitTasksConfig` 提供提交异步任务所需的参数。`Accept` 跟 `Source` 仅与异步音视频处理有关。`Tasks` 是一个任务数组,数组中的每一个元素都是任务相关的参数(一般情况下为字典类型)。 diff --git a/examples/cc.jpg b/examples/cc.jpg deleted file mode 100644 index c245254b14fcb61de8f71f96835526dcd8babc6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34444 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ν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�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 diff --git a/examples/config/config.go b/examples/config/config.go deleted file mode 100644 index ecf0630..0000000 --- a/examples/config/config.go +++ /dev/null @@ -1,9 +0,0 @@ -package config - -var ( - Bucket = "ServiceName" - Username = "OperatorName" - Passwd = "Password" - Notify = "http://www.upyun.com/notify" - Secret = "token secret in UPYUN Console" -) diff --git a/examples/upform.go b/examples/upform.go deleted file mode 100644 index e511d14..0000000 --- a/examples/upform.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - config "./config" - "fmt" - "github.com/upyun/go-sdk/upyun" -) - -func main() { - uf := upyun.NewUpYunForm(config.Bucket, config.Secret) - - options := map[string]string{ - "x-gmkerl-rotate": "90", - "notify-url": config.Notify, - } - fmt.Print(options) - - formResp, err := uf.Put("cc.jpg", "/{year}/{mon}/{day}/upload_{filename}{.suffix}", 3600, options) - if err != nil { - fmt.Println(err) - } else { - fmt.Printf("%+v\n", formResp) - } -} diff --git a/examples/upmedia.go b/examples/upmedia.go deleted file mode 100644 index d33ea4b..0000000 --- a/examples/upmedia.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - config "./config" - "fmt" - "github.com/upyun/go-sdk/upyun" - "strings" - "time" -) - -func main() { - upm := upyun.NewUpYunMedia(config.Bucket, config.Username, config.Passwd) - - task := map[string]interface{}{ - "type": "thumbnail", - "thumb_single": true, - } - tasks := []map[string]interface{}{task} - - ids, _ := upm.PostTasks("kai.3gp", config.Notify, "json", tasks) - - for { - status, _ := upm.GetProgress(strings.Join(ids, ",")) - for _, id := range ids { - fmt.Println(id, status.Tasks[id]) - } - time.Sleep(time.Second) - } -} diff --git a/examples/upmultipart.go b/examples/upmultipart.go deleted file mode 100644 index 51f347a..0000000 --- a/examples/upmultipart.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - config "./config" - "fmt" - "github.com/upyun/go-sdk/upyun" - "time" -) - -func main() { - ump := upyun.NewUpYunMultiPart(config.Bucket, config.Secret, 1024000) - options := map[string]interface{}{ - "x-gmkerl-rotate": "90", - "notify-url": config.Notify, - "ext-param": "123456", - } - resp, err := ump.Put("cc.jpg", "/test/IMG-c"+fmt.Sprint(time.Now().Unix())+".jpg", 3600, options) - fmt.Printf("%+v %v\n", resp, err) -} diff --git a/examples/uprest.go b/examples/uprest.go deleted file mode 100644 index ea3f625..0000000 --- a/examples/uprest.go +++ /dev/null @@ -1,36 +0,0 @@ -// SEE upyun_test.go -package main - -import ( - config "./config" - "fmt" - "github.com/upyun/go-sdk/upyun" - "os" - "time" -) - -func main() { - up := upyun.NewUpYun(config.Bucket, config.Username, config.Passwd) - headers := map[string]string{ - "x-gmkerl-watermark-type": "text", - "x-gmkerl-watermark-font": "simhei", - "x-gmkerl-watermark-color": "#faf1fb", - "x-gmkerl-watermark-size": "20", - "x-gmkerl-watermark-text": "UPYUN", - "x-gmkerl-watermark-border": "#40404085", - "x-gmkerl-watermark-margin": "10,10", - } - - fd, _ := os.Open("cc.jpg") - x := fmt.Sprintf("/wm/cc%d.jpg", time.Now().Unix()%10000) - fmt.Println(up.Put(x, fd, false, headers)) - - c := up.GetLargeList("/", true) - for { - v, more := <-c - if !more { - break - } - fmt.Println(v) - } -} diff --git a/upyun/auth.go b/upyun/auth.go new file mode 100644 index 0000000..6905ae9 --- /dev/null +++ b/upyun/auth.go @@ -0,0 +1,84 @@ +package upyun + +import ( + "fmt" + "sort" + "strings" +) + +type RESTAuthConfig struct { + Method string + Uri string + DateStr string + LengthStr string +} + +type PurgeAuthConfig struct { + PurgeList string + DateStr string +} + +type UnifiedAuthConfig struct { + Method string + Uri string + DateStr string + Policy string + ContentMD5 string +} + +func (u *UpYun) MakeRESTAuth(config *RESTAuthConfig) string { + sign := []string{ + config.Method, + config.Uri, + config.DateStr, + config.LengthStr, + u.Password, + } + return "UpYun " + u.Operator + ":" + md5Str(strings.Join(sign, "&")) +} + +func (u *UpYun) MakePurgeAuth(config *PurgeAuthConfig) string { + sign := []string{ + config.PurgeList, + u.Bucket, + config.DateStr, + u.Password, + } + return "UpYun " + u.Bucket + ":" + u.Operator + ":" + md5Str(strings.Join(sign, "&")) +} + +func (u *UpYun) MakeFormAuth(policy string) string { + return md5Str(base64ToStr([]byte(policy)) + "&" + u.Secret) +} + +func (u *UpYun) MakeProcessAuth(kwargs map[string]string) string { + keys := []string{} + for k, _ := range kwargs { + keys = append(keys, k) + } + sort.Strings(keys) + + auth := "" + for _, k := range keys { + auth += k + kwargs[k] + } + return fmt.Sprintf("UpYun %s:%s", u.Operator, md5Str(u.Operator+auth+u.Password)) +} + +func (u *UpYun) MakeUnifiedAuth(config *UnifiedAuthConfig) string { + sign := []string{ + config.Method, + config.Uri, + config.DateStr, + config.Policy, + config.ContentMD5, + } + signNoEmpty := []string{} + for _, v := range sign { + if v != "" { + signNoEmpty = append(signNoEmpty, v) + } + } + signStr := base64ToStr(hmacSha1(u.Password, []byte(strings.Join(signNoEmpty, "&")))) + return "UpYun " + u.Operator + ":" + signStr +} diff --git a/upyun/fileinfo.go b/upyun/fileinfo.go new file mode 100644 index 0000000..9d9d540 --- /dev/null +++ b/upyun/fileinfo.go @@ -0,0 +1,61 @@ +package upyun + +import ( + "net/http" + "strings" + "time" +) + +type FileInfo struct { + Name string + Size int64 + ContentType string + IsDir bool + ETag string + Time time.Time + + Meta map[string]string + + /* image information */ + ImgType string + ImgWidth int64 + ImgHeight int64 + ImgFrames int64 +} + +/* + Content-Type: image/gif + ETag: "dc9ea7257aa6da18e74505259b04a946" + x-upyun-file-type: GIF + x-upyun-height: 379 + x-upyun-width: 500 + x-upyun-frames: 90 +*/ +func parseHeaderToFileInfo(header http.Header, getinfo bool) *FileInfo { + fInfo := &FileInfo{} + for k, v := range header { + lk := strings.ToLower(k) + if strings.HasPrefix(lk, "x-upyun-meta-") { + if fInfo.Meta == nil { + fInfo.Meta = make(map[string]string) + } + fInfo.Meta[lk] = v[0] + } + } + + if getinfo { + // HTTP HEAD + fInfo.Size = parseStrToInt(header.Get("x-upyun-file-size")) + fInfo.IsDir = header.Get("x-upyun-file-type") == "folder" + fInfo.Time = time.Unix(parseStrToInt(header.Get("x-upyun-file-date")), 0) + } else { + fInfo.Size = parseStrToInt(header.Get("Content-Length")) + fInfo.ContentType = header.Get("Content-Type") + fInfo.ETag = header.Get("ETag") + fInfo.ImgType = header.Get("x-upyun-file-type") + fInfo.ImgWidth = parseStrToInt(header.Get("x-upyun-width")) + fInfo.ImgHeight = parseStrToInt(header.Get("x-upyun-height")) + fInfo.ImgFrames = parseStrToInt(header.Get("x-upyun-frames")) + } + return fInfo +} diff --git a/upyun/form.go b/upyun/form.go new file mode 100644 index 0000000..4b4517b --- /dev/null +++ b/upyun/form.go @@ -0,0 +1,144 @@ +package upyun + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" +) + +type FormUploadConfig struct { + LocalPath string + SaveKey string + ExpireAfterSec int64 + NotifyUrl string + Apps []map[string]interface{} + Options map[string]interface{} +} + +type FormUploadResp struct { + Code int `json:"code"` + Msg string `json:"message"` + Url string `json:"url"` + Timestamp int64 `json:"time"` + ImgWidth int `json:"image-width"` + ImgHeight int `json:"image-height"` + ImgFrames int `json:"image-frames"` + ImgType string `json:"image-type"` + Sign string `json:"sign"` + Taskids []string `json:"task_ids"` +} + +func (config *FormUploadConfig) Format() { + if config.Options == nil { + config.Options = make(map[string]interface{}) + } + if config.SaveKey != "" { + config.Options["save-key"] = config.SaveKey + } + if config.NotifyUrl != "" { + config.Options["notify-url"] = config.NotifyUrl + } + if config.ExpireAfterSec > 0 { + config.Options["expiration"] = time.Now().Unix() + config.ExpireAfterSec + } + if len(config.Apps) > 0 { + config.Options["apps"] = config.Apps + } +} + +func (up *UpYun) FormUpload(config *FormUploadConfig) (*FormUploadResp, error) { + config.Format() + config.Options["bucket"] = up.Bucket + + args, err := json.Marshal(config.Options) + if err != nil { + return nil, err + } + policy := base64ToStr(args) + + formValues := make(map[string]string) + formValues["policy"] = policy + formValues["file"] = config.LocalPath + + if up.deprecated { + formValues["signature"] = up.MakeFormAuth(policy) + } else { + sign := &UnifiedAuthConfig{ + Method: "POST", + Uri: "/" + up.Bucket, + Policy: policy, + } + if v, ok := config.Options["date"]; ok { + sign.DateStr = v.(string) + } + if v, ok := config.Options["content-md5"]; ok { + sign.ContentMD5 = v.(string) + } + formValues["authorization"] = up.MakeUnifiedAuth(sign) + } + + endpoint := up.doGetEndpoint("v0.api.upyun.com") + url := fmt.Sprintf("http://%s/%s", endpoint, up.Bucket) + resp, err := up.doFormRequest(url, formValues) + if err != nil { + return nil, err + } + + b, _ := ioutil.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("%s", string(b)) + } + + var r FormUploadResp + err = json.Unmarshal(b, &r) + return &r, err +} + +func (up *UpYun) doFormRequest(url string, formValues map[string]string) (*http.Response, error) { + formBody := &bytes.Buffer{} + formWriter := multipart.NewWriter(formBody) + defer formWriter.Close() + + for k, v := range formValues { + if k != "file" { + formWriter.WriteField(k, v) + } + } + + boundary := formWriter.Boundary() + bdBuf := bytes.NewBufferString(fmt.Sprintf("\r\n--%s--\r\n", boundary)) + + fpath := formValues["file"] + fd, err := os.Open(fpath) + if err != nil { + return nil, err + } + defer fd.Close() + + fInfo, err := fd.Stat() + if err != nil { + return nil, err + } + + _, err = formWriter.CreateFormFile("file", filepath.Base(fpath)) + if err != nil { + return nil, err + } + + headers := map[string]string{ + "Content-Type": "multipart/form-data; boundary=" + boundary, + "Content-Length": fmt.Sprint(formBody.Len() + int(fInfo.Size()) + bdBuf.Len()), + } + + body := io.MultiReader(formBody, fd, bdBuf) + return up.doHTTPRequest("POST", url, headers, body) +} diff --git a/upyun/form_test.go b/upyun/form_test.go new file mode 100644 index 0000000..0060c13 --- /dev/null +++ b/upyun/form_test.go @@ -0,0 +1,52 @@ +package upyun + +import ( + "path" + "testing" +) + +var ( + FORM_FILE = path.Join(ROOT, "FORM", "表单_FILE") +) + +func TestFormPutFile(t *testing.T) { + resp, err := up.FormUpload(&FormUploadConfig{ + LocalPath: LOCAL_FILE, + SaveKey: FORM_FILE, + ExpireAfterSec: 60, + }) + + Nil(t, err) + NotNil(t, resp) +} + +func TestFormPutApps(t *testing.T) { + thumb := map[string]interface{}{ + "name": "thumb", + "x-gmkerl-thumb": "/fw/120", + "save_as": "/x120.gif", + } + + naga := map[string]interface{}{ + "name": "naga", + "type": "video", + "avopts": "/f/mp4", + } + + spider := map[string]interface{}{ + "name": "spiderman", + "url": "http://www.upyun.com/index.html", + } + + resp, err := up.FormUpload(&FormUploadConfig{ + LocalPath: LOCAL_FILE, + SaveKey: FORM_FILE, + NotifyUrl: NOTIFY_URL, + ExpireAfterSec: 60, + Apps: []map[string]interface{}{thumb, naga, spider}, + }) + + NotNil(t, resp) + Nil(t, err) + Equal(t, len(resp.Taskids), 3) +} diff --git a/upyun/http.go b/upyun/http.go new file mode 100644 index 0000000..abb4cd1 --- /dev/null +++ b/upyun/http.go @@ -0,0 +1,55 @@ +package upyun + +import ( + // "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" +) + +func (up *UpYun) doHTTPRequest(method, url string, headers map[string]string, + body io.Reader) (resp *http.Response, err error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + + for k, v := range headers { + if strings.ToLower(k) == "host" { + req.Host = v + } else { + req.Header.Set(k, v) + } + } + + req.Header.Set("User-Agent", up.UserAgent) + if method == "PUT" || method == "POST" { + length := req.Header.Get("Content-Length") + if length != "" { + req.ContentLength, _ = strconv.ParseInt(length, 10, 64) + } else { + switch v := body.(type) { + case *os.File: + if fInfo, err := v.Stat(); err == nil { + req.ContentLength = fInfo.Size() + } + case UpYunPutReader: + req.ContentLength = int64(v.Len()) + } + } + } + + // fmt.Printf("%+v\n", req) + + return up.httpc.Do(req) +} + +func (up *UpYun) doGetEndpoint(host string) string { + s := up.Hosts[host] + if s != "" { + return s + } + return host +} diff --git a/upyun/io.go b/upyun/io.go new file mode 100644 index 0000000..b0b865f --- /dev/null +++ b/upyun/io.go @@ -0,0 +1,78 @@ +package upyun + +import ( + "fmt" + "io" + "os" +) + +type UpYunPutReader interface { + Len() (n int) + MD5() (ret string) + Read([]byte) (n int, err error) + Copyed() (n int) +} + +type fragmentFile struct { + realFile *os.File + offset int64 + limit int64 + cursor int64 +} + +func (f *fragmentFile) Seek(offset int64, whence int) (ret int64, err error) { + switch whence { + case 0: + f.cursor = offset + ret, err = f.realFile.Seek(f.offset+f.cursor, 0) + return ret - f.offset, err + default: + return 0, fmt.Errorf("whence must be 0") + } +} + +func (f *fragmentFile) Read(b []byte) (n int, err error) { + if f.cursor >= f.limit { + return 0, io.EOF + } + n, err = f.realFile.Read(b) + if f.cursor+int64(n) > f.limit { + n = int(f.limit - f.cursor) + } + f.cursor += int64(n) + return n, err +} + +func (f *fragmentFile) Stat() (fInfo os.FileInfo, err error) { + return fInfo, fmt.Errorf("fragmentFile not implement Stat()") +} + +func (f *fragmentFile) Close() error { + return nil +} + +func (f *fragmentFile) Copyed() int { + return int(f.cursor - f.offset) +} + +func (f *fragmentFile) Len() int { + return int(f.limit - f.offset) +} + +func (f *fragmentFile) MD5() string { + s, _ := md5File(f) + return s +} + +func newFragmentFile(file *os.File, offset, limit int64) (*fragmentFile, error) { + f := &fragmentFile{ + realFile: file, + offset: offset, + limit: limit, + } + + if _, err := f.Seek(0, 0); err != nil { + return nil, err + } + return f, nil +} diff --git a/upyun/process.go b/upyun/process.go new file mode 100644 index 0000000..925254b --- /dev/null +++ b/upyun/process.go @@ -0,0 +1,132 @@ +package upyun + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" +) + +type CommitTasksConfig struct { + AppName string + Accept string + Source string + NotifyUrl string + Tasks []interface{} +} + +func (up *UpYun) CommitTasks(config *CommitTasksConfig) (taskIds []string, err error) { + b, err := json.Marshal(config.Tasks) + if err != nil { + return nil, err + } + + kwargs := map[string]string{ + "app_name": config.AppName, + "tasks": base64ToStr(b), + "notify_url": config.NotifyUrl, + + // for naga + "source": config.Source, + } + if config.Accept != "" { + kwargs["accept"] = config.Accept + } + + err = up.doProcessRequest("POST", "/pretreatment/", kwargs, &taskIds) + return +} + +func (up *UpYun) GetProgress(taskIds []string) (result map[string]int, err error) { + kwargs := map[string]string{ + "task_ids": strings.Join(taskIds, ","), + } + v := map[string]map[string]int{} + err = up.doProcessRequest("GET", "/status/", kwargs, &v) + if err != nil { + return + } + + if r, ok := v["tasks"]; ok { + return r, err + } + return nil, fmt.Errorf("no tasks") +} + +func (up *UpYun) GetResult(taskIds []string) (result map[string]interface{}, err error) { + kwargs := map[string]string{ + "task_ids": strings.Join(taskIds, ","), + } + v := map[string]map[string]interface{}{} + err = up.doProcessRequest("GET", "/result/", kwargs, &v) + if err != nil { + return + } + + if r, ok := v["tasks"]; ok { + return r, err + } + return nil, fmt.Errorf("no tasks") +} + +func (up *UpYun) doProcessRequest(method, uri string, + kwargs map[string]string, v interface{}) error { + if _, ok := kwargs["service"]; !ok { + kwargs["service"] = up.Bucket + } + if _, ok := kwargs["bucket"]; !ok { + kwargs["bucket"] = up.Bucket + } + if _, ok := kwargs["bucket_name"]; !ok { + kwargs["bucket_name"] = up.Bucket + } + + if method == "GET" { + uri = addQueryToUri(uri, kwargs) + } + + headers := make(map[string]string) + headers["Date"] = makeRFC1123Date(time.Now()) + headers["Content-Type"] = "application/x-www-form-urlencoded" + if up.deprecated { + headers["Authorization"] = up.MakeProcessAuth(kwargs) + } else { + headers["Authorization"] = up.MakeUnifiedAuth(&UnifiedAuthConfig{ + Method: method, + Uri: uri, + DateStr: headers["Date"], + }) + } + + var resp *http.Response + var err error + endpoint := up.doGetEndpoint("p0.api.upyun.com") + rawurl := fmt.Sprintf("http://%s%s", endpoint, uri) + switch method { + case "GET": + resp, err = up.doHTTPRequest(method, rawurl, headers, nil) + case "POST": + payload := encodeQueryToPayload(kwargs) + resp, err = up.doHTTPRequest(method, rawurl, headers, bytes.NewBufferString(payload)) + default: + return fmt.Errorf("Unknown method") + } + + if err != nil { + return err + } + + b, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + if resp.StatusCode/100 != 2 { + return fmt.Errorf("%d %s", resp.StatusCode, string(b)) + } + + return json.Unmarshal(b, v) +} diff --git a/upyun/process_test.go b/upyun/process_test.go new file mode 100644 index 0000000..7e638fb --- /dev/null +++ b/upyun/process_test.go @@ -0,0 +1,61 @@ +package upyun + +import ( + "path" + "testing" +) + +var ( + MP4_URL = "http://prog-test.b0.upaiyun.com/const/kai.3gp" + MP4_SAVE_AS = path.Join(ROOT, "kai.3gp") + MP4_TASK_IDS []string +) + +func TestSpider(t *testing.T) { + task := map[string]interface{}{ + "url": MP4_URL, + "save_as": MP4_SAVE_AS, + } + ids, err := up.CommitTasks(&CommitTasksConfig{ + AppName: "spiderman", + NotifyUrl: NOTIFY_URL, + Tasks: []interface{}{task}, + }) + + Nil(t, err) + Equal(t, len(ids), 1) +} + +func TestNagaCommit(t *testing.T) { + task := map[string]interface{}{ + "type": "video", + "avopts": "/f/mp4", + } + task2 := map[string]interface{}{ + "type": "video", + "avopts": "/f/mp3", + } + + ids, err := up.CommitTasks(&CommitTasksConfig{ + AppName: "naga", + NotifyUrl: NOTIFY_URL, + Tasks: []interface{}{task, task2}, + }) + + Nil(t, err) + Equal(t, len(ids), 2) + + MP4_TASK_IDS = ids +} + +func TestNagaProgress(t *testing.T) { + res, err := up.GetProgress(MP4_TASK_IDS) + Nil(t, err) + Equal(t, len(res), 2) +} + +func TestNagaResult(t *testing.T) { + res, err := up.GetResult(MP4_TASK_IDS) + Nil(t, err) + Equal(t, len(res), 2) +} diff --git a/upyun/purge.go b/upyun/purge.go new file mode 100644 index 0000000..58dddef --- /dev/null +++ b/upyun/purge.go @@ -0,0 +1,58 @@ +package upyun + +import ( + "encoding/json" + "fmt" + "io/ioutil" + URL "net/url" + "strings" + "time" +) + +// TODO +func (up *UpYun) Purge(urls []string) (fails []string, err error) { + purge := "http://purge.upyun.com/purge/" + date := makeRFC1123Date(time.Now()) + purgeList := strings.Join(urls, "\n") + + headers := map[string]string{ + "Date": date, + "Authorization": up.MakePurgeAuth(&PurgeAuthConfig{ + PurgeList: purgeList, + DateStr: date, + }), + "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", + } + + form := make(URL.Values) + form.Add("purge", purgeList) + + body := strings.NewReader(form.Encode()) + resp, err := up.doHTTPRequest("POST", purge, headers, body) + if err != nil { + return fails, err + } + defer resp.Body.Close() + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fails, err + } + + if resp.StatusCode/100 == 2 { + result := map[string]interface{}{} + if err := json.Unmarshal(content, &result); err != nil { + return fails, err + } + if it, ok := result["invalid_domain_of_url"]; ok { + if urls, ok := it.([]interface{}); ok { + for _, url := range urls { + fails = append(fails, url.(string)) + } + } + } + return fails, nil + } + + return nil, fmt.Errorf("purge %d %s", resp.StatusCode, string(content)) +} diff --git a/upyun/purge_test.go b/upyun/purge_test.go new file mode 100644 index 0000000..e5597e8 --- /dev/null +++ b/upyun/purge_test.go @@ -0,0 +1,24 @@ +package upyun + +import ( + "fmt" + "testing" +) + +func TestPurge(t *testing.T) { + fails, err := up.Purge([]string{ + fmt.Sprintf("http://%s.b0.upaiyun.com/demo.jpg", up.Bucket), + }) + + Nil(t, err) + Equal(t, len(fails), 0) + + fails, err = up.Purge([]string{ + fmt.Sprintf("http://%s.b0.upaiyun.com/demo.jpg", up.Bucket), + fmt.Sprintf("http://%s-t.b0.upaiyun.com/demo.jpg", up.Bucket), + }) + + Nil(t, err) + Equal(t, len(fails), 1) + Equal(t, fails[0], fmt.Sprintf("http://%s-t.b0.upaiyun.com/demo.jpg", up.Bucket)) +} diff --git a/upyun/rest.go b/upyun/rest.go new file mode 100644 index 0000000..ddf4b84 --- /dev/null +++ b/upyun/rest.go @@ -0,0 +1,490 @@ +package upyun + +import ( + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "os" + "path" + "strings" + "time" +) + +const ( + defaultResumePartSize = 1024 * 1024 + minResumePutFileSize = 10 * 1024 * 1024 +) + +type restReqConfig struct { + method string + uri string + headers map[string]string + closeBody bool + httpBody io.Reader + useMD5 bool +} + +// GetObjectConfig provides a configuration to Get method. +type GetObjectConfig struct { + Path string + // Headers contains custom http header, like User-Agent. + Headers map[string]string + LocalPath string + Writer io.Writer +} + +// GetObjectConfig provides a configuration to List method. +type GetObjectsConfig struct { + Path string + Headers map[string]string + ObjectsChan chan *FileInfo + QuitChan chan bool + MaxListObjects int + MaxListTries int + // MaxListLevel: depth of recursion + MaxListLevel int + // DescOrder: whether list objects by desc-order + DescOrder bool + + rootDir string + level int + objNum int + try int +} + +// PutObjectConfig provides a configuration to Put method. +type PutObjectConfig struct { + Path string + LocalPath string + Reader io.Reader + Headers map[string]string + UseMD5 bool + UseResumeUpload bool + AppendContent bool + ResumePartSize int64 + MaxResumePutTries int +} + +type DeleteObjectConfig struct { + Path string + Async bool +} + +type ModifyMetadataConfig struct { + Path string + Operation string + Headers map[string]string +} + +func (up *UpYun) Usage() (n int64, err error) { + var resp *http.Response + resp, err = up.doRESTRequest(&restReqConfig{ + method: "GET", + uri: "/?usage", + }) + + if err == nil { + n, err = readHTTPBodyToInt(resp) + } + + if err != nil { + return 0, fmt.Errorf("usage: %v", err) + } + return n, nil +} + +func (up *UpYun) Mkdir(path string) error { + _, err := up.doRESTRequest(&restReqConfig{ + method: "POST", + uri: path, + headers: map[string]string{ + "folder": "true", + }, + closeBody: true, + }) + if err != nil { + return fmt.Errorf("mkdir %s: %v", path, err) + } + return nil +} + +// TODO: maybe directory +func (up *UpYun) Get(config *GetObjectConfig) (fInfo *FileInfo, err error) { + if config.LocalPath != "" { + var fd *os.File + if fd, err = os.Create(config.LocalPath); err != nil { + return nil, fmt.Errorf("create file: %v", err) + } + defer fd.Close() + config.Writer = fd + } + + if config.Writer == nil { + return nil, fmt.Errorf("no writer") + } + + resp, err := up.doRESTRequest(&restReqConfig{ + method: "GET", + uri: config.Path, + }) + if err != nil { + return nil, fmt.Errorf("doRESTRequest: %v", err) + } + defer resp.Body.Close() + + fInfo = parseHeaderToFileInfo(resp.Header, false) + fInfo.Name = config.Path + + if fInfo.Size, err = io.Copy(config.Writer, resp.Body); err != nil { + return nil, fmt.Errorf("io copy: %v", err) + } + + return +} + +func (up *UpYun) put(config *PutObjectConfig) error { + if config.AppendContent { + if config.Headers == nil { + config.Headers = make(map[string]string) + } + config.Headers["X-Upyun-Append"] = "true" + } + _, err := up.doRESTRequest(&restReqConfig{ + method: "PUT", + uri: config.Path, + headers: config.Headers, + closeBody: true, + httpBody: config.Reader, + useMD5: config.UseMD5, + }) + if err != nil { + return fmt.Errorf("doRESTRequest: %v", err) + } + return nil +} + +// TODO: progress +func (up *UpYun) resumePut(config *PutObjectConfig) error { + f, ok := config.Reader.(*os.File) + if !ok { + return fmt.Errorf("resumePut: type != *os.File") + } + + fileinfo, err := f.Stat() + if err != nil { + return fmt.Errorf("Stat: %v", err) + } + + fsize := fileinfo.Size() + if fsize < minResumePutFileSize { + return up.put(config) + } + + if config.ResumePartSize == 0 { + config.ResumePartSize = defaultResumePartSize + } + maxPartID := int((fsize+config.ResumePartSize-1)/config.ResumePartSize - 1) + + if config.Headers == nil { + config.Headers = make(map[string]string) + } + + curSize, partSize := int64(0), config.ResumePartSize + headers := config.Headers + for id := 0; id <= maxPartID; id++ { + if curSize+partSize > fsize { + partSize = fsize - curSize + } + headers["Content-Length"] = fmt.Sprint(partSize) + headers["X-Upyun-Part-ID"] = fmt.Sprint(id) + + switch id { + case 0: + headers["X-Upyun-Multi-Type"] = headers["Content-Type"] + headers["X-Upyun-Multi-Length"] = fmt.Sprint(fsize) + headers["X-Upyun-Multi-Stage"] = "initiate,upload" + case int(maxPartID): + headers["X-Upyun-Multi-Stage"] = "upload,complete" + if config.UseMD5 { + f.Seek(0, 0) + headers["X-Upyun-Multi-MD5"], _ = md5File(f) + } + default: + headers["X-Upyun-Multi-Stage"] = "upload" + } + + fragFile, err := newFragmentFile(f, curSize, partSize) + if err != nil { + return fmt.Errorf("newFragmentFile: %v", err) + } + + try := 0 + var resp *http.Response + for ; config.MaxResumePutTries == 0 || try < config.MaxResumePutTries; try++ { + resp, err = up.doRESTRequest(&restReqConfig{ + method: "PUT", + uri: config.Path, + headers: headers, + closeBody: true, + useMD5: config.UseMD5, + httpBody: fragFile, + }) + if err == nil { + break + } + if _, ok := err.(net.Error); !ok { + return fmt.Errorf("doRESTRequest: %v", err) + } + fragFile.Seek(0, 0) + } + + if config.MaxResumePutTries > 0 && try == config.MaxResumePutTries { + return err + } + + if id == 0 { + headers["X-Upyun-Multi-UUID"] = resp.Header.Get("X-Upyun-Multi-UUID") + } else { + if id == maxPartID { + return nil + } + } + + curSize += partSize + } + + return nil +} + +func (up *UpYun) Put(config *PutObjectConfig) (err error) { + if config.LocalPath != "" { + var fd *os.File + if fd, err = os.Open(config.LocalPath); err != nil { + return fmt.Errorf("open file: %v", err) + } + defer fd.Close() + config.Reader = fd + } + + if config.UseResumeUpload { + return up.resumePut(config) + } + return up.put(config) +} + +func (up *UpYun) Delete(config *DeleteObjectConfig) error { + headers := map[string]string{} + if config.Async == true { + headers["x-upyun-async"] = "true" + } + _, err := up.doRESTRequest(&restReqConfig{ + method: "DELETE", + uri: config.Path, + headers: headers, + closeBody: true, + }) + if err != nil { + return fmt.Errorf("delete %s: %v", config.Path, err) + } + return nil +} + +func (up *UpYun) GetInfo(path string) (*FileInfo, error) { + resp, err := up.doRESTRequest(&restReqConfig{ + method: "HEAD", + uri: path, + closeBody: true, + }) + if err != nil { + return nil, fmt.Errorf("getinfo %s: %v", path, err) + } + fInfo := parseHeaderToFileInfo(resp.Header, true) + fInfo.Name = path + return fInfo, nil +} + +func (up *UpYun) List(config *GetObjectsConfig) error { + if config.ObjectsChan == nil { + return fmt.Errorf("ObjectsChan == nil") + } + if config.Headers == nil { + config.Headers = make(map[string]string) + } + if config.QuitChan == nil { + config.QuitChan = make(chan bool) + } + // 50 is nice value + if _, exist := config.Headers["X-List-Limit"]; !exist { + config.Headers["X-List-Limit"] = "50" + } + + if config.DescOrder { + config.Headers["X-List-Order"] = "desc" + } + + // 1st level + if config.level == 0 { + defer close(config.ObjectsChan) + } + + for { + select { + case <-config.QuitChan: + return nil + default: + resp, err := up.doRESTRequest(&restReqConfig{ + method: "GET", + uri: config.Path, + headers: config.Headers, + }) + + if err != nil { + if _, ok := err.(net.Error); ok { + config.try++ + if config.MaxListTries == 0 || config.try < config.MaxListTries { + continue + } + } + return err + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("ioutil ReadAll: %v", err) + } + + for _, fInfo := range parseBodyToFileInfos(b) { + config.objNum++ + if config.MaxListObjects > 0 && config.objNum >= config.MaxListObjects { + return nil + } else { + if fInfo.IsDir && (config.level+1 < config.MaxListLevel || config.MaxListLevel == -1) { + rConfig := &GetObjectsConfig{ + Path: path.Join(config.Path, fInfo.Name), + QuitChan: config.QuitChan, + ObjectsChan: config.ObjectsChan, + MaxListTries: config.MaxListTries, + MaxListObjects: config.MaxListObjects, + DescOrder: config.DescOrder, + MaxListLevel: config.MaxListLevel, + level: config.level + 1, + rootDir: path.Join(config.rootDir, fInfo.Name), + try: config.try, + objNum: config.objNum, + } + if err = up.List(rConfig); err != nil { + return err + } + config.try, config.objNum = rConfig.try, rConfig.objNum + } + if config.rootDir != "" { + fInfo.Name = path.Join(config.rootDir, fInfo.Name) + } + config.ObjectsChan <- fInfo + } + } + config.Headers["X-List-Iter"] = resp.Header.Get("X-Upyun-List-Iter") + if config.Headers["X-List-Iter"] == "g2gCZAAEbmV4dGQAA2VvZg" { + return nil + } + } + } +} + +func (up *UpYun) ModifyMetadata(config *ModifyMetadataConfig) error { + if config.Operation == "" { + config.Operation = "merge" + } + _, err := up.doRESTRequest(&restReqConfig{ + method: "PATCH", + uri: config.Path + "?metadata=" + config.Operation, + headers: config.Headers, + closeBody: true, + }) + return err +} + +func (up *UpYun) doRESTRequest(config *restReqConfig) (*http.Response, error) { + escUri, err := escapeUri(config.uri) + if err != nil { + return nil, err + } + escUri = path.Join("/", up.Bucket, escUri) + if strings.HasSuffix(config.uri, "/") { + escUri += "/" + } + + headers := map[string]string{} + hasMD5 := false + for k, v := range config.headers { + if strings.ToLower(k) == "content-md5" && v != "" { + hasMD5 = true + } + headers[k] = v + } + + headers["Date"] = makeRFC1123Date(time.Now()) + headers["Host"] = "v0.api.upyun.com" + + if !hasMD5 && config.useMD5 { + switch v := config.httpBody.(type) { + case *os.File: + headers["Content-MD5"], _ = md5File(v) + case UpYunPutReader: + headers["Content-MD5"] = v.MD5() + } + } + + if up.deprecated { + if _, ok := headers["Content-Length"]; !ok { + size := int64(0) + switch v := config.httpBody.(type) { + case *os.File: + if fInfo, err := v.Stat(); err == nil { + size = fInfo.Size() + } + case UpYunPutReader: + size = int64(v.Len()) + } + headers["Content-Length"] = fmt.Sprint(size) + } + headers["Authorization"] = up.MakeRESTAuth(&RESTAuthConfig{ + Method: config.method, + Uri: escUri, + DateStr: headers["Date"], + LengthStr: headers["Content-Length"], + }) + } else { + headers["Authorization"] = up.MakeUnifiedAuth(&UnifiedAuthConfig{ + Method: config.method, + Uri: escUri, + DateStr: headers["Date"], + ContentMD5: headers["Content-MD5"], + }) + } + + endpoint := up.doGetEndpoint("v0.api.upyun.com") + url := fmt.Sprintf("http://%s%s", endpoint, escUri) + + resp, err := up.doHTTPRequest(config.method, url, headers, config.httpBody) + if err != nil { + // Don't modify net error + return nil, err + } + + if resp.StatusCode/100 != 2 { + body, _ := ioutil.ReadAll(resp.Body) + resp.Body.Close() + return resp, fmt.Errorf("%s %d %s", config.method, resp.StatusCode, string(body)) + } + + if config.closeBody { + io.Copy(ioutil.Discard, resp.Body) + resp.Body.Close() + } + + return resp, nil +} diff --git a/upyun/rest_test.go b/upyun/rest_test.go new file mode 100644 index 0000000..88ec7cc --- /dev/null +++ b/upyun/rest_test.go @@ -0,0 +1,222 @@ +package upyun + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path" + "sort" + "strings" + "testing" +) + +var ( + REST_DIR = path.Join(ROOT, "REST") + REST_FILE_1 = path.Join(REST_DIR, "FILE_1") + REST_FILE_BUF = path.Join(REST_DIR, "FILE_BUF") + REST_FILE_1M = path.Join(REST_DIR, "FILE_1M") + REST_FILE_BUF_BUF = path.Join(REST_DIR, "文件_BUF_BUF") + REST_OBJS = []string{"FILE_1", "FILE_1M", "FILE_BUF", "文件_BUF_BUF"} + + BUF_CONTENT = "UPYUN GO SDK" + LOCAL_FILE = "./rest.go" + LOCAL_SAVE_FILE = LOCAL_FILE + "_bak" +) + +func TestUsage(t *testing.T) { + n, err := up.Usage() + Nil(t, err) + Equal(t, n > 0, true) +} + +func TestGetInfoDir(t *testing.T) { + fInfo, err := up.GetInfo("/") + Nil(t, err) + NotNil(t, fInfo) + Equal(t, fInfo.IsDir, true) +} + +func TestMkdir(t *testing.T) { + err := up.Mkdir(REST_DIR) + Nil(t, err) +} + +func TestPutWithFileReader(t *testing.T) { + fd, _ := os.Open(LOCAL_FILE) + NotNil(t, fd) + defer fd.Close() + + err := up.Put(&PutObjectConfig{ + Path: REST_FILE_1, + Reader: fd, + Headers: map[string]string{ + "X-Upyun-Meta-Filename": LOCAL_FILE, + }, + UseMD5: true, + }) + Nil(t, err) +} + +func TestPutWithBuffer(t *testing.T) { + s := BUF_CONTENT + r := strings.NewReader(s) + + err := up.Put(&PutObjectConfig{ + Path: REST_FILE_BUF, + Reader: r, + Headers: map[string]string{ + "Content-Length": fmt.Sprint(len(s)), + }, + UseMD5: true, + }) + Nil(t, err) +} + +func TestPutWithBufferAppend(t *testing.T) { + s := BUF_CONTENT + for k := 0; k < 3; k++ { + r := strings.NewReader(s) + err := up.Put(&PutObjectConfig{ + Path: REST_FILE_BUF_BUF, + Reader: r, + Headers: map[string]string{ + "Content-Length": fmt.Sprint(len(s)), + }, + AppendContent: true, + UseMD5: true, + }) + if k != 0 { + NotNil(t, err) + } else { + Nil(t, err) + } + } +} + +func TestResumePut(t *testing.T) { + fname := "1M" + fd, _ := os.Create(fname) + NotNil(t, fd) + kb := strings.Repeat("U", 1024) + for i := 0; i < (minResumePutFileSize/1024 + 2); i++ { + fd.WriteString(kb) + } + fd.Close() + + defer os.RemoveAll(fname) + + err := up.Put(&PutObjectConfig{ + Path: REST_FILE_1M, + LocalPath: fname, + UseMD5: true, + UseResumeUpload: true, + }) + Nil(t, err) +} + +func TestGetWithWriter(t *testing.T) { + b := make([]byte, 0) + buf := bytes.NewBuffer(b) + fInfo, err := up.Get(&GetObjectConfig{ + Path: REST_FILE_BUF, + Writer: buf, + }) + Nil(t, err) + NotNil(t, fInfo) + Equal(t, fInfo.IsDir, false) + Equal(t, fInfo.Size, int64(len(BUF_CONTENT))) + Equal(t, buf.String(), BUF_CONTENT) +} + +func TestGetWithLocalPath(t *testing.T) { + defer os.Remove(LOCAL_SAVE_FILE) + fInfo, err := up.Get(&GetObjectConfig{ + Path: REST_FILE_1, + LocalPath: LOCAL_SAVE_FILE, + }) + Nil(t, err) + NotNil(t, fInfo) + Equal(t, fInfo.IsDir, false) + + NotNil(t, fInfo.Meta) + name, _ := fInfo.Meta["x-upyun-meta-filename"] + Equal(t, name, LOCAL_FILE) + + _, err = os.Stat(LOCAL_SAVE_FILE) + Nil(t, err) + + b1, err := ioutil.ReadFile(LOCAL_FILE) + Nil(t, err) + + b2, err := ioutil.ReadFile(LOCAL_SAVE_FILE) + Nil(t, err) + + Equal(t, string(b1), string(b2)) +} + +func TestGetInfoFile(t *testing.T) { + fInfo, err := up.GetInfo(REST_FILE_BUF_BUF) + Nil(t, err) + NotNil(t, fInfo) + Equal(t, fInfo.IsDir, false) + Equal(t, fInfo.Name, REST_FILE_BUF_BUF) + // as append interface + Equal(t, fInfo.Size, int64(len(BUF_CONTENT))) +} + +func TestList(t *testing.T) { + ch := make(chan *FileInfo, 10) + files := []string{} + + go func() { + err := up.List(&GetObjectsConfig{ + Path: REST_DIR, + ObjectsChan: ch, + }) + Nil(t, err) + }() + + for fInfo := range ch { + files = append(files, fInfo.Name) + } + + Equal(t, len(files), len(REST_OBJS)) + sort.Strings(files) + sort.Strings(REST_OBJS) + for k := range REST_OBJS { + Equal(t, REST_OBJS[k], files[k]) + } +} + +func TestModifyMetadata(t *testing.T) { + // time.Sleep(10 * time.Second) + err := up.ModifyMetadata(&ModifyMetadataConfig{ + Path: REST_FILE_1, + Operation: "replace", + Headers: map[string]string{ + "X-Upyun-Meta-Filename": LOCAL_SAVE_FILE, + }, + }) + + Nil(t, err) +} + +func TestDelete(t *testing.T) { + err := up.Delete(&DeleteObjectConfig{ + Path: REST_DIR, + }) + NotNil(t, err) + + for _, obj := range REST_OBJS { + err := up.Delete(&DeleteObjectConfig{ + Path: path.Join(REST_DIR, obj), + }) + Nil(t, err) + } + + err = up.Delete(&DeleteObjectConfig{ + Path: REST_DIR, + }) + Nil(t, err) +} diff --git a/upyun/upyun-form-api.go b/upyun/upyun-form-api.go deleted file mode 100644 index d4d37f6..0000000 --- a/upyun/upyun-form-api.go +++ /dev/null @@ -1,110 +0,0 @@ -package upyun - -import ( - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "os" - "strconv" - "time" -) - -// UPYUN HTTP FORM API Client -type UpYunForm struct { - // Core - upYunHTTPCore - - Secret string - Bucket string -} - -// Response from UPYUN Form API Server -type FormAPIResp struct { - Code int `json:"code"` - Msg string `json:"message"` - Url string `json:"url"` - Timestamp int64 `json:"time"` - ImgWidth int `json:"image-width"` - ImgHeight int `json:"image-height"` - ImgFrames int `json:"image-frames"` - ImgType string `json:"image-type"` - Sign string `json:"sign"` -} - -// NewUpYunForm return a UPYUN Form API client given -// a form api key and bucket name. As Default, endpoint -// is set to Auto, http client connection timeout is -// set to defalutConnectionTimeout which is equal to -// 60 seconds. -func NewUpYunForm(bucket, secret string) *UpYunForm { - upm := &UpYunForm{ - Secret: secret, - Bucket: bucket, - } - - upm.httpClient = &http.Client{} - upm.SetTimeout(defaultConnectTimeout) - upm.SetEndpoint(Auto) - - return upm -} - -// SetEndpoint sets the request endpoint to UPYUN Form API Server. -func (u *UpYunForm) SetEndpoint(ed int) error { - if ed >= Auto && ed <= Ctt { - u.endpoint = fmt.Sprintf("v%d.api.upyun.com", ed) - return nil - } - - return errors.New("Invalid endpoint, pick from Auto, Telecom, Cnc, Ctt") -} - -// Put posts a http form request given reader, save path, -// expiration, other options and returns a FormAPIResp pointer. -func (uf *UpYunForm) Put(fpath, saveas string, expireAfter int64, - options map[string]string) (*FormAPIResp, error) { - if options == nil { - options = make(map[string]string) - } - - options["bucket"] = uf.Bucket - options["save-key"] = saveas - options["expiration"] = strconv.FormatInt(time.Now().Unix()+expireAfter, 10) - - args, err := json.Marshal(options) - if err != nil { - return nil, err - } - - policy := base64.StdEncoding.EncodeToString(args) - sig := md5Str(policy + "&" + uf.Secret) - - fd, err := os.Open(fpath) - if err != nil { - return nil, err - } - - defer fd.Close() - - url := fmt.Sprintf("http://%s/%s", uf.endpoint, uf.Bucket) - resp, err := uf.doFormRequest(url, policy, sig, fpath, fd) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - buf, err := ioutil.ReadAll(resp.Body) - if resp.StatusCode/100 == 2 { - var formResp FormAPIResp - if err := json.Unmarshal(buf, &formResp); err != nil { - return nil, err - } - return &formResp, nil - } - - return nil, errors.New(string(buf)) -} diff --git a/upyun/upyun-http-core.go b/upyun/upyun-http-core.go deleted file mode 100644 index bb6a34d..0000000 --- a/upyun/upyun-http-core.go +++ /dev/null @@ -1,102 +0,0 @@ -package upyun - -import ( - "bytes" - "io" - "mime/multipart" - "net" - "net/http" - "path/filepath" - "strconv" - "time" -) - -// Auto: Auto detected, based on user's internet -// Telecom: (ISP) China Telecom -// Cnc: (ISP) China Unicom -// Ctt: (ISP) China Tietong -const ( - Auto = iota - Telecom - Cnc - Ctt -) - -type upYunHTTPCore struct { - endpoint string - httpClient *http.Client -} - -func (core *upYunHTTPCore) SetTimeout(timeout time.Duration) { - core.httpClient = &http.Client{ - Transport: &http.Transport{ - Dial: func(network, addr string) (c net.Conn, err error) { - c, err = net.DialTimeout(network, addr, timeout) - if err != nil { - return nil, err - } - return - }, - // http://studygolang.com/articles/3138 - // DisableKeepAlives: true, - }, - } -} - -// do http form request -func (core *upYunHTTPCore) doFormRequest(url, policy, sign, - fname string, fd io.Reader) (*http.Response, error) { - - body := &bytes.Buffer{} - headers := make(map[string]string) - - // generate form data - err := func() error { - writer := multipart.NewWriter(body) - defer writer.Close() - - var err error - var part io.Writer - - writer.WriteField("policy", policy) - writer.WriteField("signature", sign) - if part, err = writer.CreateFormFile("file", filepath.Base(fname)); err == nil { - if _, err = chunkedCopy(part, fd); err == nil { - headers["Content-Type"] = writer.FormDataContentType() - } - } - return err - }() - - if err != nil { - return nil, err - } - - return core.doHTTPRequest("POST", url, headers, body) -} - -// do http request -func (core *upYunHTTPCore) doHTTPRequest(method, url string, headers map[string]string, - body io.Reader) (resp *http.Response, err error) { - req, err := http.NewRequest(method, url, body) - if err != nil { - return nil, err - } - - for k, v := range headers { - req.Header.Set(k, v) - } - - // User Agent - req.Header.Set("User-Agent", makeUserAgent()) - - // https://code.google.com/p/go/issues/detail?id=6738 - if method == "PUT" || method == "POST" { - length := req.Header.Get("Content-Length") - if length != "" { - req.ContentLength, _ = strconv.ParseInt(length, 10, 64) - } - } - - return core.httpClient.Do(req) -} diff --git a/upyun/upyun-media-api.go b/upyun/upyun-media-api.go deleted file mode 100644 index 4e2dc03..0000000 --- a/upyun/upyun-media-api.go +++ /dev/null @@ -1,166 +0,0 @@ -package upyun - -import ( - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "sort" - "strings" -) - -// UPYUN MEDIA API -type UpYunMedia struct { - upYunHTTPCore // HTTP Core - - Username string - Passwd string - Bucket string -} - -// status response -type MediaStatusResp struct { - Tasks map[string]interface{} `json:"tasks"` -} - -// NewUpYunMedia returns a new UPYUN Media API client given -// a bucket name, username, password. http client connection -// timeout is set to defalutConnectionTimeout which -// is equal to 60 seconds. - -func NewUpYunMedia(bucket, user, pass string) *UpYunMedia { - up := &UpYunMedia{ - Username: user, - Passwd: md5Str(pass), - Bucket: bucket, - } - - client := &http.Client{} - up.SetTimeout(defaultConnectTimeout) - - up.endpoint = "p0.api.upyun.com" - up.httpClient = client - - return up -} - -func (upm *UpYunMedia) makeMediaAuth(kwargs map[string]string) string { - var keys []string - for k, _ := range kwargs { - keys = append(keys, k) - } - - sort.Strings(keys) - - auth := "" - for _, k := range keys { - auth += k + kwargs[k] - } - - return fmt.Sprintf("UPYUN %s:%s", upm.Username, - md5Str(upm.Username+auth+upm.Passwd)) -} - -// Send Media Tasks Reqeust -func (upm *UpYunMedia) PostTasks(src, notify, accept string, - tasks []map[string]interface{}) ([]string, error) { - data, err := json.Marshal(tasks) - if err != nil { - return nil, err - } - - kwargs := map[string]string{ - "bucket_name": upm.Bucket, - "source": src, - "notify_url": notify, - "tasks": base64Str(data), - "accept": accept, - } - - resp, err := upm.doMediaRequest("POST", "/pretreatment", kwargs) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - buf, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode/2 == 100 { - var ids []string - err = json.Unmarshal(buf, &ids) - if err != nil { - return nil, err - } - return ids, err - } - - return nil, errors.New(string(buf)) -} - -// Get Task Progress -func (upm *UpYunMedia) GetProgress(task_ids string) (*MediaStatusResp, error) { - - kwargs := map[string]string{ - "bucket_name": upm.Bucket, - "task_ids": task_ids, - } - - resp, err := upm.doMediaRequest("GET", "/status", kwargs) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - buf, err := ioutil.ReadAll(resp.Body) - if resp.StatusCode/2 == 100 { - var status MediaStatusResp - if err := json.Unmarshal(buf, &status); err != nil { - return nil, err - } - return &status, nil - } - - return nil, errors.New(string(buf)) -} - -func (upm *UpYunMedia) doMediaRequest(method, path string, - kwargs map[string]string) (*http.Response, error) { - - // Normalize url - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - url := fmt.Sprintf("http://%s%s", upm.endpoint, path) - - // Set Headers - headers := make(map[string]string) - date := genRFC1123Date() - headers["Date"] = date - headers["Authorization"] = upm.makeMediaAuth(kwargs) - - // Payload - var options []string - for k, v := range kwargs { - options = append(options, k+"="+v) - } - payload := strings.Join(options, "&") - - if method == "GET" { - url = url + "?" + payload - return upm.doHTTPRequest(method, url, headers, nil) - } else { - if method == "POST" { - headers["Content-Length"] = fmt.Sprint(len(payload)) - return upm.doHTTPRequest(method, url, headers, - strings.NewReader(payload)) - } - } - - return nil, errors.New("Unknown method") -} diff --git a/upyun/upyun-multipart-api.go b/upyun/upyun-multipart-api.go deleted file mode 100644 index dd0d72e..0000000 --- a/upyun/upyun-multipart-api.go +++ /dev/null @@ -1,301 +0,0 @@ -package upyun - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "os" - "sort" - "strings" - "time" -) - -// UPYUN MultiPart Upload API -type UpYunMultiPart struct { - upYunHTTPCore - - Bucket string - Secret string - BlockSize int64 -} - -// upload response body -type UploadResp struct { - // returns after init request - SaveToken string `json:"save_token"` - // token_secert is equal to UPYUN Form API Secret - Secret string `json:"token_secret"` - // UPYUN Bucket Name - Bucket string `json:"bucket_name"` - // Number of Blocks - Blocks int `json:"blocks"` - Status []int `json:"status"` - ExpireAt int64 `json:"expire_at"` -} - -// merge response body -type MergeResp struct { - Path string `json:"path"` - ContentType string `json:"mimetype"` - ContentLength interface{} `json:"file_size"` - LastModify int64 `json:"last_modified"` - Signature string `json:"signature"` - ImageWidth int `json:"image_width"` - ImageHeight int `json:"image_height"` - ImageFrames int `json:"image_frames"` -} - -// NewUpYunMultiPart returns a new UPYUN Multipart Upload API client -// given bucket name, form api key and blocksize. -func NewUpYunMultiPart(bucket, secret string, blocksize int64) *UpYunMultiPart { - up := &UpYunMultiPart{ - Secret: secret, - Bucket: bucket, - BlockSize: blocksize, - } - - up.endpoint = "m0.api.upyun.com" - up.httpClient = &http.Client{ - Transport: &http.Transport{ - Dial: timeoutDialer(defaultConnectTimeout), - }, - } - - return up -} - -// make multipart upload authorization -func (ump *UpYunMultiPart) makeMPAuth(secret string, kwargs map[string]interface{}) string { - var keys []string - for k, _ := range kwargs { - keys = append(keys, k) - } - sort.Strings(keys) - - sign := "" - for _, k := range keys { - sign += k + fmt.Sprint(kwargs[k]) - } - - return md5Str(sign + secret) -} - -func (ump *UpYunMultiPart) makePolicy(kwargs map[string]interface{}) (string, error) { - data, err := json.Marshal(kwargs) - if err != nil { - return "", err - } - - return base64.StdEncoding.EncodeToString(data), nil -} - -// InitUpload initalizes a multipart upload request -func (ump *UpYunMultiPart) InitUpload(key string, value *os.File, - expire int64, options map[string]interface{}) ([]byte, error) { - // seek at start point - value.Seek(0, 0) - hash, fsize, err := md5sum(value) - if err != nil { - return nil, err - } - - opt := map[string]interface{}{ - "path": key, - "expiration": time.Now().UTC().Unix() + expire, - "file_hash": string(hash), - "file_size": fsize, - "file_blocks": (fsize + ump.BlockSize - 1) / ump.BlockSize, - } - if options != nil { - for k, v := range options { - opt[k] = v - } - } - - // make policy - policy, err := ump.makePolicy(opt) - if err != nil { - return nil, err - } - - // make signature - signature := ump.makeMPAuth(ump.Secret, opt) - payload := fmt.Sprintf("policy=%s&signature=%s", policy, signature) - - // set http headers - headers := map[string]string{ - "Content-Length": fmt.Sprint(len(payload)), - "Content-Type": "application/x-www-form-urlencoded", - } - - url := fmt.Sprintf("http://%s/%s/", ump.endpoint, ump.Bucket) - resp, err := ump.doHTTPRequest("POST", - url, headers, strings.NewReader(payload)) - - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - if resp.StatusCode/100 == 2 { - return body, err - } - - return nil, errors.New(string(body)) -} - -// UploadBlock uploads a block -func (ump *UpYunMultiPart) UploadBlock(fd *os.File, bindex int, expire int64, - fpath, saveToken, secret string) ([]byte, error) { - - block := make([]byte, ump.BlockSize) - // seek to this block's start point - _, err := fd.Seek(ump.BlockSize*int64(bindex), 0) - if err != nil { - return nil, err - } - - // read block - n, err := fd.Read(block) - if err != nil { - return nil, err - } - rblock := block[:n] - - // calculate md5 - hash, _, err := md5sum(bytes.NewBuffer(rblock)) - if err != nil { - return nil, err - } - - opts := map[string]interface{}{ - "save_token": saveToken, - "expiration": time.Now().UTC().Unix() + expire, - "block_index": bindex, - "block_hash": string(hash), - } - - policy, err := ump.makePolicy(opts) - if err != nil { - return nil, err - } - - signature := ump.makeMPAuth(secret, opts) - url := fmt.Sprintf("http://%s/%s/", ump.endpoint, ump.Bucket) - - resp, err := ump.doFormRequest(url, policy, signature, fpath, bytes.NewBuffer(rblock)) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - if resp.StatusCode/100 == 2 { - return body, err - } - - return nil, errors.New(string(body)) -} - -// MergeBlock posts a merge request to merge all blocks uploaded -func (ump *UpYunMultiPart) MergeBlock(saveToken, secret string, - expire int64) ([]byte, error) { - opts := map[string]interface{}{ - "save_token": saveToken, - "expiration": time.Now().UTC().Unix() + expire, - } - - policy, err := ump.makePolicy(opts) - if err != nil { - return nil, err - } - - signature := ump.makeMPAuth(secret, opts) - payload := fmt.Sprintf("policy=%s&signature=%s", policy, signature) - - headers := map[string]string{ - "Content-Length": fmt.Sprint(len(payload)), - "Content-Type": "application/x-www-form-urlencoded", - } - - url := fmt.Sprintf("http://%s/%s/", ump.endpoint, ump.Bucket) - resp, err := ump.doHTTPRequest("POST", - url, headers, strings.NewReader(payload)) - - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - if resp.StatusCode/100 == 2 { - return body, err - } - - return nil, errors.New(string(body)) -} - -// Put uploads a file through UPYUN MultiPart Upload API -func (ump *UpYunMultiPart) Put(fpath, saveas string, - expireAfter int64, options map[string]interface{}) (*MergeResp, error) { - fd, err := os.Open(fpath) - if err != nil { - return nil, err - } - defer fd.Close() - - rdata, err := ump.InitUpload(saveas, fd, expireAfter, options) - if err != nil { - return nil, errors.New("failed to init upload." + err.Error()) - } - - var ub UploadResp - if err := json.Unmarshal(rdata, &ub); err != nil { - return nil, err - } - - saveToken := ub.SaveToken - secret := ub.Secret - status := ub.Status - for try := 1; try <= 3; try++ { - ok := 0 - for idx, _ := range status { - if status[idx] == 0 { - _, err = ump.UploadBlock(fd, idx, expireAfter, fpath, saveToken, secret) - if err != nil { - break - } - status[idx] = 1 - } - ok++ - } - - if ok == len(status) { - break - } - - if try == 3 { - return nil, errors.New("failed to upload block." + err.Error()) - } - } - - data, err := ump.MergeBlock(saveToken, secret, expireAfter) - if err != nil { - return nil, errors.New("failed to merge blocks." + err.Error()) - } - - var mr MergeResp - if err := json.Unmarshal(data, &mr); err != nil { - return nil, err - } - - return &mr, nil -} diff --git a/upyun/upyun-rest-api.go b/upyun/upyun-rest-api.go deleted file mode 100644 index 3bce0c6..0000000 --- a/upyun/upyun-rest-api.go +++ /dev/null @@ -1,495 +0,0 @@ -package upyun - -import ( - "bytes" - "crypto/md5" - "encoding/json" - "errors" - "fmt" - "io" - "io/ioutil" - "net" - "net/http" - URL "net/url" - "os" - "path" - "strconv" - "strings" - "time" -) - -// UPYUN REST API Client -type UpYun struct { - // Core - upYunHTTPCore - - Bucket string - Username string - Passwd string - ChunkSize int -} - -// NewUpYun return a new UPYUN REST API client given a bucket name, -// username, password. As Default, endpoint is set to Auto, http -// client connection timeout is set to defalutConnectionTimeout which -// is equal to 60 seconds. -func NewUpYun(bucket, username, passwd string) *UpYun { - u := &UpYun{ - Bucket: bucket, - Username: username, - Passwd: passwd, - } - - u.httpClient = &http.Client{} - u.SetEndpoint(Auto) - u.SetTimeout(defaultConnectTimeout) - - return u -} - -// SetEndpoint sets the request endpoint to UPYUN REST API Server. -func (u *UpYun) SetEndpoint(ed int) error { - if ed >= Auto && ed <= Ctt { - u.endpoint = fmt.Sprintf("v%d.api.upyun.com", ed) - return nil - } - - return errors.New("Invalid endpoint, pick from Auto, Telecom, Cnc, Ctt") -} - -// SetEndpointStr sets the request endpoint to UPYUN REST API Server. -func (u *UpYun) SetEndpointStr(endpoint string) error { - u.endpoint = endpoint - return nil -} - -// make UpYun REST Authorization -func (u *UpYun) makeRESTAuth(method, uri, date, lengthStr string) string { - sign := []string{method, uri, date, lengthStr, md5Str(u.Passwd)} - - return "UpYun " + u.Username + ":" + md5Str(strings.Join(sign, "&")) -} - -// make UpYun Purge Authorization -func (u *UpYun) makePurgeAuth(purgeList, date string) string { - sign := []string{purgeList, u.Bucket, date, md5Str(u.Passwd)} - - return "UpYun " + u.Bucket + ":" + u.Username + ":" + md5Str(strings.Join(sign, "&")) -} - -// Usage gets the usage of the bucket in UPYUN File System -func (u *UpYun) Usage() (int64, error) { - result, _, err := u.doRESTRequest("GET", "/", "usage", nil, nil) - if err != nil { - return 0, err - } - - return strconv.ParseInt(result, 10, 64) -} - -// Mkdir creates a directory in UPYUN File System -func (u *UpYun) Mkdir(key string) error { - headers := make(map[string]string) - - headers["mkdir"] = "true" - headers["folder"] = "true" - - _, _, err := u.doRESTRequest("POST", key, "", headers, nil) - - return err -} - -// Put uploads filelike object to UPYUN File System -func (u *UpYun) Put(key string, value io.Reader, useMD5 bool, - headers map[string]string) (http.Header, error) { - if headers == nil { - headers = make(map[string]string) - } - - if _, ok := headers["Content-Length"]; !ok { - switch v := value.(type) { - case *os.File: - if fileInfo, err := v.Stat(); err != nil { - return nil, err - } else { - headers["Content-Length"] = fmt.Sprint(fileInfo.Size()) - } - default: - // max buffer is 10k - rw := bytes.NewBuffer(make([]byte, 0, 10240)) - if n, err := io.Copy(rw, value); err != nil { - return nil, err - } else { - headers["Content-Length"] = fmt.Sprint(n) - } - value = rw - } - } - - if _, ok := headers["Content-MD5"]; !ok && useMD5 { - switch v := value.(type) { - case *os.File: - hash := md5.New() - if _, err := io.Copy(hash, value); err != nil { - return nil, err - } - - headers["Content-MD5"] = fmt.Sprintf("%x", hash.Sum(nil)) - - if _, err := v.Seek(0, 0); err != nil { - return nil, err - } - } - } - - _, rtHeaders, err := u.doRESTRequest("PUT", key, "", headers, value) - - return rtHeaders, err -} - -// Put uploads file object to UPYUN File System part by part, -// and automatically retries when a network problem occurs -func (u *UpYun) ResumePut(key string, value *os.File, useMD5 bool, - headers map[string]string, reporter ResumeReporter) (http.Header, error) { - if headers == nil { - headers = make(map[string]string) - } - - fileinfo, err := value.Stat() - if err != nil { - return nil, err - } - - // If filesize < resumePartSizeLowerLimit, use UpYun.Put() instead - if fileinfo.Size() < resumeFileSizeLowerLimit { - return u.Put(key, value, useMD5, headers) - } - - maxPartID := int(fileinfo.Size() / resumePartSize) - if fileinfo.Size()%resumePartSize == 0 { - maxPartID-- - } - - var resp http.Header - - for part := 0; part <= maxPartID; part++ { - - innerHeaders := make(map[string]string) - for k, v := range headers { - innerHeaders[k] = v - } - - innerHeaders["X-Upyun-Part-Id"] = strconv.Itoa(part) - switch part { - case 0: - innerHeaders["X-Upyun-Multi-Type"] = headers["Content-Type"] - innerHeaders["X-Upyun-Multi-Length"] = strconv.FormatInt(fileinfo.Size(), 10) - innerHeaders["X-Upyun-Multi-Stage"] = "initiate,upload" - innerHeaders["Content-Length"] = strconv.Itoa(resumePartSize) - case maxPartID: - innerHeaders["X-Upyun-Multi-Stage"] = "upload,complete" - innerHeaders["Content-Length"] = fmt.Sprint(fileinfo.Size() - int64(resumePartSize)*int64(part)) - if useMD5 { - value.Seek(0, 0) - hex, _, _ := md5sum(value) - innerHeaders["X-Upyun-Multi-MD5"] = hex - } - default: - innerHeaders["X-Upyun-Multi-Stage"] = "upload" - innerHeaders["Content-Length"] = strconv.Itoa(resumePartSize) - } - - file, err := NewFragmentFile(value, int64(part)*int64(resumePartSize), resumePartSize) - if err != nil { - return resp, err - } - if useMD5 { - innerHeaders["Content-MD5"], _ = file.MD5() - } - - // Retry when get net error from UpYun.Put(), return error in other cases - for i := 0; i < ResumeRetryCount+1; i++ { - resp, err = u.Put(key, file, useMD5, innerHeaders) - if err == nil { - break - } - // Retry only get net error - _, ok := err.(net.Error) - if !ok { - return resp, err - } - if i == ResumeRetryCount { - return resp, err - } - time.Sleep(ResumeWaitTime) - file.Seek(0, 0) - } - if reporter != nil { - reporter(part, maxPartID) - } - - if part == 0 { - headers["X-Upyun-Multi-UUID"] = resp.Get("X-Upyun-Multi-Uuid") - } - } - - return resp, nil -} - -// Get gets the specified file in UPYUN File System -func (u *UpYun) Get(key string, value io.Writer) (int, error) { - length, _, err := u.doRESTRequest("GET", key, "", nil, value) - if err != nil { - return 0, err - } - return strconv.Atoi(length) -} - -// Delete deletes the specified **file** in UPYUN File System. -func (u *UpYun) Delete(key string) error { - _, _, err := u.doRESTRequest("DELETE", key, "", nil, nil) - - return err -} - -// AsyncDelete deletes the specified **file** in UPYUN File System asynchronously. -func (u *UpYun) AsyncDelete(key string) error { - headers := map[string]string{ - "X-Upyun-Async": "true", - } - _, _, err := u.doRESTRequest("DELETE", key, "", headers, nil) - - return err -} - -// GetList lists items in key. The number of items must be -// less then 100 -func (u *UpYun) GetList(key string) ([]*FileInfo, error) { - ret, _, err := u.doRESTRequest("GET", key, "", nil, nil) - if err != nil { - return nil, err - } - - list := strings.Split(ret, "\n") - var infoList []*FileInfo - - for _, v := range list { - if len(v) == 0 { - continue - } - infoList = append(infoList, newFileInfo(v)) - } - - return infoList, nil -} - -// Note: key must be directory -func (u *UpYun) GetLargeList(key string, asc, recursive bool) (chan *FileInfo, chan error) { - infoChannel := make(chan *FileInfo, 1000) - errChannel := make(chan error, 10) - if !strings.HasSuffix(key, "/") { - key += "/" - } - order := "desc" - if asc == true { - order = "asc" - } - - go func() { - var listDir func(k string) error - listDir = func(k string) error { - var infos []*FileInfo - var niter string - var err error - iter, limit := "", 50 - for { - infos, niter, err = u.loopList(k, iter, order, limit) - if err != nil { - errChannel <- err - return err - } - iter = niter - for _, f := range infos { - // absolute path - abs := path.Join(k, f.Name) - // relative path - f.Name = strings.Replace(abs, key, "", 1) - if f.Name[0] == '/' { - f.Name = f.Name[1:] - } - if recursive && f.Type == "folder" { - if err = listDir(abs + "/"); err != nil { - return err - } - } - infoChannel <- f - } - if iter == "" { - break - } - } - return nil - } - - listDir(key) - - close(errChannel) - close(infoChannel) - }() - - return infoChannel, errChannel -} - -// LoopList list items iteratively. -func (u *UpYun) loopList(key, iter, order string, limit int) ([]*FileInfo, string, error) { - headers := map[string]string{ - "X-List-Limit": fmt.Sprint(limit), - "X-List-Order": order, - } - if iter != "" { - headers["X-List-Iter"] = iter - } - - ret, rtHeaders, err := u.doRESTRequest("GET", key, "", headers, nil) - if err != nil { - return nil, "", err - } - - list := strings.Split(ret, "\n") - var infoList []*FileInfo - for _, v := range list { - if len(v) == 0 { - continue - } - infoList = append(infoList, newFileInfo(v)) - } - - nextIter := "" - if _, ok := rtHeaders["X-Upyun-List-Iter"]; ok { - nextIter = rtHeaders["X-Upyun-List-Iter"][0] - } else { - // Maybe Wrong - return nil, "", nil - } - - if nextIter == "g2gCZAAEbmV4dGQAA2VvZg" { - nextIter = "" - } - - return infoList, nextIter, nil -} - -// GetInfo gets information of item in UPYUN File System -func (u *UpYun) GetInfo(key string) (*FileInfo, error) { - _, headers, err := u.doRESTRequest("HEAD", key, "", nil, nil) - if err != nil { - return nil, err - } - - fileInfo := newFileInfo(headers) - - return fileInfo, nil -} - -// Purge post a purge request to UPYUN Purge Server -func (u *UpYun) Purge(urls []string) (string, error) { - purge := "http://purge.upyun.com/purge/" - - date := genRFC1123Date() - purgeList := strings.Join(urls, "\n") - - headers := make(map[string]string) - headers["Date"] = date - headers["Authorization"] = u.makePurgeAuth(purgeList, date) - headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" - - form := make(URL.Values) - form.Add("purge", purgeList) - - body := strings.NewReader(form.Encode()) - resp, err := u.doHTTPRequest("POST", purge, headers, body) - defer resp.Body.Close() - - content, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode/100 == 2 { - result := make(map[string][]string) - if err := json.Unmarshal(content, &result); err != nil { - // quick fix for invalid json resp: {"invalid_domain_of_url":{}} - return "", nil - } - - return strings.Join(result["invalid_domain_of_url"], ","), nil - } - - return "", errors.New(string(content)) -} - -func (u *UpYun) doRESTRequest(method, uri, query string, headers map[string]string, - value interface{}) (result string, rtHeaders http.Header, err error) { - if headers == nil { - headers = make(map[string]string) - } - - // Normalize url - if !strings.HasPrefix(uri, "/") { - uri = "/" + uri - } - - uri = escapeURI("/" + u.Bucket + uri) - url := fmt.Sprintf("http://%s%s", u.endpoint, uri) - - if query != "" { - query = escapeURI(query) - url += "?" + query - } - - // date - date := genRFC1123Date() - - // auth - lengthStr, ok := headers["Content-Length"] - if !ok { - lengthStr = "0" - } - - headers["Date"] = date - headers["Authorization"] = u.makeRESTAuth(method, uri, date, lengthStr) - if !strings.Contains(u.endpoint, "api.upyun.com") { - headers["Host"] = "v0.api.upyun.com" - } - - // HEAD GET request has no body - rc, ok := value.(io.Reader) - if !ok || method == "GET" || method == "HEAD" { - rc = nil - } - - resp, err := u.doHTTPRequest(method, url, headers, rc) - if err != nil { - return "", nil, err - } - - defer resp.Body.Close() - - if (resp.StatusCode / 100) == 2 { - if method == "GET" && value != nil { - written, err := chunkedCopy(value.(io.Writer), resp.Body) - return strconv.FormatInt(written, 10), resp.Header, err - } - body, err := ioutil.ReadAll(resp.Body) - return string(body), resp.Header, err - } - - if body, err := ioutil.ReadAll(resp.Body); err == nil { - if len(body) == 0 && resp.StatusCode/100 != 2 { - return "", resp.Header, errors.New(fmt.Sprint(resp.StatusCode)) - } - return "", resp.Header, errors.New(string(body)) - } else { - return "", resp.Header, err - } -} diff --git a/upyun/upyun-resume.go b/upyun/upyun-resume.go deleted file mode 100644 index 42fae87..0000000 --- a/upyun/upyun-resume.go +++ /dev/null @@ -1,93 +0,0 @@ -package upyun - -import ( - "errors" - "fmt" - "io" - "os" - "time" -) - -const ( - // resumePartSize is the size of each part for resume upload - resumePartSize = 1024 * 1024 - // resumeFileSizeLowerLimit is the lowest file size limit for resume upload - resumeFileSizeLowerLimit int64 = resumePartSize * 10 -) - -var ( - // ResumeRetry is the number of retries for resume upload - ResumeRetryCount = 3 - // ResumeWaitSeconds is the number of time to wait when net error occurs - ResumeWaitTime = time.Second * 5 -) - -// ResumeReporter -type ResumeReporter func(int, int) - -// ResumeReporterPrintln is a simple ResumeReporter for test -func ResumeReporterPrintln(partID int, maxPartID int) { - fmt.Printf("resume test reporter: %v / %v\n", partID, maxPartID) -} - -// FragmentFile is like os.File, but only a part of file can be Read(). -// return io.EOF when cursor fetch the limit. -type FragmentFile struct { - offset int64 - limit int - cursor int - *os.File -} - -// NewFragmentFile returns a new FragmentFile. -func NewFragmentFile(file *os.File, offset int64, limit int) (*FragmentFile, error) { - sizedfile := &FragmentFile{ - offset: offset, - limit: limit, - File: file, - } - _, err := sizedfile.Seek(0, 0) - if err != nil { - return nil, err - } - return sizedfile, nil -} - -// Seek likes os.File.Seek() -func (f *FragmentFile) Seek(offset int64, whence int) (ret int64, err error) { - switch whence { - case 0: - f.cursor = int(offset) - return f.File.Seek(f.offset+offset, 0) - default: - return 0, errors.New("whence must be 0") - } -} - -// Read is just like os.File.Read but return io.EOF when catch sizedfile's limit -// or the end of file -func (f *FragmentFile) Read(b []byte) (n int, err error) { - if f.cursor >= f.limit { - return 0, io.EOF - } - n, err = f.File.Read(b) - if int(f.cursor)+n > f.limit { - n = f.limit - f.cursor - } - f.cursor += n - return n, err -} - -// Close will not actually close FragmentFile -func (f *FragmentFile) Close() error { - return nil -} - -// MD5 returns md5 of the FragmentFile. -func (f *FragmentFile) MD5() (string, error) { - cursor := f.cursor - f.Seek(0, 0) - md5, _, err := md5sum(f) - f.Seek(int64(cursor), 0) - return md5, err -} diff --git a/upyun/upyun.go b/upyun/upyun.go index 8dfc5e2..a8889f7 100644 --- a/upyun/upyun.go +++ b/upyun/upyun.go @@ -1,174 +1,61 @@ -// package upyun is used for your UPYUN bucket -// this sdk implement purge api, form api, http rest api package upyun import ( - "crypto/md5" - "encoding/base64" - "encoding/hex" - "fmt" - "io" "net" "net/http" - URL "net/url" - "strconv" - "strings" "time" ) const ( - Version = "2.0.0" -) + version = "0.1.0" -const ( - // Default(Min/Max)ChunkSize: set the buffer size when doing copy operation - defaultChunkSize = 32 * 1024 - // defaultConnectTimeout: connection timeout when connect to upyun endpoint + defaultChunkSize = 32 * 1024 defaultConnectTimeout = time.Second * 60 ) -// chunkSize: chunk size when copy -var ( - chunkSize = defaultChunkSize -) - -// Util functions - -// User Agent -func makeUserAgent() string { - return fmt.Sprintf("UPYUN Go SDK %s", Version) -} - -// Greenwich Mean Time -func genRFC1123Date() string { - return time.Now().UTC().Format(time.RFC1123) -} - -// make md5 from string -func md5Str(s string) (ret string) { - return fmt.Sprintf("%x", md5.Sum([]byte(s))) -} - -// make base64 from []byte -func base64Str(b []byte) string { - return base64.StdEncoding.EncodeToString(b) -} - -// URL encode -func encodeURL(uri string) string { - return base64.URLEncoding.EncodeToString([]byte(uri)) -} - -// URI escape -func escapeURI(uri string) string { - Uri := URL.URL{} - Uri.Path = uri - return Uri.String() -} - -func md5sum(fd io.Reader) (string, int64, error) { - var result []byte - hash := md5.New() - if written, err := io.Copy(hash, fd); err != nil { - return "", written, err +type UpYunConfig struct { + Bucket string + Operator string + Password string + Secret string // deprecated + Hosts map[string]string + UserAgent string +} + +type UpYun struct { + UpYunConfig + httpc *http.Client + deprecated bool +} + +func NewUpYun(config *UpYunConfig) *UpYun { + up := &UpYun{} + up.Bucket = config.Bucket + up.Operator = config.Operator + up.Password = md5Str(config.Password) + up.Secret = config.Secret + up.Hosts = config.Hosts + if config.UserAgent != "" { + up.UserAgent = config.UserAgent } else { - return hex.EncodeToString(hash.Sum(result)), written, nil - } -} - -// Because of io.Copy use a 32Kb buffer, and, it is hard coded -// user can specify a chunksize with upyun.SetChunkSize -func chunkedCopy(dst io.Writer, src io.Reader) (written int64, err error) { - buf := make([]byte, chunkSize) - - for { - nr, er := src.Read(buf) - if nr > 0 { - nw, ew := dst.Write(buf[0:nr]) - - if nw > 0 { - written += int64(nw) - } - if ew != nil { - err = ew - break - } - if nr != nw { - err = io.ErrShortWrite - break - } - } - if er == io.EOF { - break - } - if er != nil { - err = er - break - } + up.UserAgent = makeUserAgent(version) } - return -} -// Use for http connection timeout -func timeoutDialer(timeout time.Duration) func(string, string) (net.Conn, error) { - return func(network, addr string) (c net.Conn, err error) { - c, err = net.DialTimeout(network, addr, timeout) - if err != nil { - return nil, err - } - return + up.httpc = &http.Client{ + Transport: &http.Transport{ + Dial: func(network, addr string) (c net.Conn, err error) { + return net.DialTimeout(network, addr, defaultConnectTimeout) + }, + }, } -} -func SetChunkSize(chunksize int) { - chunkSize = chunksize + return up } -// FileInfo when use getlist -type FileInfo struct { - Size int64 - Time time.Time - Name string - Type string +func (up *UpYun) SetHTTPClient(httpc *http.Client) { + up.httpc = httpc } -func newFileInfo(arg interface{}) *FileInfo { - switch arg.(type) { - case string: - s := arg.(string) - infoList := strings.Split(s, "\t") - if len(infoList) != 4 { - return nil - } - - size, _ := strconv.ParseInt(infoList[2], 10, 64) - timestamp, _ := strconv.ParseInt(infoList[3], 10, 64) - typ := "folder" - if infoList[1] != "F" { - typ = "file" - } - - return &FileInfo{ - Name: infoList[0], - Type: typ, - Size: size, - Time: time.Unix(timestamp, 0), - } - - default: - var fileInfo FileInfo - headers := arg.(http.Header) - for k, v := range headers { - switch { - case strings.Contains(k, "File-Type"): - fileInfo.Type = v[0] - case strings.Contains(k, "File-Size"): - fileInfo.Size, _ = strconv.ParseInt(v[0], 10, 64) - case strings.Contains(k, "File-Date"): - timestamp, _ := strconv.ParseInt(v[0], 10, 64) - fileInfo.Time = time.Unix(timestamp, 0) - } - } - return &fileInfo - } +func (up *UpYun) UseDeprecatedApi() { + up.deprecated = true } diff --git a/upyun/upyun_test.go b/upyun/upyun_test.go index a347aed..37e4b01 100644 --- a/upyun/upyun_test.go +++ b/upyun/upyun_test.go @@ -1,346 +1,116 @@ package upyun import ( - "bytes" - "crypto/md5" + "flag" "fmt" - "io" "os" - "strings" + "path" + "path/filepath" + "reflect" + "runtime" + "sync" "testing" ) var ( - username = os.Getenv("UPYUN_USERNAME") - password = os.Getenv("UPYUN_PASSWORD") - bucket = os.Getenv("UPYUN_BUCKET") - apikey = os.Getenv("UPYUN_SECRET") - up = NewUpYun(bucket, username, password) - upf = NewUpYunForm(bucket, apikey) - ump = NewUpYunMultiPart(bucket, apikey, 1024000) - upm = NewUpYunMedia(bucket, username, password) - testPath = "/gosdk" - upload = "upyun-rest-api.go" - uploadInfo, _ = os.Lstat(upload) - uploadSize = uploadInfo.Size() - download = "/tmp/xxx.go" - - length int - err error - fd *os.File - upInfo *FileInfo - upInfos []*FileInfo - formResp *FormAPIResp - mergeResp *MergeResp + ROOT = MakeTmpPath() + NOTIFY_URL = "http://124.160.114.202:18989/echo?key=gosdk" ) -func TestUsage(t *testing.T) { - if _, err := up.Usage(); err != nil { - fmt.Println(err) - t.Errorf("failed to get Usage. %v", err) - } -} - -func TestSetEndpoint(t *testing.T) { - for _, ed := range []int{Telecom, Cnc, Ctt, Auto} { - if err = up.SetEndpoint(ed); err == nil { - _, err = up.Usage() - } - if err != nil { - t.Errorf("failed to SetEndpoint. %v %v", ed, err) - } - } - if err = up.SetEndpoint(5); err == nil { - t.Errorf("invalid SetEndpoint") - } -} - -func TestMkdir(t *testing.T) { - if _, err = up.GetInfo(testPath); err == nil { - t.Error(testPath, "already exists") - // t.Fail() - } - if err = up.Mkdir(testPath); err == nil { - _, err = up.GetInfo(testPath) - } - if err != nil { - t.Errorf("failed to Mkdir. %v", err) - } -} - -func TestPut(t *testing.T) { - // put file - if fd, err = os.Open(upload); err != nil { - t.Skipf("failed to open %s %v", upload, err) - } - - _, err = up.Put(testPath+"/"+upload, fd, false, nil) - if err != nil { - t.Errorf("failed to put %v", err) - } - - fd, _ = os.Open(upload) - _, err = up.Put(testPath+"/dir2/"+upload, fd, true, map[string]string{"Content-Type": "video/mp4"}) - if err != nil { - t.Errorf("failed to put %v", err) - } - - // put buf - b := bytes.NewReader([]byte("UPYUN GO SDK")) - _, err = up.Put(testPath+"/"+upload+".buf", b, false, nil) - if err != nil { - t.Errorf("failed to put %v", err) - } -} - -func TestGet(t *testing.T) { - fd, err = os.OpenFile(download, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0600) - if err != nil { - t.Skipf("failed to open %s %v", download, err) - } - - defer os.Remove(download) - - if length, err = up.Get(testPath+"/"+upload, fd); err != nil { - t.Errorf("failed to get %s %v", testPath+"/"+upload, err) - } - - if length != int(uploadSize) { - t.Errorf("size not equal %d != %d", length, uploadSize) - } +var up = NewUpYun(&UpYunConfig{ + Bucket: "prog-test", + Operator: "myworker", + Password: "tyghbnTYGHBN", +}) - dInfo, _ := fd.Stat() - if dInfo.Size() != uploadSize { - t.Errorf("size not equal %d != %d", dInfo.Size(), uploadSize) - } +func MakeTmpPath() string { + return "/go-sdk/123456789" } -func TestGetInfo(t *testing.T) { - if upInfo, err = up.GetInfo(testPath); err != nil { - t.Errorf("failed to GetInfo %s %v", testPath, err) - } - if upInfo.Type != "folder" { - t.Errorf("%s not folder", testPath) - } - - if upInfo, err = up.GetInfo(testPath + "/" + upload); err != nil { - t.Errorf("failed to GetInfo %s %v", testPath+"/"+upload, err) - } else { - if upInfo.Type != "file" { - t.Errorf("%s not file", testPath+"/"+upload) - } - if upInfo.Size != uploadSize { - t.Errorf("size not equal %d != %d", upInfo.Size, uploadSize) - } - } - - if upInfo, err = up.GetInfo(testPath + "/up"); upInfo != nil || err == nil { - t.Errorf("%s should not exist", testPath+"/up") +func Equal(t *testing.T, actual, expected interface{}) { + if !reflect.DeepEqual(actual, expected) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\tnexp: %#v\n\n\tgot: %#v\033[39m\n\n", + filepath.Base(file), line, expected, actual) + t.FailNow() } } -func TestGetList(t *testing.T) { - if upInfos, err = up.GetList(testPath); err != nil { - t.Errorf("failed to GetList %s %v", testPath, err) - } - - if len(upInfos) != 3 { - t.Errorf("failed to GetList %s %d != 3", testPath, len(upInfos)) - } -} - -func TestGetLargeList(t *testing.T) { - ch, _ := up.GetLargeList(testPath, false, false) - count := 0 - for { - var more bool - upInfo, more = <-ch - if !more { - break - } - count++ - } - if count != 3 { - t.Errorf("GetLargeList %d != 3", count) - } - - ch, _ = up.GetLargeList(testPath, true, true) - count = 0 - for { - var more bool - upInfo, more = <-ch - if !more { - break - } - count++ - } - if count != 4 { - t.Errorf("GetLargeList recursive %d != 4", count) +func NotEqual(t *testing.T, actual, expected interface{}) { + if reflect.DeepEqual(actual, expected) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\tnexp: %#v\n\n\tgot: %#v\033[39m\n\n", + filepath.Base(file), line, expected, actual) + t.FailNow() } } -func TestResumeSmallFile(t *testing.T) { - file, err := os.Open(upload) - if err != nil { - t.Error(err) - } - defer file.Close() - - md5Hash := md5.New() - io.Copy(md5Hash, file) - md5 := fmt.Sprintf("%x", md5Hash.Sum(nil)) - file.Seek(0, 0) - - _, err = up.ResumePut(testPath+"/"+upload, file, true, map[string]string{"Content-Type": "text/plain"}, nil) - if err != nil { - t.Error(err) - } - - _, err = up.GetInfo(testPath + "/" + upload) - if err != nil { - t.Error(err) - } - - buf := bytes.NewBuffer(make([]byte, 0, 1024)) - up.Get(testPath+"/"+upload, buf) - - md5Hash.Reset() - io.Copy(md5Hash, buf) - - if fmt.Sprintf("%x", md5Hash.Sum(nil)) != md5 { - t.Error("MD5 is inconsistent") +func Nil(t *testing.T, object interface{}) { + if !isNil(object) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\t (expected)\n\n\t!= %#v (actual)\033[39m\n\n", + filepath.Base(file), line, object) + t.FailNow() } } -func TestResumeBigFile(t *testing.T) { - file, err := os.OpenFile("/tmp/bigfile", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0666) - if err != nil { - t.Error(err) - } - defer os.Remove(file.Name()) - defer file.Close() - - for i := 0; i < 15*1024; i++ { - file.Write(bytes.Repeat([]byte("1"), 1024)) - } - file.Seek(0, 0) - - md5Hash := md5.New() - io.Copy(md5Hash, file) - md5 := fmt.Sprintf("%x", md5Hash.Sum(nil)) - file.Seek(0, 0) - - _, err = up.ResumePut(testPath+"/"+"bigfile", file, true, nil, ResumeReporterPrintln) - if err != nil { - t.Error(err) - } - - defer up.Delete(testPath + "/" + "bigfile") - - _, err = up.GetInfo(testPath + "/" + "bigfile") - if err != nil { - t.Error(err) - } - - buf := bytes.NewBuffer(make([]byte, 0, 1024)) - up.Get(testPath+"/"+"bigfile", buf) - - md5Hash.Reset() - io.Copy(md5Hash, buf) - if fmt.Sprintf("%x", md5Hash.Sum(nil)) != md5 { - t.Error("MD5 is inconsistent") +func NotNil(t *testing.T, object interface{}) { + if isNil(object) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\tExpected value not to be \033[39m\n\n", + filepath.Base(file), line, object) + t.FailNow() } } -func TestDelete(t *testing.T) { - // delete file - path := testPath + "/" + upload - if err = up.Delete(path); err != nil { - t.Errorf("failed to Delete %s %v", path, err) +func isNil(object interface{}) bool { + if object == nil { + return true } - path = testPath + "/" + upload + ".buf" - if err = up.Delete(path); err != nil { - t.Errorf("failed to Delete %s %v", path, err) + value := reflect.ValueOf(object) + kind := value.Kind() + if kind >= reflect.Chan && kind <= reflect.Slice && value.IsNil() { + return true } - path = testPath + "/dir2/" + upload - if err = up.Delete(path); err != nil { - t.Errorf("failed to Delete %s %v", path, err) - } - - // delete not empty folder - path = testPath - if err = up.Delete(path); err == nil { - t.Errorf("Delete no-empty folder should failed %s", path) - } - // delete empty folder - path = testPath + "/dir2" - if err = up.Delete(path); err != nil { - t.Errorf("failed to Delete empty folder %s %v", path, err) - } - - path = testPath - if err = up.Delete(path); err != nil { - t.Errorf("failed to Delete empty folder %s %v", path, err) - } -} - -func TestPurge(t *testing.T) { - var s string - s, err = up.Purge([]string{"http://www.baidu.com", - fmt.Sprintf("http://%s.b0.upaiyun.com/%s", up.Bucket, testPath+"/"+upload)}) - if err != nil { - t.Errorf("failed to Purge %v", err) - } + return false - if s != "http://www.baidu.com" { - t.Errorf("%s != baidu", s) - } } -func TestFormAPI(t *testing.T) { - formResp, err = upf.Put(upload, - testPath+"/upload_{filename}{.suffix}", 3600, nil) - if err != nil { - t.Errorf("failed to put %s %v", upload, err) - return - } - if err = up.Delete(formResp.Url); err == nil { - err = up.Delete(testPath) - } - if err != nil { - t.Errorf("failed to remove %s %v", formResp.Url, err) - } -} - -func TestMultiPart(t *testing.T) { - mergeResp, err = ump.Put(upload, testPath+"/multipart", 3600, nil) - if err != nil { - t.Errorf("failed to put %s %v", upload, err) - return - } - - if err = up.Delete(mergeResp.Path); err == nil { - err = up.Delete(testPath) - } - if err != nil { - t.Errorf("failed to remove %s %v", mergeResp.Path, err) +func TestMain(m *testing.M) { + clean := func() { + objs := make(chan *FileInfo, 20) + var wg sync.WaitGroup + wg.Add(1) + go func() { + for obj := range objs { + up.Delete(&DeleteObjectConfig{ + Path: path.Join(ROOT, obj.Name), + }) + } + up.Delete(&DeleteObjectConfig{ + Path: ROOT, + }) + wg.Done() + }() + + up.List(&GetObjectsConfig{ + Path: ROOT, + ObjectsChan: objs, + MaxListLevel: -1, + }) + wg.Wait() + + if _, err := up.GetInfo(ROOT); err == nil { + fmt.Println("Not cleanup") + os.Exit(-1) + } } -} -func TestMedia(t *testing.T) { - task := map[string]interface{}{ - "type": "thumbnail", - "thumb_single": true, - } - tasks := []map[string]interface{}{task} + flag.Parse() + code := m.Run() - if ids, err := upm.PostTasks("kai.3gp", "http://www.upyun.com/notify", "json", tasks); err != nil { - t.Errorf("failed to post tasks %v %v", tasks, err) - } else { - if _, err = upm.GetProgress(strings.Join(ids, ",")); err != nil { - t.Errorf("failed to get progress %v %v", ids, err) - } - } + clean() + os.Exit(code) } diff --git a/upyun/utils.go b/upyun/utils.go new file mode 100644 index 0000000..f3308e3 --- /dev/null +++ b/upyun/utils.go @@ -0,0 +1,128 @@ +package upyun + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "encoding/base64" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" +) + +func makeRFC1123Date(d time.Time) string { + utc := d.UTC().Format(time.RFC1123) + return strings.Replace(utc, "UTC", "GMT", -1) +} + +func makeUserAgent(version string) string { + return fmt.Sprintf("UPYUN Go SDK V2/%s", version) +} + +func md5Str(s string) string { + return fmt.Sprintf("%x", md5.Sum([]byte(s))) +} + +func base64ToStr(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} + +func hmacSha1(key string, data []byte) []byte { + hm := hmac.New(sha1.New, []byte(key)) + hm.Write(data) + return hm.Sum(nil) +} + +func escapeUri(uri string) (string, error) { + uri = path.Join("/", uri) + u, err := url.ParseRequestURI(uri) + if err != nil { + return "", err + } + return u.String(), nil +} + +var readHTTPBody = ioutil.ReadAll + +func readHTTPBodyToStr(resp *http.Response) (string, error) { + b, err := readHTTPBody(resp.Body) + resp.Body.Close() + if err != nil { + return "", fmt.Errorf("read http body: %v", err) + } + return string(b), nil +} + +func addQueryToUri(rawurl string, kwargs map[string]string) string { + u, _ := url.ParseRequestURI(rawurl) + q := u.Query() + for k, v := range kwargs { + q.Add(k, v) + } + u.RawQuery = q.Encode() + return u.String() +} + +func encodeQueryToPayload(kwargs map[string]string) string { + payload := url.Values{} + for k, v := range kwargs { + payload.Set(k, v) + } + return payload.Encode() +} + +func readHTTPBodyToInt(resp *http.Response) (int64, error) { + b, err := readHTTPBody(resp.Body) + resp.Body.Close() + if err != nil { + return 0, fmt.Errorf("read http body: %v", err) + } + + n, err := strconv.ParseInt(string(b), 10, 64) + if err != nil { + return 0, fmt.Errorf("parse int: %v", err) + } + return n, nil +} + +func parseStrToInt(s string) int64 { + n, _ := strconv.ParseInt(s, 10, 64) + return n +} + +func md5File(f io.ReadSeeker) (string, error) { + offset, _ := f.Seek(0, 0) + defer f.Seek(offset, 0) + hash := md5.New() + if _, err := io.Copy(hash, f); err != nil { + return "", err + } + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} + +func parseBodyToFileInfos(b []byte) (fInfos []*FileInfo) { + line := strings.Split(string(b), "\n") + for _, l := range line { + if len(l) == 0 { + continue + } + items := strings.Split(l, "\t") + if len(items) != 4 { + continue + } + + fInfos = append(fInfos, &FileInfo{ + Name: items[0], + IsDir: items[1] == "F", + Size: int64(parseStrToInt(items[2])), + Time: time.Unix(parseStrToInt(items[3]), 0), + }) + } + return +} From e062ef1d64109e6143d5e55da63d3a8dcde8fb77 Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Thu, 19 Jan 2017 09:56:13 +0000 Subject: [PATCH 2/8] add toc to readme --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d915a1b..39009b0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,39 @@ UPYUN Go SDK, 集成: - [又拍云缓存刷新接口](http://docs.upyun.com/api/purge/) - [又拍云视频处理接口](http://docs.upyun.com/api/av_pretreatment/) +Table of Contents +================= + + * [UPYUN Go SDK](#upyun-go-sdk) + * [Projects using this SDK](#projects-using-this-sdk) + * [Usage](#usage) + * [快速上手](#快速上手) + * [初始化 UpYun](#初始化-upyun) + * [又拍云 REST API 接口](#又拍云-rest-api-接口) + * [获取空间存储使用量](#获取空间存储使用量) + * [创建目录](#创建目录) + * [上传](#上传) + * [下载](#下载) + * [删除](#删除) + * [获取文件信息](#获取文件信息) + * [获取文件列表](#获取文件列表) + * [又拍云缓存刷新接口](#又拍云缓存刷新接口) + * [又拍云表单上传接口](#又拍云表单上传接口) + * [又拍云处理接口](#又拍云处理接口) + * [提交处理任务](#提交处理任务) + * [获取处理进度](#获取处理进度) + * [获取处理结果](#获取处理结果) + * [基本类型](#%E5%9F%BA%E6%9C%AC%E7%B1%BB%E5%9E%8B) + * [UpYun](#upyun) + * [FileInfo](#fileinfo) + * [FormUploadResp](#formuploadresp) + * [PutObjectConfig](#putobjectconfig) + * [GetObjectConfig](#getobjectconfig) + * [GetObjectsConfig](#getobjectsconfig) + * [DeleteObjectConfig](#deleteobjectconfig) + * [FormUploadConfig](#formuploadconfig) + * [CommitTasksConfig](#committasksconfig) + ## Projects using this SDK - [又拍云命令行工具](https://github.com/polym/upx) by [polym](https://github.com/polym) @@ -135,7 +168,7 @@ func (up *UpYun) FormUpload(config *FormUploadConfig) (*FormUploadResp, error) ### 又拍云处理接口 -#### 提供处理任务 +#### 提交处理任务 ```go func (up *UpYun) CommitTasks(config *CommitTasksConfig) (taskIds []string, err error) From 1d3deff4346a414f68c8e8cda0cd7e4ce7062b07 Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Thu, 19 Jan 2017 10:01:59 +0000 Subject: [PATCH 3/8] rm go1.3 support --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 315d014..b7c299c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ language: go go: - - 1.3 - 1.4 - 1.5 + - 1.6 + - 1.7 install: - go get github.com/upyun/go-sdk/upyun From c809928d0cc96fdf6a8764eed2099b4b81a6cc82 Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Fri, 20 Jan 2017 02:27:47 +0000 Subject: [PATCH 4/8] improve testcase --- upyun/upyun_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/upyun/upyun_test.go b/upyun/upyun_test.go index 37e4b01..a151be6 100644 --- a/upyun/upyun_test.go +++ b/upyun/upyun_test.go @@ -18,9 +18,10 @@ var ( ) var up = NewUpYun(&UpYunConfig{ - Bucket: "prog-test", - Operator: "myworker", - Password: "tyghbnTYGHBN", + Bucket: os.Getenv("UPYUN_BUCKET"), + Operator: os.Getenv("UPYUN_USERNAME"), + Password: os.Getenv("UPYUN_PASSWORD"), + Secret: os.Getenv("UPYUN_SECRET"), }) func MakeTmpPath() string { @@ -79,6 +80,11 @@ func isNil(object interface{}) bool { } func TestMain(m *testing.M) { + _, err := up.Usage() + if err != nil { + fmt.Println("failed to login. Have set UPYUN_BUCKET UPYUN_USERNAME UPYUN_PASSWORD UPYUN_SECRET?") + os.Exit(-1) + } clean := func() { objs := make(chan *FileInfo, 20) var wg sync.WaitGroup From 7cfb7667be8247bc838c82b13099a685810d704b Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Fri, 20 Jan 2017 03:14:40 +0000 Subject: [PATCH 5/8] fix typos --- README.md | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 39009b0..05328c3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ import "github.com/upyun/go-sdk/upyun" -UPYUN Go SDK, 集成: +又拍云 Go SDK, 集成: - [又拍云 HTTP REST 接口](http://docs.upyun.com/api/rest_api/) - [又拍云 HTTP FORM 接口](http://docs.upyun.com/api/form_api/) - [又拍云缓存刷新接口](http://docs.upyun.com/api/purge/) @@ -32,7 +32,7 @@ Table of Contents * [提交处理任务](#提交处理任务) * [获取处理进度](#获取处理进度) * [获取处理结果](#获取处理结果) - * [基本类型](#%E5%9F%BA%E6%9C%AC%E7%B1%BB%E5%9E%8B) + * [基本类型](#基本类型) * [UpYun](#upyun) * [FileInfo](#fileinfo) * [FormUploadResp](#formuploadresp) @@ -56,7 +56,7 @@ package main import ( "fmt" - "github.com/polym/new/upyun" + "github.com/upyun/go-sdk/upyun" ) func main() { @@ -199,13 +199,13 @@ type UpYunConfig struct { Bucket string // 云存储服务名(空间名) Operator string // 操作员 Password string // 密码 - Secret string // 表单上传密钥,已经弃用 + Secret string // 表单上传密钥,已经弃用! Hosts map[string]string // 自定义 Hosts 映射关系 - UserAgent string // HTTP User-Agent 头,默认 + UserAgent string // HTTP User-Agent 头,默认 "UPYUN Go SDK V2" } ``` -`UpYunConfig` 提供了初始化 `UpYun` 的参数。 需要注意的是,`Secret` 表单密钥已经弃用,如果一定需要使用,需调用 `Use` +`UpYunConfig` 提供初始化 `UpYun` 的所需参数。 需要注意的是,`Secret` 表单密钥已经弃用,如果一定需要使用,需调用 `UseDeprecatedApi`。 #### FileInfo @@ -220,12 +220,6 @@ type FileInfo struct { Time time.Time // 文件修改时间 Meta map[string]string // Metadata 数据 - - /* image information */ - ImgType string - ImgWidth int64 - ImgHeight int64 - ImgFrames int64 } ``` @@ -246,7 +240,7 @@ type FormUploadResp struct { } ``` -`FormUploadResp` 为表单上传的返回内容的格式。其中 `Code` 字段为状态码,可以查看 [API 错误码表](https://docs.upyun.com/api/errno/) +`FormUploadResp` 为表单上传的返回内容的格式。其中 `Code` 字段为状态码,可以查看 [API 错误码表](https://docs.upyun.com/api/errno/)。 #### PutObjectConfig @@ -255,21 +249,21 @@ type PutObjectConfig struct { Path string // 云存储中的路径 LocalPath string // 待上传文件在本地文件系统中的路径 Reader io.Reader // 待上传的内容 - Headers map[string]string // 请求额外的 HTTP 头 + Headers map[string]string // 额外的 HTTP 请求头 UseMD5 bool // 是否需要 MD5 校验 UseResumeUpload bool // 是否使用断点续传 - AppendContent bool // 是否是追加文件内容 + AppendContent bool // 是否需要追加文件内容 ResumePartSize int64 // 断点续传块大小 MaxResumePutTries int // 断点续传最大重试次数 } ``` `PutObjectConfig` 提供上传单个文件所需的参数。有几点需要注意: -- `LocalPath` 跟 `Reader` 是一个互斥的关系,如果设置了 `LocalPath`,SDK 就会去读取这个文件,而忽略 `Reader` 中的内容。 +- `LocalPath` 跟 `Reader` 是互斥的关系,如果设置了 `LocalPath`,SDK 就会去读取这个文件,而忽略 `Reader` 中的内容。 - 如果 `Reader` 是一个流/缓冲等的话,需要通过 `Headers` 参数设置 `Content-Length`,SDK 默认会对 `*os.File` 增加该字段。 -- [断点续传](https://docs.upyun.com/api/rest_api/#_3)的上传内容必须是 `*os.File`, 断点续传会将文件按照 `ResumePartSize` 进行切割,然后按次序一块一块上传,如果遇到网络问题,会进行重试,重试 `MaxResumePutTries` 次,默认无限重试。 +- [断点续传](https://docs.upyun.com/api/rest_api/#_3)的上传内容类型必须是 `*os.File`, 断点续传会将文件按照 `ResumePartSize` 进行切割,然后按次序一块一块上传,如果遇到网络问题,会进行重试,重试 `MaxResumePutTries` 次,默认无限重试。 - `AppendContent` 如果是追加文件的话,确保非最后的分片必须为 1M 的整数倍。 -- 如果需要 MD5 校验,SDK 对 `*os.File` 会自动计算 MD5 值,其他类型需要自行通过 `Headers` 参数设置 `Content-MD5` +- 如果需要 MD5 校验,SDK 对 `*os.File` 会自动计算 MD5 值,其他类型需要自行通过 `Headers` 参数设置 `Content-MD5`。 #### GetObjectConfig @@ -277,13 +271,13 @@ type PutObjectConfig struct { ```go type GetObjectConfig struct { Path string // 云存储中的路径 - Headers map[string]string // 请求额外的 HTTP 头 - LocalPath string // 文件本地保存路径 + Headers map[string]string // 额外的 HTTP 请求头 + LocalPath string // 本地文件路径 Writer io.Writer // 保存内容的容器 } ``` -`GetObjectConfig` 提供下载单个文件所需的参数。 跟 `PutObjectConfig` 类似,`LocalPath` 跟 `Writer` 是一个互斥的关系,如果设置了 `LocalPath`,SDK 就会把内容写入到这个文件中,而忽略 `Writer`。 +`GetObjectConfig` 提供下载单个文件所需的参数。 跟 `PutObjectConfig` 类似,`LocalPath` 跟 `Writer` 是互斥的关系,如果设置了 `LocalPath`,SDK 就会把内容写入到这个文件中,而忽略 `Writer`。 #### GetObjectsConfig @@ -291,19 +285,19 @@ type GetObjectConfig struct { ```go type GetObjectsConfig struct { Path string // 云存储中的路径 - Headers map[string]string // 请求额外的 HTTP 头 - ObjectsChan chan *FileInfo // 对象 Channel + Headers map[string]string // 额外的 HTTP 请求头 + ObjectsChan chan *FileInfo // 对象通道 QuitChan chan bool // 停止信号 MaxListObjects int // 最大列对象个数 MaxListTries int // 列目录最大重试次数 MaxListLevel int // 递归最大深度 - DescOrder bool // 是否按降序列取,默认为生序 + DescOrder bool // 是否按降序列取,默认为升序 // Has unexported fields. } ``` -`GetObjectsConfig` 提供列目录所需的参数。当列目录结束后,SDK 会将 `ObjectChan` 关闭掉。 +`GetObjectsConfig` 提供列目录所需的参数。当列目录结束后,SDK 会将 `ObjectsChan` 关闭掉。 #### DeleteObjectConfig From 7709d07a600d9f9dc39387c3e5f311835e3f98ed Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Mon, 23 Jan 2017 09:52:53 +0000 Subject: [PATCH 6/8] fix bug in List --- upyun/process.go | 6 --- upyun/rest.go | 105 ++++++++++++++++++++++++----------------------- 2 files changed, 54 insertions(+), 57 deletions(-) diff --git a/upyun/process.go b/upyun/process.go index 925254b..ff976ad 100644 --- a/upyun/process.go +++ b/upyun/process.go @@ -77,12 +77,6 @@ func (up *UpYun) doProcessRequest(method, uri string, if _, ok := kwargs["service"]; !ok { kwargs["service"] = up.Bucket } - if _, ok := kwargs["bucket"]; !ok { - kwargs["bucket"] = up.Bucket - } - if _, ok := kwargs["bucket_name"]; !ok { - kwargs["bucket_name"] = up.Bucket - } if method == "GET" { uri = addQueryToUri(uri, kwargs) diff --git a/upyun/rest.go b/upyun/rest.go index ddf4b84..24ef653 100644 --- a/upyun/rest.go +++ b/upyun/rest.go @@ -330,66 +330,69 @@ func (up *UpYun) List(config *GetObjectsConfig) error { } for { - select { - case <-config.QuitChan: - return nil - default: - resp, err := up.doRESTRequest(&restReqConfig{ - method: "GET", - uri: config.Path, - headers: config.Headers, - }) + resp, err := up.doRESTRequest(&restReqConfig{ + method: "GET", + uri: config.Path, + headers: config.Headers, + }) - if err != nil { - if _, ok := err.(net.Error); ok { - config.try++ - if config.MaxListTries == 0 || config.try < config.MaxListTries { - continue - } + if err != nil { + if _, ok := err.(net.Error); ok { + config.try++ + if config.MaxListTries == 0 || config.try < config.MaxListTries { + continue } - return err } - defer resp.Body.Close() + return err + } + defer resp.Body.Close() - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("ioutil ReadAll: %v", err) - } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("ioutil ReadAll: %v", err) + } - for _, fInfo := range parseBodyToFileInfos(b) { - config.objNum++ - if config.MaxListObjects > 0 && config.objNum >= config.MaxListObjects { - return nil - } else { - if fInfo.IsDir && (config.level+1 < config.MaxListLevel || config.MaxListLevel == -1) { - rConfig := &GetObjectsConfig{ - Path: path.Join(config.Path, fInfo.Name), - QuitChan: config.QuitChan, - ObjectsChan: config.ObjectsChan, - MaxListTries: config.MaxListTries, - MaxListObjects: config.MaxListObjects, - DescOrder: config.DescOrder, - MaxListLevel: config.MaxListLevel, - level: config.level + 1, - rootDir: path.Join(config.rootDir, fInfo.Name), - try: config.try, - objNum: config.objNum, - } - if err = up.List(rConfig); err != nil { - return err - } - config.try, config.objNum = rConfig.try, rConfig.objNum - } - if config.rootDir != "" { - fInfo.Name = path.Join(config.rootDir, fInfo.Name) - } - config.ObjectsChan <- fInfo + for _, fInfo := range parseBodyToFileInfos(b) { + if fInfo.IsDir && (config.level+1 < config.MaxListLevel || config.MaxListLevel == -1) { + rConfig := &GetObjectsConfig{ + Path: path.Join(config.Path, fInfo.Name), + QuitChan: config.QuitChan, + ObjectsChan: config.ObjectsChan, + MaxListTries: config.MaxListTries, + MaxListObjects: config.MaxListObjects, + DescOrder: config.DescOrder, + MaxListLevel: config.MaxListLevel, + level: config.level + 1, + rootDir: path.Join(config.rootDir, fInfo.Name), + try: config.try, + objNum: config.objNum, } + if err = up.List(rConfig); err != nil { + return err + } + config.try, config.objNum = rConfig.try, rConfig.objNum + } + if config.rootDir != "" { + fInfo.Name = path.Join(config.rootDir, fInfo.Name) + } + + select { + case <-config.QuitChan: + return nil + default: + config.ObjectsChan <- fInfo } - config.Headers["X-List-Iter"] = resp.Header.Get("X-Upyun-List-Iter") - if config.Headers["X-List-Iter"] == "g2gCZAAEbmV4dGQAA2VvZg" { + + config.objNum++ + if config.MaxListObjects > 0 && config.objNum >= config.MaxListObjects { return nil } + + } + + config.Headers["X-List-Iter"] = resp.Header.Get("X-Upyun-List-Iter") + if config.Headers["X-List-Iter"] == "g2gCZAAEbmV4dGQAA2VvZg" { + return nil } } } From 6aa1b0feecaed54ce99d4da6d61e201b3da36a48 Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Fri, 10 Feb 2017 06:59:42 +0000 Subject: [PATCH 7/8] improve testcases --- upyun/rest.go | 5 ++++- upyun/upyun_test.go | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/upyun/rest.go b/upyun/rest.go index 24ef653..d735c61 100644 --- a/upyun/rest.go +++ b/upyun/rest.go @@ -100,7 +100,8 @@ func (up *UpYun) Mkdir(path string) error { method: "POST", uri: path, headers: map[string]string{ - "folder": "true", + "folder": "true", + "x-upyun-folder": "true", }, closeBody: true, }) @@ -324,6 +325,8 @@ func (up *UpYun) List(config *GetObjectsConfig) error { config.Headers["X-List-Order"] = "desc" } + config.Headers["X-UpYun-Folder"] = "true" + // 1st level if config.level == 0 { defer close(config.ObjectsChan) diff --git a/upyun/upyun_test.go b/upyun/upyun_test.go index a151be6..d90e36e 100644 --- a/upyun/upyun_test.go +++ b/upyun/upyun_test.go @@ -10,11 +10,12 @@ import ( "runtime" "sync" "testing" + "time" ) var ( ROOT = MakeTmpPath() - NOTIFY_URL = "http://124.160.114.202:18989/echo?key=gosdk" + NOTIFY_URL = os.Getenv("UPYUN_NOTIFY") ) var up = NewUpYun(&UpYunConfig{ @@ -25,7 +26,7 @@ var up = NewUpYun(&UpYunConfig{ }) func MakeTmpPath() string { - return "/go-sdk/123456789" + return "/go-sdk/" + time.Now().String() } func Equal(t *testing.T, actual, expected interface{}) { From 1350b5cb49b0f06e3ce4d3bb6c158b2d1951755f Mon Sep 17 00:00:00 2001 From: "hongbo.mo" Date: Mon, 13 Feb 2017 09:55:04 +0000 Subject: [PATCH 8/8] fix possible fd leak --- upyun/rest.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upyun/rest.go b/upyun/rest.go index d735c61..b7e596e 100644 --- a/upyun/rest.go +++ b/upyun/rest.go @@ -348,9 +348,9 @@ func (up *UpYun) List(config *GetObjectsConfig) error { } return err } - defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() if err != nil { return fmt.Errorf("ioutil ReadAll: %v", err) }