Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drivers/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
156 changes: 156 additions & 0 deletions drivers/lsky_pro/driver.go
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict root image queries to unalbumed records

When the mounted root has ID 0, params contains only order; Lsky Pro API v1 interprets an omitted album_id as no album filter, so this call appends every album image at the root and then exposes it again inside its album. Deleting or overwriting the apparent root copy therefore mutates the same image in the album; this branch needs to send the API's explicit no-album/root filter.

Useful? React with 👍 / 👎.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the overwritten duplicate at its original path

When the gallery already contains two a.png images, List's earliest ordering exposes the first as a.png and the second as a (<id>).png; overwriting /a.png uploads a third copy and this deletion removes only the formerly canonical first copy. After the next cache refresh, the second old image becomes a.png while the new upload receives the suffix, so reading the path just overwritten returns old content; duplicate display names need stable path assignment across replacement.

Useful? React with 👍 / 👎.

// 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)
)
26 changes: 26 additions & 0 deletions drivers/lsky_pro/meta.go
Original file line number Diff line number Diff line change
@@ -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{} })
}
87 changes: 87 additions & 0 deletions drivers/lsky_pro/types.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
88 changes: 88 additions & 0 deletions drivers/lsky_pro/util.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry suffixes that collide with an existing name

If a real filename already equals a suffix synthesized later—for example objects ordered as a (bbb).png, a.png, and another a.png whose ID is bbb—this assignment generates a name that is already in seen but never checks it again. The resulting listing still has two identical OpenList paths, leaving one object unreachable and allowing operations to target the wrong record; suffix generation should continue until it finds an unused name.

Useful? React with 👍 / 👎.

switch v := o.(type) {
case *imageObj:
v.Name = name
case *model.Object:
v.Name = name
}
}
seen[name] = struct{}{}
}
}
30 changes: 30 additions & 0 deletions drivers/lsky_pro/util_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading