From decea4a739979a403a582707116237d2705914e6 Mon Sep 17 00:00:00 2001 From: Noah Hsu Date: Fri, 2 Sep 2022 21:36:47 +0800 Subject: [PATCH] feat: add quark driver --- drivers/all.go | 1 + drivers/quark/driver.go | 239 +++++++++++++++++++++++++++++++++ drivers/quark/meta.go | 25 ++++ drivers/quark/types.go | 150 +++++++++++++++++++++ drivers/quark/util.go | 248 +++++++++++++++++++++++++++++++++++ drivers/teambition/driver.go | 2 +- drivers/teambition/util.go | 4 +- pkg/cookie/cookie.go | 59 +++++++++ 8 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 drivers/quark/driver.go create mode 100644 drivers/quark/meta.go create mode 100644 drivers/quark/types.go create mode 100644 drivers/quark/util.go create mode 100644 pkg/cookie/cookie.go diff --git a/drivers/all.go b/drivers/all.go index 318d3d46..60e22706 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -6,6 +6,7 @@ import ( _ "github.com/alist-org/alist/v3/drivers/local" _ "github.com/alist-org/alist/v3/drivers/onedrive" _ "github.com/alist-org/alist/v3/drivers/pikpak" + _ "github.com/alist-org/alist/v3/drivers/quark" _ "github.com/alist-org/alist/v3/drivers/teambition" _ "github.com/alist-org/alist/v3/drivers/virtual" ) diff --git a/drivers/quark/driver.go b/drivers/quark/driver.go new file mode 100644 index 00000000..12fc797c --- /dev/null +++ b/drivers/quark/driver.go @@ -0,0 +1,239 @@ +package quark + +import ( + "context" + "crypto/md5" + "crypto/sha1" + "encoding/hex" + "io" + "net/http" + "os" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/conf" + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/pkg/utils" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +type Quark struct { + model.Storage + Addition +} + +func (d *Quark) Config() driver.Config { + return config +} + +func (d *Quark) GetAddition() driver.Additional { + return d.Addition +} + +func (d *Quark) Init(ctx context.Context, storage model.Storage) error { + d.Storage = storage + err := utils.Json.UnmarshalFromString(d.Storage.Addition, &d.Addition) + if err != nil { + return err + } + _, err = d.request("/config", http.MethodGet, nil, nil) + return err +} + +func (d *Quark) Drop(ctx context.Context) error { + return nil +} + +func (d *Quark) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + files, err := d.GetFiles(dir.GetID()) + if err != nil { + return nil, err + } + objs := make([]model.Obj, len(files)) + for i := 0; i < len(files); i++ { + objs[i] = fileToObj(files[i]) + } + return objs, nil +} + +//func (d *Quark) Get(ctx context.Context, path string) (model.Obj, error) { +// // TODO this is optional +// return nil, errs.NotImplement +//} + +func (d *Quark) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + data := base.Json{ + "fids": []string{file.GetID()}, + } + var resp DownResp + _, err := d.request("/file/download", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, &resp) + if err != nil { + return nil, err + } + return &model.Link{ + URL: resp.Data[0].DownloadUrl, + Header: http.Header{ + "Cookie": []string{d.Cookie}, + "Referer": []string{"https://pan.quark.cn"}, + }, + }, nil +} + +func (d *Quark) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + data := base.Json{ + "dir_init_lock": false, + "dir_path": "", + "file_name": dirName, + "pdir_fid": parentDir.GetID(), + } + _, err := d.request("/file", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, nil) + return err +} + +func (d *Quark) Move(ctx context.Context, srcObj, dstDir model.Obj) error { + data := base.Json{ + "action_type": 1, + "exclude_fids": []string{}, + "filelist": []string{srcObj.GetID()}, + "to_pdir_fid": dstDir.GetID(), + } + _, err := d.request("/file/move", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, nil) + return err +} + +func (d *Quark) Rename(ctx context.Context, srcObj model.Obj, newName string) error { + data := base.Json{ + "fid": srcObj.GetID(), + "file_name": newName, + } + _, err := d.request("/file/rename", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, nil) + return err +} + +func (d *Quark) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { + return errs.NotSupport +} + +func (d *Quark) Remove(ctx context.Context, obj model.Obj) error { + data := base.Json{ + "action_type": 1, + "exclude_fids": []string{}, + "filelist": []string{obj.GetID()}, + } + _, err := d.request("/file/delete", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, nil) + return err +} + +func (d *Quark) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error { + var tempFile *os.File + var err error + if f, ok := stream.GetReadCloser().(*os.File); ok { + tempFile = f + } else { + tempFile, err = os.CreateTemp(conf.Conf.TempDir, "file-*") + if err != nil { + return err + } + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }() + _, err = io.Copy(tempFile, stream) + if err != nil { + return err + } + _, err = tempFile.Seek(0, io.SeekStart) + if err != nil { + return err + } + } + m := md5.New() + _, err = io.Copy(m, tempFile) + if err != nil { + return err + } + _, err = tempFile.Seek(0, io.SeekStart) + if err != nil { + return err + } + md5Str := hex.EncodeToString(m.Sum(nil)) + s := sha1.New() + _, err = io.Copy(s, tempFile) + if err != nil { + return err + } + _, err = tempFile.Seek(0, io.SeekStart) + if err != nil { + return err + } + sha1Str := hex.EncodeToString(s.Sum(nil)) + // pre + pre, err := d.upPre(stream, dstDir.GetID()) + if err != nil { + return err + } + log.Debugln("hash: ", md5Str, sha1Str) + // hash + finish, err := d.upHash(md5Str, sha1Str, pre.Data.TaskId) + if err != nil { + return err + } + if finish { + return nil + } + // part up + partSize := pre.Metadata.PartSize + var bytes []byte + md5s := make([]string, 0) + defaultBytes := make([]byte, partSize) + left := stream.GetSize() + partNumber := 1 + sizeDivide100 := stream.GetSize() / 100 + for left > 0 { + if left > int64(partSize) { + bytes = defaultBytes + } else { + bytes = make([]byte, left) + } + _, err := io.ReadFull(tempFile, bytes) + if err != nil { + return err + } + left -= int64(partSize) + log.Debugf("left: %d", left) + m, err := d.upPart(pre, stream.GetMimetype(), partNumber, bytes) + //m, err := driver.UpPart(pre, file.GetMIMEType(), partNumber, bytes, account, md5Str, sha1Str) + if err != nil { + return err + } + if m == "finish" { + return nil + } + md5s = append(md5s, m) + partNumber++ + up(100 - int(left/sizeDivide100)) + } + err = d.upCommit(pre, md5s) + if err != nil { + return err + } + return d.upFinish(pre) +} + +func (d *Quark) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { + return nil, errs.NotSupport +} + +var _ driver.Driver = (*Quark)(nil) diff --git a/drivers/quark/meta.go b/drivers/quark/meta.go new file mode 100644 index 00000000..52a55ed1 --- /dev/null +++ b/drivers/quark/meta.go @@ -0,0 +1,25 @@ +package quark + +import ( + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/op" +) + +type Addition struct { + Cookie string `json:"cookie" required:"true"` + driver.RootFolderID + OrderBy string `json:"order_by" type:"select" options:"file_type,file_name,updated_at" default:"file_name"` + OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"` +} + +var config = driver.Config{ + Name: "Quark", +} + +func New() driver.Driver { + return &Quark{} +} + +func init() { + op.RegisterDriver(config, New) +} diff --git a/drivers/quark/types.go b/drivers/quark/types.go new file mode 100644 index 00000000..afbdb3ef --- /dev/null +++ b/drivers/quark/types.go @@ -0,0 +1,150 @@ +package quark + +import ( + "time" + + "github.com/alist-org/alist/v3/internal/model" +) + +type Resp struct { + Status int `json:"status"` + Code int `json:"code"` + Message string `json:"message"` + //ReqId string `json:"req_id"` + //Timestamp int `json:"timestamp"` +} + +type File struct { + Fid string `json:"fid"` + FileName string `json:"file_name"` + //PdirFid string `json:"pdir_fid"` + //Category int `json:"category"` + //FileType int `json:"file_type"` + Size int64 `json:"size"` + //FormatType string `json:"format_type"` + //Status int `json:"status"` + //Tags string `json:"tags,omitempty"` + //LCreatedAt int64 `json:"l_created_at"` + LUpdatedAt int64 `json:"l_updated_at"` + //NameSpace int `json:"name_space"` + //IncludeItems int `json:"include_items,omitempty"` + //RiskType int `json:"risk_type"` + //BackupSign int `json:"backup_sign"` + //Duration int `json:"duration"` + //FileSource string `json:"file_source"` + File bool `json:"file"` + //CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + //PrivateExtra struct {} `json:"_private_extra"` + //ObjCategory string `json:"obj_category,omitempty"` + //Thumbnail string `json:"thumbnail,omitempty"` +} + +func fileToObj(f File) *model.Object { + return &model.Object{ + ID: f.Fid, + Name: f.FileName, + Size: f.Size, + Modified: time.UnixMilli(f.UpdatedAt), + IsFolder: !f.File, + } +} + +type SortResp struct { + Resp + Data struct { + List []File `json:"list"` + } `json:"data"` + Metadata struct { + Size int `json:"_size"` + Page int `json:"_page"` + Count int `json:"_count"` + Total int `json:"_total"` + Way string `json:"way"` + } `json:"metadata"` +} + +type DownResp struct { + Resp + Data []struct { + //Fid string `json:"fid"` + //FileName string `json:"file_name"` + //PdirFid string `json:"pdir_fid"` + //Category int `json:"category"` + //FileType int `json:"file_type"` + //Size int `json:"size"` + //FormatType string `json:"format_type"` + //Status int `json:"status"` + //Tags string `json:"tags"` + //LCreatedAt int64 `json:"l_created_at"` + //LUpdatedAt int64 `json:"l_updated_at"` + //NameSpace int `json:"name_space"` + //Thumbnail string `json:"thumbnail"` + DownloadUrl string `json:"download_url"` + //Md5 string `json:"md5"` + //RiskType int `json:"risk_type"` + //RangeSize int `json:"range_size"` + //BackupSign int `json:"backup_sign"` + //ObjCategory string `json:"obj_category"` + //Duration int `json:"duration"` + //FileSource string `json:"file_source"` + //File bool `json:"file"` + //CreatedAt int64 `json:"created_at"` + //UpdatedAt int64 `json:"updated_at"` + //PrivateExtra struct { + //} `json:"_private_extra"` + } `json:"data"` + //Metadata struct { + // Acc2 string `json:"acc2"` + // Acc1 string `json:"acc1"` + //} `json:"metadata"` +} + +type UpPreResp struct { + Resp + Data struct { + TaskId string `json:"task_id"` + Finish bool `json:"finish"` + UploadId string `json:"upload_id"` + ObjKey string `json:"obj_key"` + UploadUrl string `json:"upload_url"` + Fid string `json:"fid"` + Bucket string `json:"bucket"` + Callback struct { + CallbackUrl string `json:"callbackUrl"` + CallbackBody string `json:"callbackBody"` + } `json:"callback"` + FormatType string `json:"format_type"` + Size int `json:"size"` + AuthInfo string `json:"auth_info"` + } `json:"data"` + Metadata struct { + PartThread int `json:"part_thread"` + Acc2 string `json:"acc2"` + Acc1 string `json:"acc1"` + PartSize int `json:"part_size"` // 分片大小 + } `json:"metadata"` +} + +type HashResp struct { + Resp + Data struct { + Finish bool `json:"finish"` + Fid string `json:"fid"` + Thumbnail string `json:"thumbnail"` + FormatType string `json:"format_type"` + } `json:"data"` + Metadata struct { + } `json:"metadata"` +} + +type UpAuthResp struct { + Resp + Data struct { + AuthKey string `json:"auth_key"` + Speed int `json:"speed"` + Headers []interface{} `json:"headers"` + } `json:"data"` + Metadata struct { + } `json:"metadata"` +} diff --git a/drivers/quark/util.go b/drivers/quark/util.go new file mode 100644 index 00000000..45396904 --- /dev/null +++ b/drivers/quark/util.go @@ -0,0 +1,248 @@ +package quark + +import ( + "crypto/md5" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/pkg/cookie" + "github.com/alist-org/alist/v3/pkg/utils" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +// do others that not defined in Driver interface + +func (d *Quark) request(pathname string, method string, callback func(req *resty.Request), resp interface{}) ([]byte, error) { + u := "https://drive.quark.cn/1/clouddrive" + pathname + req := base.RestyClient.R() + req.SetHeaders(map[string]string{ + "Cookie": d.Cookie, + "Accept": "application/json, text/plain, */*", + "Referer": "https://pan.quark.cn/", + }) + req.SetQueryParam("pr", "ucpro") + req.SetQueryParam("fr", "pc") + if callback != nil { + callback(req) + } + if resp != nil { + req.SetResult(resp) + } + var e Resp + req.SetError(&e) + res, err := req.Execute(method, u) + if err != nil { + return nil, err + } + __puus := cookie.GetCookie(res.Cookies(), "__puus") + if __puus != nil { + d.Cookie = cookie.SetStr(d.Cookie, "__puus", __puus.Value) + op.MustSaveDriverStorage(d) + } + if e.Status >= 400 || e.Code != 0 { + return nil, errors.New(e.Message) + } + return res.Body(), nil +} + +func (d *Quark) GetFiles(parent string) ([]File, error) { + files := make([]File, 0) + page := 1 + size := 100 + query := map[string]string{ + "pdir_fid": parent, + "_size": strconv.Itoa(size), + "_fetch_total": "1", + "_sort": "file_type:asc," + d.OrderBy + ":" + d.OrderDirection, + } + for { + query["_page"] = strconv.Itoa(page) + var resp SortResp + _, err := d.request("/file/sort", http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(query) + }, &resp) + if err != nil { + return nil, err + } + files = append(files, resp.Data.List...) + if page*size >= resp.Metadata.Total { + break + } + page++ + } + return files, nil +} + +func (d *Quark) upPre(file model.FileStreamer, parentId string) (UpPreResp, error) { + now := time.Now() + data := base.Json{ + "ccp_hash_update": true, + "dir_name": "", + "file_name": file.GetName(), + "format_type": file.GetMimetype(), + "l_created_at": now.UnixMilli(), + "l_updated_at": now.UnixMilli(), + "pdir_fid": parentId, + "size": file.GetSize(), + } + var resp UpPreResp + _, err := d.request("/file/upload/pre", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, &resp) + return resp, err +} + +func (d *Quark) upHash(md5, sha1, taskId string) (bool, error) { + data := base.Json{ + "md5": md5, + "sha1": sha1, + "task_id": taskId, + } + log.Debugf("hash: %+v", data) + var resp HashResp + _, err := d.request("/file/update/hash", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, &resp) + return resp.Data.Finish, err +} + +func (d *Quark) upPart(pre UpPreResp, mineType string, partNumber int, bytes []byte) (string, error) { + //func (driver Quark) UpPart(pre UpPreResp, mineType string, partNumber int, bytes []byte, account *model.Account, md5Str, sha1Str string) (string, error) { + timeStr := time.Now().UTC().Format(http.TimeFormat) + data := base.Json{ + "auth_info": pre.Data.AuthInfo, + "auth_meta": fmt.Sprintf(`PUT + +%s +%s +x-oss-date:%s +x-oss-user-agent:aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit +/%s/%s?partNumber=%d&uploadId=%s`, + mineType, timeStr, timeStr, pre.Data.Bucket, pre.Data.ObjKey, partNumber, pre.Data.UploadId), + "task_id": pre.Data.TaskId, + } + var resp UpAuthResp + _, err := d.request("/file/upload/auth", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, &resp) + if err != nil { + return "", err + } + //if partNumber == 1 { + // finish, err := driver.UpHash(md5Str, sha1Str, pre.Data.TaskId, account) + // if err != nil { + // return "", err + // } + // if finish { + // return "finish", nil + // } + //} + u := fmt.Sprintf("https://%s.%s/%s", pre.Data.Bucket, pre.Data.UploadUrl[7:], pre.Data.ObjKey) + res, err := base.RestyClient.R(). + SetHeaders(map[string]string{ + "Authorization": resp.Data.AuthKey, + "Content-Type": mineType, + "Referer": "https://pan.quark.cn/", + "x-oss-date": timeStr, + "x-oss-user-agent": "aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit", + }). + SetQueryParams(map[string]string{ + "partNumber": strconv.Itoa(partNumber), + "uploadId": pre.Data.UploadId, + }).SetBody(bytes).Put(u) + if res.StatusCode() != 200 { + return "", fmt.Errorf("up status: %d, error: %s", res.StatusCode(), res.String()) + } + return res.Header().Get("ETag"), nil +} + +func (d *Quark) upCommit(pre UpPreResp, md5s []string) error { + timeStr := time.Now().UTC().Format(http.TimeFormat) + log.Debugf("md5s: %+v", md5s) + bodyBuilder := strings.Builder{} + bodyBuilder.WriteString(` + +`) + for i, m := range md5s { + bodyBuilder.WriteString(fmt.Sprintf(` +%d +%s + +`, i+1, m)) + } + bodyBuilder.WriteString("") + body := bodyBuilder.String() + m := md5.New() + m.Write([]byte(body)) + contentMd5 := base64.StdEncoding.EncodeToString(m.Sum(nil)) + callbackBytes, err := utils.Json.Marshal(pre.Data.Callback) + if err != nil { + return err + } + callbackBase64 := base64.StdEncoding.EncodeToString(callbackBytes) + data := base.Json{ + "auth_info": pre.Data.AuthInfo, + "auth_meta": fmt.Sprintf(`POST +%s +application/xml +%s +x-oss-callback:%s +x-oss-date:%s +x-oss-user-agent:aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit +/%s/%s?uploadId=%s`, + contentMd5, timeStr, callbackBase64, timeStr, + pre.Data.Bucket, pre.Data.ObjKey, pre.Data.UploadId), + "task_id": pre.Data.TaskId, + } + log.Debugf("xml: %s", body) + log.Debugf("auth data: %+v", data) + var resp UpAuthResp + _, err = d.request("/file/upload/auth", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, nil) + if err != nil { + return err + } + u := fmt.Sprintf("https://%s.%s/%s", pre.Data.Bucket, pre.Data.UploadUrl[7:], pre.Data.ObjKey) + res, err := base.RestyClient.R(). + SetHeaders(map[string]string{ + "Authorization": resp.Data.AuthKey, + "Content-MD5": contentMd5, + "Content-Type": "application/xml", + "Referer": "https://pan.quark.cn/", + "x-oss-callback": callbackBase64, + "x-oss-date": timeStr, + "x-oss-user-agent": "aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit", + }). + SetQueryParams(map[string]string{ + "uploadId": pre.Data.UploadId, + }).SetBody(body).Post(u) + if res.StatusCode() != 200 { + return fmt.Errorf("up status: %d, error: %s", res.StatusCode(), res.String()) + } + return nil +} + +func (d *Quark) upFinish(pre UpPreResp) error { + data := base.Json{ + "obj_key": pre.Data.ObjKey, + "task_id": pre.Data.TaskId, + } + _, err := d.request("/file/upload/finish", http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, nil) + if err != nil { + return err + } + time.Sleep(time.Second) + return nil +} diff --git a/drivers/teambition/driver.go b/drivers/teambition/driver.go index 90dc3168..37de3dc0 100644 --- a/drivers/teambition/driver.go +++ b/drivers/teambition/driver.go @@ -148,7 +148,7 @@ func (d *Teambition) Put(ctx context.Context, dstDir model.Obj, stream model.Fil } else { // chunk upload //err = base.ErrNotImplement - newFile, err = d.chunkUpload(stream, token) + newFile, err = d.chunkUpload(stream, token, up) } if err != nil { return err diff --git a/drivers/teambition/util.go b/drivers/teambition/util.go index 45bc9d9b..bd293be2 100644 --- a/drivers/teambition/util.go +++ b/drivers/teambition/util.go @@ -10,6 +10,7 @@ import ( "time" "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/driver" "github.com/alist-org/alist/v3/internal/model" "github.com/go-resty/resty/v2" log "github.com/sirupsen/logrus" @@ -134,7 +135,7 @@ func (d *Teambition) upload(file model.FileStreamer, token string) (*FileUpload, return &newFile, nil } -func (d *Teambition) chunkUpload(file model.FileStreamer, token string) (*FileUpload, error) { +func (d *Teambition) chunkUpload(file model.FileStreamer, token string, up driver.UpdateProgress) (*FileUpload, error) { prefix := "tcs" referer := "https://www.teambition.com/" if d.isInternational() { @@ -176,6 +177,7 @@ func (d *Teambition) chunkUpload(file model.FileStreamer, token string) (*FileUp if err != nil { return nil, err } + up(i * 100 / newChunk.Chunks) } _, err = base.RestyClient.R().SetHeader("Authorization", token).Post( fmt.Sprintf("https://%s.teambition.net/upload/chunk/%s", diff --git a/pkg/cookie/cookie.go b/pkg/cookie/cookie.go new file mode 100644 index 00000000..8a6ca859 --- /dev/null +++ b/pkg/cookie/cookie.go @@ -0,0 +1,59 @@ +package cookie + +import ( + "net/http" + "strings" +) + +func Parse(str string) []*http.Cookie { + header := http.Header{} + header.Add("Cookie", str) + request := http.Request{Header: header} + return request.Cookies() +} + +func ToString(cookies []*http.Cookie) string { + if cookies == nil { + return "" + } + cookieStrings := make([]string, len(cookies)) + for i, cookie := range cookies { + cookieStrings[i] = cookie.String() + } + return strings.Join(cookieStrings, ";") +} + +func SetCookie(cookies []*http.Cookie, name, value string) []*http.Cookie { + for i, cookie := range cookies { + if cookie.Name == name { + cookies[i].Value = value + return cookies + } + } + cookies = append(cookies, &http.Cookie{Name: name, Value: value}) + return cookies +} + +func GetCookie(cookies []*http.Cookie, name string) *http.Cookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil +} + +func SetStr(cookiesStr, name, value string) string { + cookies := Parse(cookiesStr) + cookies = SetCookie(cookies, name, value) + return ToString(cookies) +} + +func GetStr(cookiesStr, name string) string { + cookies := Parse(cookiesStr) + cookie := GetCookie(cookies, name) + if cookie == nil { + return "" + } + return cookie.Value +}