2022-08-31 13:01:15 +00:00
|
|
|
package op
|
2022-07-10 06:45:39 +00:00
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"sort"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/alist-org/alist/v3/internal/db"
|
|
|
|
"github.com/alist-org/alist/v3/internal/driver"
|
|
|
|
"github.com/alist-org/alist/v3/internal/model"
|
|
|
|
"github.com/alist-org/alist/v3/pkg/generic_sync"
|
|
|
|
"github.com/alist-org/alist/v3/pkg/utils"
|
2022-12-17 11:49:05 +00:00
|
|
|
mapset "github.com/deckarep/golang-set/v2"
|
2022-07-10 06:45:39 +00:00
|
|
|
"github.com/pkg/errors"
|
2022-08-03 06:26:59 +00:00
|
|
|
log "github.com/sirupsen/logrus"
|
2022-07-10 06:45:39 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// Although the driver type is stored,
|
|
|
|
// there is a storage in each driver,
|
|
|
|
// so it should actually be a storage, just wrapped by the driver
|
|
|
|
var storagesMap generic_sync.MapOf[string, driver.Driver]
|
|
|
|
|
2022-12-10 11:03:09 +00:00
|
|
|
func GetAllStorages() []driver.Driver {
|
|
|
|
return storagesMap.Values()
|
|
|
|
}
|
|
|
|
|
2022-12-11 06:59:58 +00:00
|
|
|
func HasStorage(mountPath string) bool {
|
2022-12-17 11:49:05 +00:00
|
|
|
return storagesMap.Has(utils.FixAndCleanPath(mountPath))
|
2022-12-11 06:59:58 +00:00
|
|
|
}
|
|
|
|
|
2022-12-24 12:23:04 +00:00
|
|
|
func GetStorageByMountPath(mountPath string) (driver.Driver, error) {
|
|
|
|
mountPath = utils.FixAndCleanPath(mountPath)
|
|
|
|
storageDriver, ok := storagesMap.Load(mountPath)
|
2022-07-10 06:45:39 +00:00
|
|
|
if !ok {
|
2022-12-24 12:23:04 +00:00
|
|
|
return nil, errors.Errorf("no mount path for an storage is: %s", mountPath)
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
return storageDriver, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// CreateStorage Save the storage to database so storage can get an id
|
|
|
|
// then instantiate corresponding driver and save it in memory
|
2022-11-13 12:17:10 +00:00
|
|
|
func CreateStorage(ctx context.Context, storage model.Storage) (uint, error) {
|
2022-07-10 06:45:39 +00:00
|
|
|
storage.Modified = time.Now()
|
2022-12-17 11:49:05 +00:00
|
|
|
storage.MountPath = utils.FixAndCleanPath(storage.MountPath)
|
2022-07-10 06:45:39 +00:00
|
|
|
var err error
|
|
|
|
// check driver first
|
|
|
|
driverName := storage.Driver
|
feat: Crypt driver, improve http/webdav handling (#4884)
this PR has several enhancements, fixes, and features:
- [x] Crypt: a transparent encryption driver. Anyone can easily, and safely store encrypted data on the remote storage provider. Consider your data is safely stored in the safe, and the storage provider can only see the safe, but not your data.
- [x] Optional: compatible with [Rclone Crypt](https://rclone.org/crypt/). More ways to manipulate the encrypted data.
- [x] directory and filename encryption
- [x] server-side encryption mode (server encrypts & decrypts all data, all data flows thru the server)
- [x] obfuscate sensitive information internally
- [x] introduced a server memory-cached multi-thread downloader.
- [x] Driver: **Quark** enabled this feature, faster load in any single thread scenario. e.g. media player directly playing from the link, now it's faster.
- [x] general improvement on HTTP/WebDAV stream processing & header handling & response handling
- [x] Driver: **Mega** driver support ranged http header
- [x] Driver: **Quark** fix bug of not closing HTTP request to Quark server while user end has closed connection to alist
## Crypt, a transparent Encrypt/Decrypt Driver. (Rclone Crypt compatible)
e.g.
Crypt mount path -> /vault
Crypt remote path -> /ali/encrypted
Aliyun mount paht -> /ali
when the user uploads a.jpg to /vault, the data will be encrypted and saved to /ali/encrypted/xxxxx. And when the user wants to access a.jpg, it's automatically decrypted, and the user can do anything with it.
Since it's Rclone Crypt compatible, users can download /ali/encrypted/xxxxx and decrypt it with rclone crypt tool. Or the user can mount this folder using rclone, then mount the decrypted folder in Linux...
NB. Some breaking changes is made to make it follow global standard, e.g. processing the HTTP header properly.
close #4679
close #4827
Co-authored-by: Sean He <866155+seanhe26@users.noreply.github.com>
Co-authored-by: Andy Hsu <i@nn.ci>
2023-08-02 06:40:36 +00:00
|
|
|
driverNew, err := GetDriver(driverName)
|
2022-07-10 06:45:39 +00:00
|
|
|
if err != nil {
|
2022-11-13 12:17:10 +00:00
|
|
|
return 0, errors.WithMessage(err, "failed get driver new")
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
storageDriver := driverNew()
|
|
|
|
// insert storage to database
|
|
|
|
err = db.CreateStorage(&storage)
|
|
|
|
if err != nil {
|
2022-11-13 12:17:10 +00:00
|
|
|
return storage.ID, errors.WithMessage(err, "failed create storage in database")
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
// already has an id
|
2022-12-13 10:03:30 +00:00
|
|
|
err = initStorage(ctx, storage, storageDriver)
|
2022-12-24 12:23:04 +00:00
|
|
|
go callStorageHooks("add", storageDriver)
|
2022-07-10 06:45:39 +00:00
|
|
|
if err != nil {
|
2022-12-13 10:03:30 +00:00
|
|
|
return storage.ID, errors.Wrap(err, "failed init storage but storage is already created")
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
log.Debugf("storage %+v is created", storageDriver)
|
2022-11-13 12:17:10 +00:00
|
|
|
return storage.ID, nil
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
|
2022-08-11 13:08:50 +00:00
|
|
|
// LoadStorage load exist storage in db to memory
|
|
|
|
func LoadStorage(ctx context.Context, storage model.Storage) error {
|
2022-12-17 11:49:05 +00:00
|
|
|
storage.MountPath = utils.FixAndCleanPath(storage.MountPath)
|
2022-08-11 13:08:50 +00:00
|
|
|
// check driver first
|
|
|
|
driverName := storage.Driver
|
feat: Crypt driver, improve http/webdav handling (#4884)
this PR has several enhancements, fixes, and features:
- [x] Crypt: a transparent encryption driver. Anyone can easily, and safely store encrypted data on the remote storage provider. Consider your data is safely stored in the safe, and the storage provider can only see the safe, but not your data.
- [x] Optional: compatible with [Rclone Crypt](https://rclone.org/crypt/). More ways to manipulate the encrypted data.
- [x] directory and filename encryption
- [x] server-side encryption mode (server encrypts & decrypts all data, all data flows thru the server)
- [x] obfuscate sensitive information internally
- [x] introduced a server memory-cached multi-thread downloader.
- [x] Driver: **Quark** enabled this feature, faster load in any single thread scenario. e.g. media player directly playing from the link, now it's faster.
- [x] general improvement on HTTP/WebDAV stream processing & header handling & response handling
- [x] Driver: **Mega** driver support ranged http header
- [x] Driver: **Quark** fix bug of not closing HTTP request to Quark server while user end has closed connection to alist
## Crypt, a transparent Encrypt/Decrypt Driver. (Rclone Crypt compatible)
e.g.
Crypt mount path -> /vault
Crypt remote path -> /ali/encrypted
Aliyun mount paht -> /ali
when the user uploads a.jpg to /vault, the data will be encrypted and saved to /ali/encrypted/xxxxx. And when the user wants to access a.jpg, it's automatically decrypted, and the user can do anything with it.
Since it's Rclone Crypt compatible, users can download /ali/encrypted/xxxxx and decrypt it with rclone crypt tool. Or the user can mount this folder using rclone, then mount the decrypted folder in Linux...
NB. Some breaking changes is made to make it follow global standard, e.g. processing the HTTP header properly.
close #4679
close #4827
Co-authored-by: Sean He <866155+seanhe26@users.noreply.github.com>
Co-authored-by: Andy Hsu <i@nn.ci>
2023-08-02 06:40:36 +00:00
|
|
|
driverNew, err := GetDriver(driverName)
|
2022-08-11 13:08:50 +00:00
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get driver new")
|
|
|
|
}
|
|
|
|
storageDriver := driverNew()
|
2022-12-13 10:03:30 +00:00
|
|
|
|
|
|
|
err = initStorage(ctx, storage, storageDriver)
|
2022-12-24 12:23:04 +00:00
|
|
|
go callStorageHooks("add", storageDriver)
|
2022-12-13 10:03:30 +00:00
|
|
|
log.Debugf("storage %+v is created", storageDriver)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// initStorage initialize the driver and store to storagesMap
|
|
|
|
func initStorage(ctx context.Context, storage model.Storage, storageDriver driver.Driver) (err error) {
|
|
|
|
storageDriver.SetStorage(storage)
|
|
|
|
driverStorage := storageDriver.GetStorage()
|
|
|
|
|
|
|
|
// Unmarshal Addition
|
|
|
|
err = utils.Json.UnmarshalFromString(driverStorage.Addition, storageDriver.GetAddition())
|
|
|
|
if err == nil {
|
|
|
|
err = storageDriver.Init(ctx)
|
|
|
|
}
|
|
|
|
storagesMap.Store(driverStorage.MountPath, storageDriver)
|
2022-08-11 13:08:50 +00:00
|
|
|
if err != nil {
|
2022-12-13 10:03:30 +00:00
|
|
|
driverStorage.SetStatus(err.Error())
|
|
|
|
err = errors.Wrap(err, "failed init storage")
|
2022-09-03 14:32:09 +00:00
|
|
|
} else {
|
2022-12-13 10:03:30 +00:00
|
|
|
driverStorage.SetStatus(WORK)
|
2022-08-11 13:08:50 +00:00
|
|
|
}
|
2022-12-13 10:03:30 +00:00
|
|
|
MustSaveDriverStorage(storageDriver)
|
|
|
|
return err
|
2022-08-11 13:08:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func EnableStorage(ctx context.Context, id uint) error {
|
|
|
|
storage, err := db.GetStorageById(id)
|
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get storage")
|
|
|
|
}
|
|
|
|
if !storage.Disabled {
|
|
|
|
return errors.Errorf("this storage have enabled")
|
|
|
|
}
|
|
|
|
storage.Disabled = false
|
|
|
|
err = db.UpdateStorage(storage)
|
|
|
|
if err != nil {
|
2022-09-14 07:13:02 +00:00
|
|
|
return errors.WithMessage(err, "failed update storage in db")
|
|
|
|
}
|
|
|
|
err = LoadStorage(ctx, *storage)
|
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed load storage")
|
2022-08-11 13:08:50 +00:00
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func DisableStorage(ctx context.Context, id uint) error {
|
|
|
|
storage, err := db.GetStorageById(id)
|
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get storage")
|
|
|
|
}
|
|
|
|
if storage.Disabled {
|
|
|
|
return errors.Errorf("this storage have disabled")
|
|
|
|
}
|
2022-12-18 11:51:20 +00:00
|
|
|
storageDriver, err := GetStorageByMountPath(storage.MountPath)
|
2022-08-11 13:08:50 +00:00
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get storage driver")
|
|
|
|
}
|
|
|
|
// drop the storage in the driver
|
|
|
|
if err := storageDriver.Drop(ctx); err != nil {
|
2022-12-13 10:03:30 +00:00
|
|
|
return errors.Wrap(err, "failed drop storage")
|
2022-08-11 13:08:50 +00:00
|
|
|
}
|
|
|
|
// delete the storage in the memory
|
|
|
|
storage.Disabled = true
|
2023-03-11 12:45:35 +00:00
|
|
|
storage.SetStatus(DISABLED)
|
2022-08-11 13:08:50 +00:00
|
|
|
err = db.UpdateStorage(storage)
|
|
|
|
if err != nil {
|
2022-09-14 07:13:02 +00:00
|
|
|
return errors.WithMessage(err, "failed update storage in db")
|
2022-08-11 13:08:50 +00:00
|
|
|
}
|
2022-09-14 07:13:02 +00:00
|
|
|
storagesMap.Delete(storage.MountPath)
|
2022-12-24 12:23:04 +00:00
|
|
|
go callStorageHooks("del", storageDriver)
|
2022-08-11 13:08:50 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-07-10 06:45:39 +00:00
|
|
|
// UpdateStorage update storage
|
|
|
|
// get old storage first
|
|
|
|
// drop the storage then reinitialize
|
|
|
|
func UpdateStorage(ctx context.Context, storage model.Storage) error {
|
|
|
|
oldStorage, err := db.GetStorageById(storage.ID)
|
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get old storage")
|
|
|
|
}
|
|
|
|
if oldStorage.Driver != storage.Driver {
|
|
|
|
return errors.Errorf("driver cannot be changed")
|
|
|
|
}
|
|
|
|
storage.Modified = time.Now()
|
2022-12-17 11:49:05 +00:00
|
|
|
storage.MountPath = utils.FixAndCleanPath(storage.MountPath)
|
2022-07-10 06:45:39 +00:00
|
|
|
err = db.UpdateStorage(&storage)
|
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed update storage in database")
|
|
|
|
}
|
2022-08-11 13:46:03 +00:00
|
|
|
if storage.Disabled {
|
|
|
|
return nil
|
|
|
|
}
|
2022-12-18 11:51:20 +00:00
|
|
|
storageDriver, err := GetStorageByMountPath(oldStorage.MountPath)
|
2022-07-12 06:11:37 +00:00
|
|
|
if oldStorage.MountPath != storage.MountPath {
|
2022-08-31 14:08:12 +00:00
|
|
|
// mount path renamed, need to drop the storage
|
2022-07-12 06:11:37 +00:00
|
|
|
storagesMap.Delete(oldStorage.MountPath)
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get storage driver")
|
|
|
|
}
|
|
|
|
err = storageDriver.Drop(ctx)
|
|
|
|
if err != nil {
|
2022-08-31 12:58:57 +00:00
|
|
|
return errors.Wrapf(err, "failed drop storage")
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
2022-12-13 10:03:30 +00:00
|
|
|
|
|
|
|
err = initStorage(ctx, storage, storageDriver)
|
2022-12-24 12:23:04 +00:00
|
|
|
go callStorageHooks("update", storageDriver)
|
2022-12-13 10:03:30 +00:00
|
|
|
log.Debugf("storage %+v is update", storageDriver)
|
|
|
|
return err
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func DeleteStorageById(ctx context.Context, id uint) error {
|
|
|
|
storage, err := db.GetStorageById(id)
|
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get storage")
|
|
|
|
}
|
2022-10-09 14:20:48 +00:00
|
|
|
if !storage.Disabled {
|
2022-12-18 11:51:20 +00:00
|
|
|
storageDriver, err := GetStorageByMountPath(storage.MountPath)
|
2022-10-09 14:20:48 +00:00
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed get storage driver")
|
|
|
|
}
|
|
|
|
// drop the storage in the driver
|
|
|
|
if err := storageDriver.Drop(ctx); err != nil {
|
|
|
|
return errors.Wrapf(err, "failed drop storage")
|
|
|
|
}
|
2022-12-24 12:23:04 +00:00
|
|
|
// delete the storage in the memory
|
|
|
|
storagesMap.Delete(storage.MountPath)
|
|
|
|
go callStorageHooks("del", storageDriver)
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
// delete the storage in the database
|
|
|
|
if err := db.DeleteStorageById(id); err != nil {
|
|
|
|
return errors.WithMessage(err, "failed delete storage in database")
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// MustSaveDriverStorage call from specific driver
|
|
|
|
func MustSaveDriverStorage(driver driver.Driver) {
|
|
|
|
err := saveDriverStorage(driver)
|
|
|
|
if err != nil {
|
|
|
|
log.Errorf("failed save driver storage: %s", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func saveDriverStorage(driver driver.Driver) error {
|
|
|
|
storage := driver.GetStorage()
|
|
|
|
addition := driver.GetAddition()
|
2022-12-18 11:51:20 +00:00
|
|
|
str, err := utils.Json.MarshalToString(addition)
|
2022-07-10 06:45:39 +00:00
|
|
|
if err != nil {
|
|
|
|
return errors.Wrap(err, "error while marshal addition")
|
|
|
|
}
|
2022-12-18 11:51:20 +00:00
|
|
|
storage.Addition = str
|
2022-08-30 13:52:06 +00:00
|
|
|
err = db.UpdateStorage(storage)
|
2022-07-10 06:45:39 +00:00
|
|
|
if err != nil {
|
|
|
|
return errors.WithMessage(err, "failed update storage in database")
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// getStoragesByPath get storage by longest match path, contains balance storage.
|
|
|
|
// for example, there is /a/b,/a/c,/a/d/e,/a/d/e.balance
|
|
|
|
// getStoragesByPath(/a/d/e/f) => /a/d/e,/a/d/e.balance
|
|
|
|
func getStoragesByPath(path string) []driver.Driver {
|
|
|
|
storages := make([]driver.Driver, 0)
|
|
|
|
curSlashCount := 0
|
2022-12-17 11:49:05 +00:00
|
|
|
storagesMap.Range(func(mountPath string, value driver.Driver) bool {
|
2022-12-18 11:51:20 +00:00
|
|
|
mountPath = utils.GetActualMountPath(mountPath)
|
2022-12-17 11:49:05 +00:00
|
|
|
// is this path
|
2022-12-20 08:27:04 +00:00
|
|
|
if utils.IsSubPath(mountPath, path) {
|
2022-12-18 11:51:20 +00:00
|
|
|
slashCount := strings.Count(utils.PathAddSeparatorSuffix(mountPath), "/")
|
2022-12-17 11:49:05 +00:00
|
|
|
// not the longest match
|
|
|
|
if slashCount > curSlashCount {
|
|
|
|
storages = storages[:0]
|
|
|
|
curSlashCount = slashCount
|
|
|
|
}
|
|
|
|
if slashCount == curSlashCount {
|
|
|
|
storages = append(storages, value)
|
|
|
|
}
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
return true
|
|
|
|
})
|
|
|
|
// make sure the order is the same for same input
|
|
|
|
sort.Slice(storages, func(i, j int) bool {
|
2022-07-12 06:11:37 +00:00
|
|
|
return storages[i].GetStorage().MountPath < storages[j].GetStorage().MountPath
|
2022-07-10 06:45:39 +00:00
|
|
|
})
|
|
|
|
return storages
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetStorageVirtualFilesByPath Obtain the virtual file generated by the storage according to the path
|
|
|
|
// for example, there are: /a/b,/a/c,/a/d/e,/a/b.balance1,/av
|
|
|
|
// GetStorageVirtualFilesByPath(/a) => b,c,d
|
|
|
|
func GetStorageVirtualFilesByPath(prefix string) []model.Obj {
|
|
|
|
files := make([]model.Obj, 0)
|
|
|
|
storages := storagesMap.Values()
|
|
|
|
sort.Slice(storages, func(i, j int) bool {
|
2022-09-07 07:55:15 +00:00
|
|
|
if storages[i].GetStorage().Order == storages[j].GetStorage().Order {
|
2022-07-12 06:11:37 +00:00
|
|
|
return storages[i].GetStorage().MountPath < storages[j].GetStorage().MountPath
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
2022-09-07 07:55:15 +00:00
|
|
|
return storages[i].GetStorage().Order < storages[j].GetStorage().Order
|
2022-07-10 06:45:39 +00:00
|
|
|
})
|
2022-12-17 11:49:05 +00:00
|
|
|
|
|
|
|
prefix = utils.FixAndCleanPath(prefix)
|
|
|
|
set := mapset.NewSet[string]()
|
2022-07-10 06:45:39 +00:00
|
|
|
for _, v := range storages {
|
2022-12-18 11:51:20 +00:00
|
|
|
mountPath := utils.GetActualMountPath(v.GetStorage().MountPath)
|
2022-12-17 11:49:05 +00:00
|
|
|
// Exclude prefix itself and non prefix
|
2022-12-20 08:27:04 +00:00
|
|
|
if len(prefix) >= len(mountPath) || !utils.IsSubPath(prefix, mountPath) {
|
2022-07-10 06:45:39 +00:00
|
|
|
continue
|
|
|
|
}
|
2022-12-18 11:51:20 +00:00
|
|
|
name := strings.SplitN(strings.TrimPrefix(mountPath[len(prefix):], "/"), "/", 2)[0]
|
2022-12-17 11:49:05 +00:00
|
|
|
if set.Add(name) {
|
|
|
|
files = append(files, &model.Object{
|
|
|
|
Name: name,
|
|
|
|
Size: 0,
|
|
|
|
Modified: v.GetStorage().Modified,
|
|
|
|
IsFolder: true,
|
|
|
|
})
|
2022-07-10 06:45:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return files
|
|
|
|
}
|
|
|
|
|
|
|
|
var balanceMap generic_sync.MapOf[string, int]
|
|
|
|
|
|
|
|
// GetBalancedStorage get storage by path
|
|
|
|
func GetBalancedStorage(path string) driver.Driver {
|
2022-12-17 11:49:05 +00:00
|
|
|
path = utils.FixAndCleanPath(path)
|
2022-07-10 06:45:39 +00:00
|
|
|
storages := getStoragesByPath(path)
|
|
|
|
storageNum := len(storages)
|
|
|
|
switch storageNum {
|
|
|
|
case 0:
|
|
|
|
return nil
|
|
|
|
case 1:
|
|
|
|
return storages[0]
|
|
|
|
default:
|
2022-12-18 11:51:20 +00:00
|
|
|
virtualPath := utils.GetActualMountPath(storages[0].GetStorage().MountPath)
|
2022-12-17 11:49:05 +00:00
|
|
|
i, _ := balanceMap.LoadOrStore(virtualPath, 0)
|
|
|
|
i = (i + 1) % storageNum
|
|
|
|
balanceMap.Store(virtualPath, i)
|
2022-07-10 06:45:39 +00:00
|
|
|
return storages[i]
|
|
|
|
}
|
|
|
|
}
|