Repository: AlistGo/alist (language: go)

Issue:
希望增加蓝奏云优享版的支持
### Please make sure of the following things

- [X] I have read the [documentation](https://alist.nn.ci).
- [X] I'm sure there are no duplicate issues or discussions.
- [X] I'm sure this feature is not implemented.
- [X] I'm sure it's a reasonable and popular requirement.

### Description of the feature / 需求描述

希望增加蓝奏云优享版的支持
网址：https://www.ilanzou.com/

### Suggested solution / 实现思路

_No response_

### Additional context / 附件

_No response_


Discussion / hints:
Thanks for opening your first issue here! Be sure to follow the issue template!

### See
1. 54% #4325
2. 54% #894
3. 50% #5364
4. 50% #1630
5. 50% #1106

<!-- Created by actions-cool/issues-similarity-analysis. Do not remove. -->

Fix patch:
diff --git a/README.md b/README.md
index ef68e01656b..74cd291d7df 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,7 @@ English | [中文](./README_cn.md)| [日本語](./README_ja.md) | [Contributing]
     - [x] [Quark](https://pan.quark.cn)
     - [x] [Thunder](https://pan.xunlei.com)
     - [x] [Lanzou](https://www.lanzou.com/)
+    - [x] [ILanzou](https://www.ilanzou.com/)
     - [x] [Aliyundrive share](https://www.alipan.com/)
     - [x] [Google photo](https://photos.google.com/)
     - [x] [Mega.nz](https://mega.nz)
diff --git a/README_cn.md b/README_cn.md
index a5dfab47f45..9d3603fa529 100644
--- a/README_cn.md
+++ b/README_cn.md
@@ -65,6 +65,7 @@
     - [x] [夸克网盘](https://pan.quark.cn)
     - [x] [迅雷网盘](https://pan.xunlei.com)
     - [x] [蓝奏云](https://www.lanzou.com/)
+    - [x] [蓝奏云优享版](https://www.ilanzou.com/)
     - [x] [阿里云盘分享](https://www.alipan.com/)
     - [x] [谷歌相册](https://photos.google.com/)
     - [x] [Mega.nz](https://mega.nz)
diff --git a/README_ja.md b/README_ja.md
index 3bcdd8de3d5..a50425455b3 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -66,6 +66,7 @@
     - [x] [Quark](https://pan.quark.cn)
     - [x] [Thunder](https://pan.xunlei.com)
     - [x] [Lanzou](https://www.lanzou.com/)
+    - [x] [ILanzou](https://www.ilanzou.com/)
     - [x] [Aliyundrive share](https://www.alipan.com/)
     - [x] [Google photo](https://photos.google.com/)
     - [x] [Mega.nz](https://mega.nz)
diff --git a/drivers/all.go b/drivers/all.go
index 40666028f11..599820c296c 100644
--- a/drivers/all.go
+++ b/drivers/all.go
@@ -25,6 +25,7 @@ import (
 	_ "github.com/alist-org/alist/v3/drivers/ftp"
 	_ "github.com/alist-org/alist/v3/drivers/google_drive"
 	_ "github.com/alist-org/alist/v3/drivers/google_photo"
+	_ "github.com/alist-org/alist/v3/drivers/ilanzou"
 	_ "github.com/alist-org/alist/v3/drivers/ipfs_api"
 	_ "github.com/alist-org/alist/v3/drivers/lanzou"
 	_ "github.com/alist-org/alist/v3/drivers/local"
diff --git a/drivers/ilanzou/driver.go b/drivers/ilanzou/driver.go
new file mode 100644
index 00000000000..85ba27e2e23
--- /dev/null
+++ b/drivers/ilanzou/driver.go
@@ -0,0 +1,365 @@
+package template
+
+import (
+	"context"
+	"crypto/md5"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/alist-org/alist/v3/drivers/base"
+	"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/foxxorcat/mopan-sdk-go"
+	"github.com/go-resty/resty/v2"
+	log "github.com/sirupsen/logrus"
+)
+
+type ILanZou struct {
+	model.Storage
+	Addition
+
+	userID   string
+	account  string
+	upClient *resty.Client
+}
+
+func (d *ILanZou) Config() driver.Config {
+	return config
+}
+
+func (d *ILanZou) GetAddition() driver.Additional {
+	return &d.Addition
+}
+
+func (d *ILanZou) Init(ctx context.Context) error {
+	d.upClient = base.NewRestyClient().SetTimeout(time.Minute * 10)
+	if d.UUID == "" {
+		res, err := d.unproved("/getUuid", http.MethodGet, nil)
+		if err != nil {
+			return err
+		}
+		d.UUID = utils.Json.Get(res, "uuid").ToString()
+	}
+	res, err := d.proved("/user/account/map", http.MethodGet, nil)
+	if err != nil {
+		return err
+	}
+	d.userID = utils.Json.Get(res, "map", "userId").ToString()
+	d.account = utils.Json.Get(res, "map", "account").ToString()
+	log.Debugf("[ilanzou] init response: %s", res)
+	return nil
+}
+
+func (d *ILanZou) Drop(ctx context.Context) error {
+	return nil
+}
+
+func (d *ILanZou) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
+	offset := 1
+	limit := 60
+	var res []ListItem
+	for {
+		var resp ListResp
+		_, err := d.proved("/record/file/list", http.MethodGet, func(req *resty.Request) {
+			req.SetQueryParams(map[string]string{
+				"type":     "0",
+				"folderId": dir.GetID(),
+				"offset":   strconv.Itoa(offset),
+				"limit":    strconv.Itoa(limit),
+			}).SetResult(&resp)
+		})
+		if err != nil {
+			return nil, err
+		}
+		res = append(res, resp.List...)
+		if resp.TotalPage <= resp.Offset {
+			break
+		}
+		offset++
+	}
+	return utils.SliceConvert(res, func(f ListItem) (model.Obj, error) {
+		updTime, err := time.ParseInLocation("2006-01-02 15:04:05", f.UpdTime, time.Local)
+		if err != nil {
+			return nil, err
+		}
+		obj := model.Object{
+			ID: strconv.FormatInt(f.FileId, 10),
+			//Path:     "",
+			Name:     f.FileName,
+			Size:     f.FileSize * 1024,
+			Modified: updTime,
+			Ctime:    updTime,
+			IsFolder: false,
+			//HashInfo: utils.HashInfo{},
+		}
+		if f.FileType == 2 {
+			obj.IsFolder = true
+			obj.Size = 0
+			obj.ID = strconv.FormatInt(f.FolderId, 10)
+			obj.Name = f.FolderName
+		}
+		return &obj, nil
+	})
+}
+
+func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
+	u, err := url.Parse("https://api.ilanzou.com/unproved/file/redirect")
+	if err != nil {
+		return nil, err
+	}
+	query := u.Query()
+	query.Set("uuid", d.UUID)
+	query.Set("devType", "6")
+	query.Set("devCode", d.UUID)
+	query.Set("devModel", "chrome")
+	query.Set("devVersion", "120")
+	query.Set("appVersion", "")
+	ts, err := getTimestamp()
+	if err != nil {
+		return nil, err
+	}
+	query.Set("timestamp", ts)
+	//query.Set("appToken", d.Token)
+	query.Set("enable", "1")
+	downloadId, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%s", file.GetID(), d.userID)), AesSecret)
+	if err != nil {
+		return nil, err
+	}
+	query.Set("downloadId", hex.EncodeToString(downloadId))
+	auth, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%d", file.GetID(), time.Now().UnixMilli())), AesSecret)
+	if err != nil {
+		return nil, err
+	}
+	query.Set("auth", hex.EncodeToString(auth))
+	u.RawQuery = query.Encode()
+	link := model.Link{URL: u.String()}
+	return &link, nil
+}
+
+func (d *ILanZou) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
+	res, err := d.proved("/file/folder/save", http.MethodPost, func(req *resty.Request) {
+		req.SetBody(base.Json{
+			"folderDesc": "",
+			"folderId":   parentDir.GetID(),
+			"folderName": dirName,
+		})
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &model.Object{
+		ID: utils.Json.Get(res, "list", "0", "id").ToString(),
+		//Path:     "",
+		Name:     dirName,
+		Size:     0,
+		Modified: time.Now(),
+		Ctime:    time.Now(),
+		IsFolder: true,
+		//HashInfo: utils.HashInfo{},
+	}, nil
+}
+
+func (d *ILanZou) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
+	var fileIds, folderIds []string
+	if srcObj.IsDir() {
+		folderIds = []string{srcObj.GetID()}
+	} else {
+		fileIds = []string{srcObj.GetID()}
+	}
+	_, err := d.proved("/file/folder/move", http.MethodPost, func(req *resty.Request) {
+		req.SetBody(base.Json{
+			"folderIds": strings.Join(folderIds, ","),
+			"fileIds":   strings.Join(fileIds, ","),
+			"targetId":  dstDir.GetID(),
+		})
+	})
+	if err != nil {
+		return nil, err
+	}
+	return srcObj, nil
+}
+
+func (d *ILanZou) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
+	var err error
+	if srcObj.IsDir() {
+		_, err = d.proved("/file/folder/edit", http.MethodPost, func(req *resty.Request) {
+			req.SetBody(base.Json{
+				"folderDesc": "",
+				"folderId":   srcObj.GetID(),
+				"folderName": newName,
+			})
+		})
+	} else {
+		_, err = d.proved("/file/edit", http.MethodPost, func(req *resty.Request) {
+			req.SetBody(base.Json{
+				"fileDesc": "",
+				"fileId":   srcObj.GetID(),
+				"fileName": newName,
+			})
+		})
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &model.Object{
+		ID: srcObj.GetID(),
+		//Path:     "",
+		Name:     newName,
+		Size:     srcObj.GetSize(),
+		Modified: time.Now(),
+		Ctime:    srcObj.CreateTime(),
+		IsFolder: srcObj.IsDir(),
+	}, nil
+}
+
+func (d *ILanZou) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
+	// TODO copy obj, optional
+	return nil, errs.NotImplement
+}
+
+func (d *ILanZou) Remove(ctx context.Context, obj model.Obj) error {
+	var fileIds, folderIds []string
+	if obj.IsDir() {
+		folderIds = []string{obj.GetID()}
+	} else {
+		fileIds = []string{obj.GetID()}
+	}
+	_, err := d.proved("/file/delete", http.MethodPost, func(req *resty.Request) {
+		req.SetBody(base.Json{
+			"folderIds": strings.Join(folderIds, ","),
+			"fileIds":   strings.Join(fileIds, ","),
+			"status":    0,
+		})
+	})
+	return err
+}
+
+const DefaultPartSize = 1024 * 1024 * 8
+
+func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
+	h := md5.New()
+	// need to calculate md5 of the full content
+	tempFile, err := stream.CacheFullInTempFile()
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		_ = tempFile.Close()
+	}()
+	if _, err = io.Copy(h, tempFile); err != nil {
+		return nil, err
+	}
+	_, err = tempFile.Seek(0, io.SeekStart)
+	if err != nil {
+		return nil, err
+	}
+	etag := hex.EncodeToString(h.Sum(nil))
+	// get upToken
+	res, err := d.proved("/7n/getUpToken", http.MethodPost, func(req *resty.Request) {
+		req.SetBody(base.Json{
+			"fileId":   "",
+			"fileName": stream.GetName(),
+			"fileSize": stream.GetSize() / 1024,
+			"folderId": dstDir.GetID(),
+			"md5":      etag,
+			"type":     1,
+		})
+	})
+	if err != nil {
+		return nil, err
+	}
+	upToken := utils.Json.Get(res, "upToken").ToString()
+	now := time.Now()
+	key := fmt.Sprintf("disk/%d/%d/%d/%s/%016d", now.Year(), now.Month(), now.Day(), d.account, now.UnixMilli())
+	var token string
+	if stream.GetSize() > DefaultPartSize {
+		res, err := d.upClient.R().SetMultipartFormData(map[string]string{
+			"token": upToken,
+			"key":   key,
+			"fname": stream.GetName(),
+		}).SetMultipartField("file", stream.GetName(), stream.GetMimetype(), tempFile).
+			Post("https://upload.qiniup.com/")
+		if err != nil {
+			return nil, err
+		}
+		token = utils.Json.Get(res.Body(), "token").ToString()
+	} else {
+		keyBase64 := base64.URLEncoding.EncodeToString([]byte(key))
+		res, err := d.upClient.R().Post(fmt.Sprintf("https://upload.qiniup.com/buckets/wpanstore-lanzou/objects/%s/uploads", keyBase64))
+		if err != nil {
+			return nil, err
+		}
+		uploadId := utils.Json.Get(res.Body(), "uploadId").ToString()
+		parts := make([]Part, 0)
+		partNum := (stream.GetSize() + DefaultPartSize - 1) / DefaultPartSize
+		for i := 1; i <= int(partNum); i++ {
+			u := fmt.Sprintf("https://upload.qiniup.com/buckets/wpanstore-lanzou/objects/%s/uploads/%s/%d", keyBase64, uploadId, i)
+			res, err = d.upClient.R().SetBody(io.LimitReader(tempFile, DefaultPartSize)).Put(u)
+			if err != nil {
+				return nil, err
+			}
+			etag := utils.Json.Get(res.Body(), "etag").ToString()
+			parts = append(parts, Part{
+				PartNumber: i,
+				ETag:       etag,
+			})
+		}
+		res, err = d.upClient.R().SetBody(base.Json{
+			"fnmae": stream.GetName(),
+			"parts": parts,
+		}).Post(fmt.Sprintf("https://upload.qiniup.com/buckets/wpanstore-lanzou/objects/%s/uploads/%s", keyBase64, uploadId))
+		if err != nil {
+			return nil, err
+		}
+		token = utils.Json.Get(res.Body(), "token").ToString()
+	}
+	// commit upload
+	var resp UploadResultResp
+	for i := 0; i < 10; i++ {
+		_, err = d.unproved("/7n/results", http.MethodPost, func(req *resty.Request) {
+			req.SetQueryParams(map[string]string{
+				"tokenList": token,
+				"tokenTime": time.Now().Format("Mon Jan 02 2006 15:04:05 GMT-0700 (MST)"),
+			}).SetResult(&resp)
+		})
+		if err != nil {
+			return nil, err
+		}
+		if len(resp.List) == 0 {
+			return nil, fmt.Errorf("upload failed, empty response")
+		}
+		if resp.List[0].Status == 1 {
+			break
+		}
+		time.Sleep(time.Second * 1)
+	}
+	file := resp.List[0]
+	if file.Status != 1 {
+		return nil, fmt.Errorf("upload failed, status: %d", resp.List[0].Status)
+	}
+	return &model.Object{
+		ID: strconv.FormatInt(file.FileId, 10),
+		//Path:     ,
+		Name:     file.FileName,
+		Size:     stream.GetSize(),
+		Modified: stream.ModTime(),
+		Ctime:    stream.CreateTime(),
+		IsFolder: false,
+		HashInfo: utils.NewHashInfo(utils.MD5, etag),
+	}, nil
+}
+
+//func (d *ILanZou) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
+//	return nil, errs.NotSupport
+//}
+
+var _ driver.Driver = (*ILanZou)(nil)
diff --git a/drivers/ilanzou/meta.go b/drivers/ilanzou/meta.go
new file mode 100644
index 00000000000..44adbf0a6f7
--- /dev/null
+++ b/drivers/ilanzou/meta.go
@@ -0,0 +1,35 @@
+package template
+
+import (
+	"github.com/alist-org/alist/v3/internal/driver"
+	"github.com/alist-org/alist/v3/internal/op"
+)
+
+type Addition struct {
+	driver.RootID
+	Username string `json:"username" type:"string" required:"true"`
+	Password string `json:"password" type:"string" required:"true"`
+
+	Token string
+	UUID  string
+}
+
+var config = driver.Config{
+	Name:              "ILanZou",
+	LocalSort:         false,
+	OnlyLocal:         false,
+	OnlyProxy:         false,
+	NoCache:           false,
+	NoUpload:          false,
+	NeedMs:            false,
+	DefaultRoot:       "0",
+	CheckStatus:       false,
+	Alert:             "",
+	NoOverwriteUpload: false,
+}
+
+func init() {
+	op.RegisterDriver(func() driver.Driver {
+		return &ILanZou{}
+	})
+}
diff --git a/drivers/ilanzou/types.go b/drivers/ilanzou/types.go
new file mode 100644
index 00000000000..135724c749c
--- /dev/null
+++ b/drivers/ilanzou/types.go
@@ -0,0 +1,57 @@
+package template
+
+type ListResp struct {
+	Msg       string     `json:"msg"`
+	Total     int        `json:"total"`
+	Code      int        `json:"code"`
+	Offset    int        `json:"offset"`
+	TotalPage int        `json:"totalPage"`
+	Limit     int        `json:"limit"`
+	List      []ListItem `json:"list"`
+}
+
+type ListItem struct {
+	IconId         int         `json:"iconId"`
+	IsAmt          int         `json:"isAmt"`
+	FolderDesc     string      `json:"folderDesc,omitempty"`
+	AddTime        string      `json:"addTime"`
+	FolderId       int64       `json:"folderId"`
+	ParentId       int64       `json:"parentId"`
+	ParentName     string      `json:"parentName"`
+	NoteType       int         `json:"noteType,omitempty"`
+	UpdTime        string      `json:"updTime"`
+	IsShare        int         `json:"isShare"`
+	FolderIcon     string      `json:"folderIcon,omitempty"`
+	FolderName     string      `json:"folderName,omitempty"`
+	FileType       int         `json:"fileType"`
+	Status         int         `json:"status"`
+	IsFileShare    int         `json:"isFileShare,omitempty"`
+	FileName       string      `json:"fileName,omitempty"`
+	FileStars      float64     `json:"fileStars,omitempty"`
+	IsFileDownload int         `json:"isFileDownload,omitempty"`
+	FileComments   int         `json:"fileComments,omitempty"`
+	FileSize       int64       `json:"fileSize,omitempty"`
+	FileIcon       string      `json:"fileIcon,omitempty"`
+	FileDownloads  int         `json:"fileDownloads,omitempty"`
+	FileUrl        interface{} `json:"fileUrl"`
+	FileLikes      int         `json:"fileLikes,omitempty"`
+	FileId         int64       `json:"fileId,omitempty"`
+}
+
+type Part struct {
+	PartNumber int    `json:"partNumber"`
+	ETag       string `json:"etag"`
+}
+
+type UploadResultResp struct {
+	Msg  string `json:"msg"`
+	Code int    `json:"code"`
+	List []struct {
+		FileIconId int    `json:"fileIconId"`
+		FileName   string `json:"fileName"`
+		FileIcon   string `json:"fileIcon"`
+		FileId     int64  `json:"fileId"`
+		Status     int    `json:"status"`
+		Token      string `json:"token"`
+	} `json:"list"`
+}
diff --git a/drivers/ilanzou/util.go b/drivers/ilanzou/util.go
new file mode 100644
index 00000000000..2ccaf52e165
--- /dev/null
+++ b/drivers/ilanzou/util.go
@@ -0,0 +1,105 @@
+package template
+
+import (
+	"encoding/hex"
+	"fmt"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/alist-org/alist/v3/drivers/base"
+	"github.com/alist-org/alist/v3/pkg/utils"
+	"github.com/foxxorcat/mopan-sdk-go"
+	"github.com/go-resty/resty/v2"
+	log "github.com/sirupsen/logrus"
+)
+
+const (
+	Base = "https://api.ilanzou.com"
+)
+
+var (
+	AesSecret = []byte("lanZouY-disk-app")
+)
+
+func (d *ILanZou) login() error {
+	res, err := d.unproved("/login", http.MethodPost, func(req *resty.Request) {
+		req.SetBody(base.Json{
+			"loginName": d.Username,
+			"loginPwd":  d.Password,
+		})
+	})
+	if err != nil {
+		return err
+	}
+	d.Token = utils.Json.Get(res, "data", "appToken").ToString()
+	if d.Token == "" {
+		return fmt.Errorf("failed to login: token is empty, resp: %s", res)
+	}
+	return nil
+}
+
+func getTimestamp() (string, error) {
+	ts := time.Now().UnixMilli()
+	tsStr := strconv.FormatInt(ts, 10)
+	res, err := mopan.AesEncrypt([]byte(tsStr), AesSecret)
+	if err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(res), nil
+}
+
+func (d *ILanZou) request(pathname, method string, callback base.ReqCallback, proved bool, retry ...bool) ([]byte, error) {
+	req := base.RestyClient.R()
+	ts, err := getTimestamp()
+	if err != nil {
+		return nil, err
+	}
+	req.SetQueryParams(map[string]string{
+		"uuid":       d.UUID,
+		"devType":    "6",
+		"devCode":    d.UUID,
+		"devModel":   "chrome",
+		"devVersion": "120",
+		"appVersion": "",
+		"timestamp":  ts,
+		//"appToken":   d.Token,
+		"extra": "2",
+	})
+	if proved {
+		req.SetQueryParam("appToken", d.Token)
+	}
+	if callback != nil {
+		callback(req)
+	}
+	res, err := req.Execute(method, Base+pathname)
+	if err != nil {
+		if res != nil {
+			log.Errorf("[iLanZou] request error: %s", res.String())
+		}
+		return nil, err
+	}
+	isRetry := len(retry) > 0 && retry[0]
+	body := res.Body()
+	code := utils.Json.Get(body, "code").ToInt()
+	msg := utils.Json.Get(body, "msg").ToString()
+	if code != 200 {
+		if !isRetry && proved && (utils.SliceContains([]int{-1, -2}, code) || d.Token == "") {
+			err = d.login()
+			if err != nil {
+				return nil, err
+			}
+			return d.request(pathname, method, callback, proved, true)
+		}
+		return nil, fmt.Errorf("%d: %s", code, msg)
+	}
+	return body, nil
+}
+
+func (d *ILanZou) unproved(pathname, method string, callback base.ReqCallback) ([]byte, error) {
+	return d.request("/unproved"+pathname, method, callback, false)
+}
+
+func (d *ILanZou) proved(pathname, method string, callback base.ReqCallback) ([]byte, error) {
+	return d.request("/proved"+pathname, method, callback, true)
+}


Test patch: