diff --git a/drivers/all.go b/drivers/all.go index 601c7bfc5c..bd9e6cafc0 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -51,6 +51,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/lanzou" _ "github.com/OpenListTeam/OpenList/v4/drivers/lenovonas_share" _ "github.com/OpenListTeam/OpenList/v4/drivers/local" + _ "github.com/OpenListTeam/OpenList/v4/drivers/lsky_pro" _ "github.com/OpenListTeam/OpenList/v4/drivers/mediafire" _ "github.com/OpenListTeam/OpenList/v4/drivers/mediatrack" _ "github.com/OpenListTeam/OpenList/v4/drivers/mega" diff --git a/drivers/lsky_pro/driver.go b/drivers/lsky_pro/driver.go new file mode 100644 index 0000000000..95a140003d --- /dev/null +++ b/drivers/lsky_pro/driver.go @@ -0,0 +1,156 @@ +package lsky_pro + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +// LskyPro maps a Lsky Pro (v2, API v1) gallery to a two level tree: +// the root holds albums (folders) and the images that belong to no album, +// each album holds its images. +type LskyPro struct { + model.Storage + Addition + client *resty.Client +} + +func (d *LskyPro) Config() driver.Config { return config } +func (d *LskyPro) GetAddition() driver.Additional { return &d.Addition } + +func (d *LskyPro) Init(ctx context.Context) error { + d.Address = strings.TrimRight(d.Address, "/") + d.client = base.NewRestyClient(). + SetBaseURL(d.Address). + SetHeader("Accept", "application/json") + + if d.Token == "" { + if d.Email == "" || d.Password == "" { + return fmt.Errorf("token, or email and password, is required") + } + var data TokenData + err := d.request(ctx, http.MethodPost, "/tokens", func(req *resty.Request) { + req.SetFormData(map[string]string{"email": d.Email, "password": d.Password}) + }, &data) + if err != nil { + return err + } + d.Token = data.Token + op.MustSaveDriverStorage(d) + } + d.client.SetAuthToken(strings.TrimPrefix(d.Token, "Bearer ")) + + // connectivity check + return d.request(ctx, http.MethodGet, "/profile", nil, nil) +} + +func (d *LskyPro) Drop(ctx context.Context) error { return nil } + +func (d *LskyPro) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + var objs []model.Obj + albumID := dir.GetID() + params := map[string]string{"order": "earliest"} + if albumID == "0" || albumID == "" { + albums, err := listAll[Album](ctx, d, "/albums", nil) + if err != nil { + return nil, err + } + for _, a := range albums { + objs = append(objs, a.toObj()) + } + } else { + params["album_id"] = albumID + } + images, err := listAll[Image](ctx, d, "/images", params) + if err != nil { + return nil, err + } + for _, i := range images { + objs = append(objs, i.toObj()) + } + uniqueNames(objs) + return objs, nil +} + +func (d *LskyPro) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if img, ok := file.(*imageObj); ok && img.url != "" { + return &model.Link{URL: img.url}, nil + } + return nil, errs.NotSupport +} + +func (d *LskyPro) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + if id := parentDir.GetID(); id != "0" && id != "" { + return nil, errs.NotSupport // albums cannot be nested + } + var album Album + err := d.request(ctx, http.MethodPost, "/albums", func(req *resty.Request) { + req.SetFormData(map[string]string{"name": dirName}) + }, &album) + if err != nil { + return nil, err + } + return album.toObj(), nil +} + +func (d *LskyPro) Remove(ctx context.Context, obj model.Obj) error { + path := "/images/" + obj.GetID() + if obj.IsDir() { + path = "/albums/" + obj.GetID() + } + return d.request(ctx, http.MethodDelete, path, nil, nil) +} + +func (d *LskyPro) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + // album_id=0 asks Lsky Pro to skip the account's default album, so a root + // upload really lands in the root (sites without the patch ignore it) + fields := map[string]string{"album_id": "0"} + if id := dstDir.GetID(); id != "0" && id != "" { + fields["album_id"] = id + } + if d.StrategyID != "" { + fields["strategy_id"] = d.StrategyID + } + var img Image + err := d.request(ctx, http.MethodPost, "/upload", func(req *resty.Request) { + req.SetMultipartFormData(fields).SetFileReader("file", file.GetName(), + driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: file, + UpdateProgress: up, + })) + }, &img) + if err != nil { + return nil, err + } + if img.OriginName == "" { + img.OriginName = file.GetName() + } + // Lsky Pro keeps same-named images side by side, so replace the old one + // when OpenList is overwriting an existing file. + if old := file.GetExist(); old != nil && old.GetID() != img.Key { + if err := d.Remove(ctx, old); err != nil { + // keep a single record per name: drop the new upload and fail + if rerr := d.Remove(ctx, img.toObj()); rerr != nil { + log.Warnf("lsky_pro: failed to clean up image %s: %v", img.Key, rerr) + } + return nil, fmt.Errorf("lsky pro: failed to replace the existing image: %w", err) + } + } + return img.toObj(), nil +} + +var ( + _ driver.Driver = (*LskyPro)(nil) + _ driver.MkdirResult = (*LskyPro)(nil) + _ driver.PutResult = (*LskyPro)(nil) + _ driver.Remove = (*LskyPro)(nil) +) diff --git a/drivers/lsky_pro/meta.go b/drivers/lsky_pro/meta.go new file mode 100644 index 0000000000..33e4e3ceeb --- /dev/null +++ b/drivers/lsky_pro/meta.go @@ -0,0 +1,26 @@ +package lsky_pro + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID + Address string `json:"address" required:"true" help:"Lsky Pro site address, e.g. https://img.example.com"` + Token string `json:"token" help:"API token, e.g. 1|xxxx. If empty, email and password are used to request one"` + Email string `json:"email" help:"Used to request a token when the token is empty"` + Password string `json:"password" help:"Used to request a token when the token is empty"` + StrategyID string `json:"strategy_id" help:"Storage strategy ID used for uploads, empty for the default strategy"` +} + +var config = driver.Config{ + Name: "LskyPro", + LocalSort: true, + OnlyProxy: false, + DefaultRoot: "0", +} + +func init() { + op.RegisterDriver(func() driver.Driver { return &LskyPro{} }) +} diff --git a/drivers/lsky_pro/types.go b/drivers/lsky_pro/types.go new file mode 100644 index 0000000000..27756d116f --- /dev/null +++ b/drivers/lsky_pro/types.go @@ -0,0 +1,87 @@ +package lsky_pro + +import ( + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// Resp is the common response envelope of the Lsky Pro API. +type Resp[T any] struct { + Status bool `json:"status"` + Message string `json:"message"` + Data T `json:"data"` +} + +type Page[T any] struct { + CurrentPage int `json:"current_page"` + LastPage int `json:"last_page"` + Data []T `json:"data"` +} + +type Album struct { + ID int `json:"id"` + Name string `json:"name"` + Intro string `json:"intro"` + ImageNum int `json:"image_num"` +} + +type Links struct { + URL string `json:"url"` + ThumbnailURL string `json:"thumbnail_url"` +} + +type Image struct { + Key string `json:"key"` + Name string `json:"name"` + OriginName string `json:"origin_name"` + Size float64 `json:"size"` // KB + Mimetype string `json:"mimetype"` + MD5 string `json:"md5"` + SHA1 string `json:"sha1"` + Date string `json:"date"` + Links Links `json:"links"` +} + +type TokenData struct { + Token string `json:"token"` +} + +// imageObj carries the public url returned by the list/upload API. +type imageObj struct { + model.ObjThumb + url string +} + +func (a Album) toObj() model.Obj { + return &model.Object{ + ID: itoa(a.ID), + Name: a.Name, + IsFolder: true, + Modified: time.Now(), + } +} + +func (i Image) toObj() model.Obj { + name := i.OriginName + if name == "" { + name = i.Name + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", i.Date, time.Local) + if err != nil { + t = time.Now() + } + return &imageObj{ + ObjThumb: model.ObjThumb{ + Object: model.Object{ + ID: i.Key, + Name: name, + Size: int64(i.Size * 1024), + Modified: t, + Ctime: t, + }, + Thumbnail: model.Thumbnail{Thumbnail: i.Links.ThumbnailURL}, + }, + url: i.Links.URL, + } +} diff --git a/drivers/lsky_pro/util.go b/drivers/lsky_pro/util.go new file mode 100644 index 0000000000..b521a649a6 --- /dev/null +++ b/drivers/lsky_pro/util.go @@ -0,0 +1,88 @@ +package lsky_pro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path" + "strconv" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + + "github.com/go-resty/resty/v2" +) + +const apiPrefix = "/api/v1" + +func itoa(i int) string { return strconv.Itoa(i) } + +// request calls the Lsky Pro API, checks the response envelope and +// unmarshals its data field into out (when out is not nil). +func (d *LskyPro) request(ctx context.Context, method, path string, callback func(*resty.Request), out any) error { + req := d.client.R().SetContext(ctx) + if callback != nil { + callback(req) + } + res, err := req.Execute(method, apiPrefix+path) + if err != nil { + return err + } + var env Resp[json.RawMessage] + if err := json.Unmarshal(res.Body(), &env); err != nil { + return fmt.Errorf("lsky pro: unexpected response (HTTP %d)", res.StatusCode()) + } + if res.StatusCode() == http.StatusUnauthorized { + return fmt.Errorf("lsky pro: unauthorized, please check the token") + } + if !env.Status { + if env.Message == "" { + return fmt.Errorf("lsky pro: request failed (HTTP %d)", res.StatusCode()) + } + return fmt.Errorf("lsky pro: %s", env.Message) + } + if out == nil || len(env.Data) == 0 { + return nil + } + return json.Unmarshal(env.Data, out) +} + +// listAll walks every page of a paginated endpoint. +func listAll[T any](ctx context.Context, d *LskyPro, path string, params map[string]string) ([]T, error) { + var all []T + for page := 1; ; page++ { + var p Page[T] + err := d.request(ctx, http.MethodGet, path, func(req *resty.Request) { + req.SetQueryParams(params).SetQueryParam("page", strconv.Itoa(page)) + }, &p) + if err != nil { + return nil, err + } + all = append(all, p.Data...) + if page >= p.LastPage || len(p.Data) == 0 { + return all, nil + } + } +} + +// uniqueNames makes names in one listing unique. Lsky Pro allows several +// images (or albums) with the same name, but OpenList resolves a path to the +// first object with that name. Later duplicates get their id inserted before +// the extension, e.g. "a.png" -> "a (Le5rYe).png". +func uniqueNames(objs []model.Obj) { + seen := make(map[string]struct{}, len(objs)) + for _, o := range objs { + name := o.GetName() + if _, dup := seen[name]; dup { + ext := path.Ext(name) + name = name[:len(name)-len(ext)] + " (" + o.GetID() + ")" + ext + switch v := o.(type) { + case *imageObj: + v.Name = name + case *model.Object: + v.Name = name + } + } + seen[name] = struct{}{} + } +} diff --git a/drivers/lsky_pro/util_test.go b/drivers/lsky_pro/util_test.go new file mode 100644 index 0000000000..fd924bc743 --- /dev/null +++ b/drivers/lsky_pro/util_test.go @@ -0,0 +1,30 @@ +package lsky_pro + +import ( + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestUniqueNames(t *testing.T) { + objs := []model.Obj{ + Album{ID: 1, Name: "pics"}.toObj(), + Album{ID: 2, Name: "pics"}.toObj(), + Image{Key: "aaa", OriginName: "a.png"}.toObj(), + Image{Key: "bbb", OriginName: "a.png"}.toObj(), + Image{Key: "ccc", OriginName: "a.png"}.toObj(), + Image{Key: "ddd", OriginName: "noext"}.toObj(), + Image{Key: "eee", OriginName: "noext"}.toObj(), + } + uniqueNames(objs) + want := []string{"pics", "pics (2)", "a.png", "a (bbb).png", "a (ccc).png", "noext", "noext (eee)"} + for i, o := range objs { + if o.GetName() != want[i] { + t.Errorf("objs[%d] = %q, want %q", i, o.GetName(), want[i]) + } + } + // ids are untouched, so Link/Remove still hit the right record + if objs[3].GetID() != "bbb" { + t.Errorf("id changed: %s", objs[3].GetID()) + } +}