mirror of https://github.com/Xhofe/alist
feat: add `baidu_netdisk` driver
parent
decea4a739
commit
611457c0e7
|
@ -62,7 +62,7 @@ func (d *Pan123) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
|
|||
}
|
||||
|
||||
//func (d *Pan123) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
// // TODO this is optional
|
||||
// // this is optional
|
||||
// return nil, errs.NotImplement
|
||||
//}
|
||||
|
||||
|
|
|
@ -30,6 +30,10 @@ type File struct {
|
|||
DownloadUrl string `json:"DownloadUrl"`
|
||||
}
|
||||
|
||||
func (f File) GetPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f File) GetSize() int64 {
|
||||
return f.Size
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ func (d *Pan123) login() error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (d *Pan123) request(url string, method string, callback func(*resty.Request), resp interface{}) ([]byte, error) {
|
||||
func (d *Pan123) request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeader("Authorization", "Bearer "+d.AccessToken)
|
||||
if callback != nil {
|
||||
|
|
|
@ -34,7 +34,7 @@ func (d *AliDrive) refreshToken() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (d *AliDrive) request(url, method string, callback func(*resty.Request), resp interface{}) ([]byte, error, RespErr) {
|
||||
func (d *AliDrive) request(url, method string, callback base.ReqCallback, resp interface{}) ([]byte, error, RespErr) {
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeader("Authorization", "Bearer\t"+d.AccessToken)
|
||||
req.SetHeader("content-type", "application/json")
|
||||
|
|
|
@ -3,6 +3,7 @@ package drivers
|
|||
import (
|
||||
_ "github.com/alist-org/alist/v3/drivers/123"
|
||||
_ "github.com/alist-org/alist/v3/drivers/aliyundrive"
|
||||
_ "github.com/alist-org/alist/v3/drivers/baidu_netdisk"
|
||||
_ "github.com/alist-org/alist/v3/drivers/local"
|
||||
_ "github.com/alist-org/alist/v3/drivers/onedrive"
|
||||
_ "github.com/alist-org/alist/v3/drivers/pikpak"
|
||||
|
|
|
@ -0,0 +1,252 @@
|
|||
package baidu_netdisk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
stdpath "path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type BaiduNetdisk struct {
|
||||
model.Storage
|
||||
Addition
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) Config() driver.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) GetAddition() driver.Additional {
|
||||
return d.Addition
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) 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
|
||||
}
|
||||
return d.refreshToken()
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) Drop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
files, err := d.getFiles(dir.GetPath())
|
||||
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 *BaiduNetdisk) Get(ctx context.Context, path string) (model.Obj, error) {
|
||||
// // this is optional
|
||||
// return nil, errs.NotImplement
|
||||
//}
|
||||
|
||||
func (d *BaiduNetdisk) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
if d.DownloadAPI == "crack" {
|
||||
return d.linkCrack(file, args)
|
||||
}
|
||||
return d.linkOfficial(file, args)
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
_, err := d.create(stdpath.Join(parentDir.GetPath(), dirName), 0, 1, "", "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
data := []base.Json{
|
||||
{
|
||||
"path": srcObj.GetPath(),
|
||||
"dest": dstDir.GetPath(),
|
||||
"newname": srcObj.GetName(),
|
||||
},
|
||||
}
|
||||
_, err := d.manage("move", data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||
data := []base.Json{
|
||||
{
|
||||
"path": srcObj.GetPath(),
|
||||
"newname": newName,
|
||||
},
|
||||
}
|
||||
_, err := d.manage("rename", data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
dest, newname := stdpath.Split(dstDir.GetPath())
|
||||
data := []base.Json{
|
||||
{
|
||||
"path": srcObj.GetPath(),
|
||||
"dest": dest,
|
||||
"newname": newname,
|
||||
},
|
||||
}
|
||||
_, err := d.manage("copy", data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) Remove(ctx context.Context, obj model.Obj) error {
|
||||
data := []string{obj.GetPath()}
|
||||
_, err := d.manage("delete", data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) 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
|
||||
}
|
||||
}
|
||||
var Default int64 = 4 * 1024 * 1024
|
||||
defaultByteData := make([]byte, Default)
|
||||
count := int(math.Ceil(float64(stream.GetSize()) / float64(Default)))
|
||||
var SliceSize int64 = 256 * 1024
|
||||
// cal md5
|
||||
h1 := md5.New()
|
||||
h2 := md5.New()
|
||||
block_list := make([]string, 0)
|
||||
content_md5 := ""
|
||||
slice_md5 := ""
|
||||
left := stream.GetSize()
|
||||
for i := 0; i < count; i++ {
|
||||
byteSize := Default
|
||||
var byteData []byte
|
||||
if left < Default {
|
||||
byteSize = left
|
||||
byteData = make([]byte, byteSize)
|
||||
} else {
|
||||
byteData = defaultByteData
|
||||
}
|
||||
left -= byteSize
|
||||
_, err = io.ReadFull(tempFile, byteData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h1.Write(byteData)
|
||||
h2.Write(byteData)
|
||||
block_list = append(block_list, fmt.Sprintf("\"%s\"", hex.EncodeToString(h2.Sum(nil))))
|
||||
h2.Reset()
|
||||
}
|
||||
content_md5 = hex.EncodeToString(h1.Sum(nil))
|
||||
_, err = tempFile.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stream.GetSize() <= SliceSize {
|
||||
slice_md5 = content_md5
|
||||
} else {
|
||||
sliceData := make([]byte, SliceSize)
|
||||
_, err = io.ReadFull(tempFile, sliceData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h2.Write(sliceData)
|
||||
slice_md5 = hex.EncodeToString(h2.Sum(nil))
|
||||
_, err = tempFile.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
path := encodeURIComponent(stdpath.Join(dstDir.GetPath(), stream.GetName()))
|
||||
block_list_str := fmt.Sprintf("[%s]", strings.Join(block_list, ","))
|
||||
data := fmt.Sprintf("path=%s&size=%d&isdir=0&autoinit=1&block_list=%s&content-md5=%s&slice-md5=%s",
|
||||
path, stream.GetSize(),
|
||||
block_list_str,
|
||||
content_md5, slice_md5)
|
||||
params := map[string]string{
|
||||
"method": "precreate",
|
||||
}
|
||||
var precreateResp PrecreateResp
|
||||
_, err = d.post("/xpan/file", params, data, &precreateResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debugf("%+v", precreateResp)
|
||||
if precreateResp.ReturnType == 2 {
|
||||
return nil
|
||||
}
|
||||
params = map[string]string{
|
||||
"method": "upload",
|
||||
"access_token": d.AccessToken,
|
||||
"type": "tmpfile",
|
||||
"path": path,
|
||||
"uploadid": precreateResp.Uploadid,
|
||||
}
|
||||
left = stream.GetSize()
|
||||
for _, partseq := range precreateResp.BlockList {
|
||||
byteSize := Default
|
||||
var byteData []byte
|
||||
if left < Default {
|
||||
byteSize = left
|
||||
byteData = make([]byte, byteSize)
|
||||
} else {
|
||||
byteData = defaultByteData
|
||||
}
|
||||
left -= byteSize
|
||||
_, err = io.ReadFull(tempFile, byteData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := "https://d.pcs.baidu.com/rest/2.0/pcs/superfile2"
|
||||
params["partseq"] = strconv.Itoa(partseq)
|
||||
res, err := base.RestyClient.R().SetQueryParams(params).SetFileReader("file", stream.GetName(), bytes.NewReader(byteData)).Post(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debugln(res.String())
|
||||
}
|
||||
_, err = d.create(path, stream.GetSize(), 0, precreateResp.Uploadid, block_list_str)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
|
||||
return nil, errs.NotSupport
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*BaiduNetdisk)(nil)
|
|
@ -0,0 +1,35 @@
|
|||
package baidu_netdisk
|
||||
|
||||
import (
|
||||
"github.com/alist-org/alist/v3/internal/driver"
|
||||
"github.com/alist-org/alist/v3/internal/op"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
RefreshToken string `json:"refresh_token" required:"true"`
|
||||
driver.RootFolderPath
|
||||
OrderBy string `json:"order_by" type:"select" options:"name,time,size" default:"name"`
|
||||
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
|
||||
DownloadAPI string `json:"download_api" type:"select" options:"official,crack" default:"official"`
|
||||
ClientID string `json:"client_id" required:"true" default:"iYCeC9g08h5vuP9UqvPHKKSVrKFXGa1v"`
|
||||
ClientSecret string `json:"client_secret" required:"true" default:"jXiFMOPVPCWlO2M5CwWQzffpNPaGTRBG"`
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "BaiduNetdisk",
|
||||
LocalSort: false,
|
||||
OnlyLocal: false,
|
||||
OnlyProxy: false,
|
||||
NoCache: false,
|
||||
NoUpload: false,
|
||||
NeedMs: false,
|
||||
DefaultRoot: "root, / or other",
|
||||
}
|
||||
|
||||
func New() driver.Driver {
|
||||
return &BaiduNetdisk{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
op.RegisterDriver(config, New)
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
package baidu_netdisk
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alist-org/alist/v3/internal/model"
|
||||
)
|
||||
|
||||
type TokenErrResp struct {
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
//TkbindId int `json:"tkbind_id"`
|
||||
//OwnerType int `json:"owner_type"`
|
||||
//Category int `json:"category"`
|
||||
//RealCategory string `json:"real_category"`
|
||||
FsId int64 `json:"fs_id"`
|
||||
ServerMtime int64 `json:"server_mtime"`
|
||||
//OperId int `json:"oper_id"`
|
||||
//ServerCtime int `json:"server_ctime"`
|
||||
Thumbs struct {
|
||||
//Icon string `json:"icon"`
|
||||
Url3 string `json:"url3"`
|
||||
//Url2 string `json:"url2"`
|
||||
//Url1 string `json:"url1"`
|
||||
} `json:"thumbs"`
|
||||
//Wpfile int `json:"wpfile"`
|
||||
//LocalMtime int `json:"local_mtime"`
|
||||
Size int64 `json:"size"`
|
||||
//ExtentTinyint7 int `json:"extent_tinyint7"`
|
||||
Path string `json:"path"`
|
||||
//Share int `json:"share"`
|
||||
//ServerAtime int `json:"server_atime"`
|
||||
//Pl int `json:"pl"`
|
||||
//LocalCtime int `json:"local_ctime"`
|
||||
ServerFilename string `json:"server_filename"`
|
||||
//Md5 string `json:"md5"`
|
||||
//OwnerId int `json:"owner_id"`
|
||||
//Unlist int `json:"unlist"`
|
||||
Isdir int `json:"isdir"`
|
||||
}
|
||||
|
||||
func fileToObj(f File) *model.ObjThumb {
|
||||
return &model.ObjThumb{
|
||||
Object: model.Object{
|
||||
ID: strconv.FormatInt(f.FsId, 10),
|
||||
Name: f.ServerFilename,
|
||||
Size: f.Size,
|
||||
Modified: time.Unix(f.ServerMtime, 0),
|
||||
IsFolder: f.Isdir == 1,
|
||||
},
|
||||
Thumbnail: model.Thumbnail{Thumbnail: f.Thumbs.Url3},
|
||||
}
|
||||
}
|
||||
|
||||
type ListResp struct {
|
||||
Errno int `json:"errno"`
|
||||
GuidInfo string `json:"guid_info"`
|
||||
List []File `json:"list"`
|
||||
RequestId int64 `json:"request_id"`
|
||||
Guid int `json:"guid"`
|
||||
}
|
||||
|
||||
type DownloadResp struct {
|
||||
Errmsg string `json:"errmsg"`
|
||||
Errno int `json:"errno"`
|
||||
List []struct {
|
||||
//Category int `json:"category"`
|
||||
//DateTaken int `json:"date_taken,omitempty"`
|
||||
Dlink string `json:"dlink"`
|
||||
//Filename string `json:"filename"`
|
||||
//FsId int64 `json:"fs_id"`
|
||||
//Height int `json:"height,omitempty"`
|
||||
//Isdir int `json:"isdir"`
|
||||
//Md5 string `json:"md5"`
|
||||
//OperId int `json:"oper_id"`
|
||||
//Path string `json:"path"`
|
||||
//ServerCtime int `json:"server_ctime"`
|
||||
//ServerMtime int `json:"server_mtime"`
|
||||
//Size int `json:"size"`
|
||||
//Thumbs struct {
|
||||
// Icon string `json:"icon,omitempty"`
|
||||
// Url1 string `json:"url1,omitempty"`
|
||||
// Url2 string `json:"url2,omitempty"`
|
||||
// Url3 string `json:"url3,omitempty"`
|
||||
//} `json:"thumbs"`
|
||||
//Width int `json:"width,omitempty"`
|
||||
} `json:"list"`
|
||||
//Names struct {
|
||||
//} `json:"names"`
|
||||
RequestId string `json:"request_id"`
|
||||
}
|
||||
|
||||
type DownloadResp2 struct {
|
||||
Errno int `json:"errno"`
|
||||
Info []struct {
|
||||
//ExtentTinyint4 int `json:"extent_tinyint4"`
|
||||
//ExtentTinyint1 int `json:"extent_tinyint1"`
|
||||
//Bitmap string `json:"bitmap"`
|
||||
//Category int `json:"category"`
|
||||
//Isdir int `json:"isdir"`
|
||||
//Videotag int `json:"videotag"`
|
||||
Dlink string `json:"dlink"`
|
||||
//OperID int64 `json:"oper_id"`
|
||||
//PathMd5 int `json:"path_md5"`
|
||||
//Wpfile int `json:"wpfile"`
|
||||
//LocalMtime int `json:"local_mtime"`
|
||||
/*Thumbs struct {
|
||||
Icon string `json:"icon"`
|
||||
URL3 string `json:"url3"`
|
||||
URL2 string `json:"url2"`
|
||||
URL1 string `json:"url1"`
|
||||
} `json:"thumbs"`*/
|
||||
//PlaySource int `json:"play_source"`
|
||||
//Share int `json:"share"`
|
||||
//FileKey string `json:"file_key"`
|
||||
//Errno int `json:"errno"`
|
||||
//LocalCtime int `json:"local_ctime"`
|
||||
//Rotate int `json:"rotate"`
|
||||
//Metadata time.Time `json:"metadata"`
|
||||
//Height int `json:"height"`
|
||||
//SampleRate int `json:"sample_rate"`
|
||||
//Width int `json:"width"`
|
||||
//OwnerType int `json:"owner_type"`
|
||||
//Privacy int `json:"privacy"`
|
||||
//ExtentInt3 int64 `json:"extent_int3"`
|
||||
//RealCategory string `json:"real_category"`
|
||||
//SrcLocation string `json:"src_location"`
|
||||
//MetaInfo string `json:"meta_info"`
|
||||
//ID string `json:"id"`
|
||||
//Duration int `json:"duration"`
|
||||
//FileSize string `json:"file_size"`
|
||||
//Channels int `json:"channels"`
|
||||
//UseSegment int `json:"use_segment"`
|
||||
//ServerCtime int `json:"server_ctime"`
|
||||
//Resolution string `json:"resolution"`
|
||||
//OwnerID int `json:"owner_id"`
|
||||
//ExtraInfo string `json:"extra_info"`
|
||||
//Size int `json:"size"`
|
||||
//FsID int64 `json:"fs_id"`
|
||||
//ExtentTinyint3 int `json:"extent_tinyint3"`
|
||||
//Md5 string `json:"md5"`
|
||||
//Path string `json:"path"`
|
||||
//FrameRate int `json:"frame_rate"`
|
||||
//ExtentTinyint2 int `json:"extent_tinyint2"`
|
||||
//ServerFilename string `json:"server_filename"`
|
||||
//ServerMtime int `json:"server_mtime"`
|
||||
//TkbindID int `json:"tkbind_id"`
|
||||
} `json:"info"`
|
||||
RequestID int64 `json:"request_id"`
|
||||
}
|
||||
|
||||
type PrecreateResp struct {
|
||||
Path string `json:"path"`
|
||||
Uploadid string `json:"uploadid"`
|
||||
ReturnType int `json:"return_type"`
|
||||
BlockList []int `json:"block_list"`
|
||||
Errno int `json:"errno"`
|
||||
RequestId int64 `json:"request_id"`
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
package baidu_netdisk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alist-org/alist/v3/drivers/base"
|
||||
"github.com/alist-org/alist/v3/internal/errs"
|
||||
"github.com/alist-org/alist/v3/internal/model"
|
||||
"github.com/alist-org/alist/v3/internal/op"
|
||||
"github.com/alist-org/alist/v3/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// do others that not defined in Driver interface
|
||||
|
||||
func (d *BaiduNetdisk) refreshToken() error {
|
||||
err := d._refreshToken()
|
||||
if err != nil && err == errs.EmptyToken {
|
||||
err = d._refreshToken()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) _refreshToken() error {
|
||||
u := "https://openapi.baidu.com/oauth/2.0/token"
|
||||
var resp base.TokenResp
|
||||
var e TokenErrResp
|
||||
_, err := base.RestyClient.R().SetResult(&resp).SetError(&e).SetQueryParams(map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": d.RefreshToken,
|
||||
"client_id": d.ClientID,
|
||||
"client_secret": d.ClientSecret,
|
||||
}).Get(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.Error != "" {
|
||||
return fmt.Errorf("%s : %s", e.Error, e.ErrorDescription)
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
return errs.EmptyToken
|
||||
}
|
||||
d.AccessToken, d.RefreshToken = resp.AccessToken, resp.RefreshToken
|
||||
op.MustSaveDriverStorage(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) request(furl string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
req := base.RestyClient.R()
|
||||
req.SetQueryParam("access_token", d.AccessToken)
|
||||
if callback != nil {
|
||||
callback(req)
|
||||
}
|
||||
if resp != nil {
|
||||
req.SetResult(resp)
|
||||
}
|
||||
res, err := req.Execute(method, furl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
errno := utils.Json.Get(res.Body(), "errno").ToInt()
|
||||
if errno != 0 {
|
||||
if errno == -6 {
|
||||
err = d.refreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.request(furl, method, callback, resp)
|
||||
}
|
||||
return nil, fmt.Errorf("errno: %d, refer to https://pan.baidu.com/union/doc/", errno)
|
||||
}
|
||||
return res.Body(), nil
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) get(pathname string, params map[string]string, resp interface{}) ([]byte, error) {
|
||||
return d.request("https://pan.baidu.com/rest/2.0"+pathname, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParams(params)
|
||||
}, resp)
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) post(pathname string, params map[string]string, data interface{}, resp interface{}) ([]byte, error) {
|
||||
return d.request("https://pan.baidu.com/rest/2.0"+pathname, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetQueryParams(params)
|
||||
req.SetBody(data)
|
||||
}, resp)
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) getFiles(dir string) ([]File, error) {
|
||||
start := 0
|
||||
limit := 200
|
||||
params := map[string]string{
|
||||
"method": "list",
|
||||
"dir": dir,
|
||||
"web": "web",
|
||||
}
|
||||
if d.OrderBy != "" {
|
||||
params["order"] = d.OrderBy
|
||||
if d.OrderDirection == "desc" {
|
||||
params["desc"] = "1"
|
||||
}
|
||||
}
|
||||
res := make([]File, 0)
|
||||
for {
|
||||
params["start"] = strconv.Itoa(start)
|
||||
params["limit"] = strconv.Itoa(limit)
|
||||
start += limit
|
||||
var resp ListResp
|
||||
_, err := d.get("/xpan/file", params, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.List) == 0 {
|
||||
break
|
||||
}
|
||||
res = append(res, resp.List...)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) linkOfficial(file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
var resp DownloadResp
|
||||
params := map[string]string{
|
||||
"method": "filemetas",
|
||||
"fsids": fmt.Sprintf("[%s]", file.GetID()),
|
||||
"dlink": "1",
|
||||
}
|
||||
_, err := d.get("/xpan/multimedia", params, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := fmt.Sprintf("%s&access_token=%s", resp.List[0].Dlink, d.AccessToken)
|
||||
res, err := base.NoRedirectClient.R().SetHeader("User-Agent", "pan.baidu.com").Head(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//if res.StatusCode() == 302 {
|
||||
u = res.Header().Get("location")
|
||||
//}
|
||||
return &model.Link{
|
||||
URL: u,
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"pan.baidu.com"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) linkCrack(file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
var resp DownloadResp2
|
||||
param := map[string]string{
|
||||
"target": fmt.Sprintf("[\"%s\"]", file.GetPath()),
|
||||
"dlink": "1",
|
||||
"web": "5",
|
||||
"origin": "dlna",
|
||||
}
|
||||
_, err := d.get("https://pan.baidu.com/api/filemetas", param, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.Link{
|
||||
URL: resp.Info[0].Dlink,
|
||||
Header: http.Header{
|
||||
"User-Agent": []string{"pan.baidu.com"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) manage(opera string, filelist interface{}) ([]byte, error) {
|
||||
params := map[string]string{
|
||||
"method": "filemanager",
|
||||
"opera": opera,
|
||||
}
|
||||
marshal, err := utils.Json.Marshal(filelist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := fmt.Sprintf("async=0&filelist=%s&ondup=newcopy", string(marshal))
|
||||
return d.post("/xpan/file", params, data, nil)
|
||||
}
|
||||
|
||||
func (d *BaiduNetdisk) create(path string, size int64, isdir int, uploadid, block_list string) ([]byte, error) {
|
||||
params := map[string]string{
|
||||
"method": "create",
|
||||
}
|
||||
data := fmt.Sprintf("path=%s&size=%d&isdir=%d", path, size, isdir)
|
||||
if uploadid != "" {
|
||||
data += fmt.Sprintf("&uploadid=%s&block_list=%s", uploadid, block_list)
|
||||
}
|
||||
return d.post("/xpan/file", params, data, nil)
|
||||
}
|
||||
|
||||
func encodeURIComponent(str string) string {
|
||||
r := url.QueryEscape(str)
|
||||
r = strings.ReplaceAll(r, "+", "%20")
|
||||
return r
|
||||
}
|
|
@ -1,8 +1,12 @@
|
|||
package base
|
||||
|
||||
import "github.com/go-resty/resty/v2"
|
||||
|
||||
type Json map[string]interface{}
|
||||
|
||||
type TokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type ReqCallback func(req *resty.Request)
|
||||
|
|
|
@ -62,7 +62,7 @@ func (d *Local) GetAddition() driver.Additional {
|
|||
}
|
||||
|
||||
func (d *Local) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
fullPath := dir.GetID()
|
||||
fullPath := dir.GetPath()
|
||||
rawFiles, err := ioutil.ReadDir(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -103,7 +103,7 @@ func (d *Local) Get(ctx context.Context, path string) (model.Obj, error) {
|
|||
return nil, err
|
||||
}
|
||||
file := model.Object{
|
||||
ID: path,
|
||||
Path: path,
|
||||
Name: f.Name(),
|
||||
Modified: f.ModTime(),
|
||||
Size: f.Size(),
|
||||
|
@ -113,7 +113,7 @@ func (d *Local) Get(ctx context.Context, path string) (model.Obj, error) {
|
|||
}
|
||||
|
||||
func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
fullPath := file.GetID()
|
||||
fullPath := file.GetPath()
|
||||
var link model.Link
|
||||
if args.Type == "thumb" && utils.Ext(file.GetName()) != "svg" {
|
||||
imgData, err := ioutil.ReadFile(fullPath)
|
||||
|
@ -143,7 +143,7 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
|
|||
}
|
||||
|
||||
func (d *Local) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
fullPath := filepath.Join(parentDir.GetID(), dirName)
|
||||
fullPath := filepath.Join(parentDir.GetPath(), dirName)
|
||||
err := os.MkdirAll(fullPath, 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -152,8 +152,8 @@ func (d *Local) MakeDir(ctx context.Context, parentDir model.Obj, dirName string
|
|||
}
|
||||
|
||||
func (d *Local) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
srcPath := srcObj.GetID()
|
||||
dstPath := filepath.Join(dstDir.GetID(), srcObj.GetName())
|
||||
srcPath := srcObj.GetPath()
|
||||
dstPath := filepath.Join(dstDir.GetPath(), srcObj.GetName())
|
||||
err := os.Rename(srcPath, dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -162,7 +162,7 @@ func (d *Local) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
|||
}
|
||||
|
||||
func (d *Local) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||
srcPath := srcObj.GetID()
|
||||
srcPath := srcObj.GetPath()
|
||||
dstPath := filepath.Join(filepath.Dir(srcPath), newName)
|
||||
err := os.Rename(srcPath, dstPath)
|
||||
if err != nil {
|
||||
|
@ -172,8 +172,8 @@ func (d *Local) Rename(ctx context.Context, srcObj model.Obj, newName string) er
|
|||
}
|
||||
|
||||
func (d *Local) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
srcPath := srcObj.GetID()
|
||||
dstPath := filepath.Join(dstDir.GetID(), srcObj.GetName())
|
||||
srcPath := srcObj.GetPath()
|
||||
dstPath := filepath.Join(dstDir.GetPath(), srcObj.GetName())
|
||||
var err error
|
||||
if srcObj.IsDir() {
|
||||
err = copyDir(srcPath, dstPath)
|
||||
|
@ -189,9 +189,9 @@ func (d *Local) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
|||
func (d *Local) Remove(ctx context.Context, obj model.Obj) error {
|
||||
var err error
|
||||
if obj.IsDir() {
|
||||
err = os.RemoveAll(obj.GetID())
|
||||
err = os.RemoveAll(obj.GetPath())
|
||||
} else {
|
||||
err = os.Remove(obj.GetID())
|
||||
err = os.Remove(obj.GetPath())
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -200,7 +200,7 @@ func (d *Local) Remove(ctx context.Context, obj model.Obj) error {
|
|||
}
|
||||
|
||||
func (d *Local) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
|
||||
fullPath := filepath.Join(dstDir.GetID(), stream.GetName())
|
||||
fullPath := filepath.Join(dstDir.GetPath(), stream.GetName())
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"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/internal/op"
|
||||
"github.com/alist-org/alist/v3/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
@ -41,7 +42,7 @@ func (d *Onedrive) Drop(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func (d *Onedrive) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
files, err := d.GetFiles(dir.GetID())
|
||||
files, err := d.GetFiles(dir.GetPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -53,7 +54,7 @@ func (d *Onedrive) List(ctx context.Context, dir model.Obj, args model.ListArgs)
|
|||
}
|
||||
|
||||
func (d *Onedrive) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
f, err := d.GetFile(file.GetID())
|
||||
f, err := d.GetFile(file.GetPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -66,7 +67,7 @@ func (d *Onedrive) Link(ctx context.Context, file model.Obj, args model.LinkArgs
|
|||
}
|
||||
|
||||
func (d *Onedrive) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
url := d.GetMetaUrl(false, parentDir.GetID()) + "/children"
|
||||
url := d.GetMetaUrl(false, parentDir.GetPath()) + "/children"
|
||||
data := base.Json{
|
||||
"name": dirName,
|
||||
"folder": base.Json{},
|
||||
|
@ -79,35 +80,31 @@ func (d *Onedrive) MakeDir(ctx context.Context, parentDir model.Obj, dirName str
|
|||
}
|
||||
|
||||
func (d *Onedrive) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
dst, err := d.GetFile(dstDir.GetID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := base.Json{
|
||||
"parentReference": base.Json{
|
||||
"id": dst.Id,
|
||||
"id": dstDir.GetID(),
|
||||
},
|
||||
"name": srcObj.GetName(),
|
||||
}
|
||||
url := d.GetMetaUrl(false, srcObj.GetID())
|
||||
_, err = d.Request(url, http.MethodPatch, func(req *resty.Request) {
|
||||
url := d.GetMetaUrl(false, srcObj.GetPath())
|
||||
_, err := d.Request(url, http.MethodPatch, func(req *resty.Request) {
|
||||
req.SetBody(data)
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Onedrive) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||
dstDir, err := d.GetFile(stdpath.Dir(srcObj.GetID()))
|
||||
dstDir, err := op.Get(ctx, d, stdpath.Dir(srcObj.GetPath()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := base.Json{
|
||||
"parentReference": base.Json{
|
||||
"id": dstDir.Id,
|
||||
"id": dstDir.GetID(),
|
||||
},
|
||||
"name": newName,
|
||||
}
|
||||
url := d.GetMetaUrl(false, srcObj.GetID())
|
||||
url := d.GetMetaUrl(false, srcObj.GetPath())
|
||||
_, err = d.Request(url, http.MethodPatch, func(req *resty.Request) {
|
||||
req.SetBody(data)
|
||||
}, nil)
|
||||
|
@ -115,7 +112,7 @@ func (d *Onedrive) Rename(ctx context.Context, srcObj model.Obj, newName string)
|
|||
}
|
||||
|
||||
func (d *Onedrive) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
dst, err := d.GetFile(dstDir.GetID())
|
||||
dst, err := d.GetFile(dstDir.GetPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -126,7 +123,7 @@ func (d *Onedrive) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
|||
},
|
||||
"name": srcObj.GetName(),
|
||||
}
|
||||
url := d.GetMetaUrl(false, srcObj.GetID()) + "/copy"
|
||||
url := d.GetMetaUrl(false, srcObj.GetPath()) + "/copy"
|
||||
_, err = d.Request(url, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(data)
|
||||
}, nil)
|
||||
|
@ -134,7 +131,7 @@ func (d *Onedrive) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
|||
}
|
||||
|
||||
func (d *Onedrive) Remove(ctx context.Context, obj model.Obj) error {
|
||||
url := d.GetMetaUrl(false, obj.GetID())
|
||||
url := d.GetMetaUrl(false, obj.GetPath())
|
||||
_, err := d.Request(url, http.MethodDelete, nil, nil)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ type Addition struct {
|
|||
driver.RootFolderPath
|
||||
Region string `json:"region" type:"select" required:"true" options:"global,cn,us,de"`
|
||||
IsSharepoint bool `json:"is_sharepoint"`
|
||||
ClientId string `json:"client_id" required:"true"`
|
||||
ClientID string `json:"client_id" required:"true"`
|
||||
ClientSecret string `json:"client_secret" required:"true"`
|
||||
RedirectUri string `json:"redirect_uri" required:"true" default:"https://tool.nn.ci/onedrive/callback"`
|
||||
RefreshToken string `json:"refresh_token" required:"true"`
|
||||
|
|
|
@ -49,7 +49,7 @@ func fileToObj(f File) *model.ObjThumbURL {
|
|||
}
|
||||
return &model.ObjThumbURL{
|
||||
Object: model.Object{
|
||||
//ID: f.Id,
|
||||
ID: f.Id,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Modified: f.LastModifiedDateTime,
|
||||
|
|
|
@ -77,7 +77,7 @@ func (d *Onedrive) _refreshToken() error {
|
|||
var e TokenErr
|
||||
_, err := base.RestyClient.R().SetResult(&resp).SetError(&e).SetFormData(map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": d.ClientId,
|
||||
"client_id": d.ClientID,
|
||||
"client_secret": d.ClientSecret,
|
||||
"redirect_uri": d.RedirectUri,
|
||||
"refresh_token": d.RefreshToken,
|
||||
|
@ -96,7 +96,7 @@ func (d *Onedrive) _refreshToken() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (d *Onedrive) Request(url string, method string, callback func(*resty.Request), resp interface{}) ([]byte, error) {
|
||||
func (d *Onedrive) Request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeader("Authorization", "Bearer "+d.AccessToken)
|
||||
if callback != nil {
|
||||
|
@ -147,7 +147,7 @@ func (d *Onedrive) GetFile(path string) (*File, error) {
|
|||
}
|
||||
|
||||
func (d *Onedrive) upSmall(dstDir model.Obj, stream model.FileStreamer) error {
|
||||
url := d.GetMetaUrl(false, stdpath.Join(dstDir.GetID(), stream.GetName())) + "/content"
|
||||
url := d.GetMetaUrl(false, stdpath.Join(dstDir.GetPath(), stream.GetName())) + "/content"
|
||||
data, err := io.ReadAll(stream)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -159,7 +159,7 @@ func (d *Onedrive) upSmall(dstDir model.Obj, stream model.FileStreamer) error {
|
|||
}
|
||||
|
||||
func (d *Onedrive) upBig(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
|
||||
url := d.GetMetaUrl(false, stdpath.Join(dstDir.GetID(), stream.GetName())) + "/createUploadSession"
|
||||
url := d.GetMetaUrl(false, stdpath.Join(dstDir.GetPath(), stream.GetName())) + "/createUploadSession"
|
||||
res, err := d.Request(url, http.MethodPost, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -66,7 +66,7 @@ func (d *PikPak) refreshToken() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (d *PikPak) request(url string, method string, callback func(*resty.Request), resp interface{}) ([]byte, error) {
|
||||
func (d *PikPak) request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeader("Authorization", "Bearer "+d.AccessToken)
|
||||
if callback != nil {
|
||||
|
|
|
@ -21,7 +21,7 @@ import (
|
|||
|
||||
// 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) {
|
||||
func (d *Quark) request(pathname string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
u := "https://drive.quark.cn/1/clouddrive" + pathname
|
||||
req := base.RestyClient.R()
|
||||
req.SetHeaders(map[string]string{
|
||||
|
|
|
@ -22,7 +22,7 @@ func (d *Teambition) isInternational() bool {
|
|||
return d.Region == "international"
|
||||
}
|
||||
|
||||
func (d *Teambition) request(pathname string, method string, callback func(req *resty.Request), resp interface{}) ([]byte, error) {
|
||||
func (d *Teambition) request(pathname string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
url := "https://www.teambition.com" + pathname
|
||||
if d.isInternational() {
|
||||
url = "https://us.teambition.com" + pathname
|
||||
|
|
|
@ -13,6 +13,7 @@ type Obj interface {
|
|||
ModTime() time.Time
|
||||
IsDir() bool
|
||||
GetID() string
|
||||
GetPath() string
|
||||
}
|
||||
|
||||
type FileStreamer interface {
|
||||
|
@ -32,8 +33,8 @@ type Thumb interface {
|
|||
Thumb() string
|
||||
}
|
||||
|
||||
type SetID interface {
|
||||
SetID(id string)
|
||||
type SetPath interface {
|
||||
SetPath(path string)
|
||||
}
|
||||
|
||||
func SortFiles(objs []Obj, orderBy, orderDirection string) {
|
||||
|
|
|
@ -4,6 +4,7 @@ import "time"
|
|||
|
||||
type Object struct {
|
||||
ID string
|
||||
Path string
|
||||
Name string
|
||||
Size int64
|
||||
Modified time.Time
|
||||
|
@ -30,8 +31,12 @@ func (o *Object) GetID() string {
|
|||
return o.ID
|
||||
}
|
||||
|
||||
func (o *Object) SetID(id string) {
|
||||
o.ID = id
|
||||
func (o *Object) GetPath() string {
|
||||
return o.Path
|
||||
}
|
||||
|
||||
func (o *Object) SetPath(id string) {
|
||||
o.Path = id
|
||||
}
|
||||
|
||||
type Thumbnail struct {
|
||||
|
|
|
@ -35,6 +35,7 @@ func List(ctx context.Context, storage driver.Driver, path string, args model.Li
|
|||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "failed get dir")
|
||||
}
|
||||
log.Debugf("list dir: %+v", dir)
|
||||
if !dir.IsDir() {
|
||||
return nil, errors.WithStack(errs.NotFolder)
|
||||
}
|
||||
|
@ -94,7 +95,7 @@ func Get(ctx context.Context, storage driver.Driver, path string) (model.Obj, er
|
|||
}
|
||||
if r, ok := storage.GetAddition().(driver.IRootFolderPath); ok && isRoot(path, r.GetRootFolderPath()) {
|
||||
return &model.Object{
|
||||
ID: r.GetRootFolderPath(),
|
||||
Path: r.GetRootFolderPath(),
|
||||
Name: "root",
|
||||
Size: 0,
|
||||
Modified: storage.GetStorage().Modified,
|
||||
|
@ -108,12 +109,13 @@ func Get(ctx context.Context, storage driver.Driver, path string) (model.Obj, er
|
|||
return nil, errors.WithMessage(err, "failed get parent list")
|
||||
}
|
||||
for _, f := range files {
|
||||
// TODO maybe copy obj here
|
||||
if f.GetName() == name {
|
||||
// use path as id, why don't set id in List function?
|
||||
// because files maybe cache, set id here can reduce memory usage
|
||||
if f.GetID() == "" {
|
||||
if s, ok := f.(model.SetID); ok {
|
||||
s.SetID(path)
|
||||
if f.GetPath() == "" {
|
||||
if s, ok := f.(model.SetPath); ok {
|
||||
s.SetPath(path)
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
|
|
Loading…
Reference in New Issue