* feat: add import command and batch import functionality - Implemented the `/import` command to allow users to import files from storage to Telegram. - Added support for listing files in storage and filtering based on regex patterns. - Created a batch import task to handle multiple file uploads concurrently. - Introduced progress tracking for batch imports, providing real-time updates to users. - Enhanced storage interfaces to support file listing and reading capabilities. - Updated localization files for the new import command and its usage instructions. - Added utility functions for file size formatting and speed calculation. - Refactored Telegram storage handling to support reading from non-seekable streams. * feat: add i18n for import command * feat: implement ListFiles and OpenFile methods for WebDAV and Alist storage * Update common/i18n/locale/zh-Hans.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/progress.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/alist/alist.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update common/i18n/locale/en.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/storagetypes/fileinfo.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update common/i18n/locale/en.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/webdav/webdav.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/telegram/telegram.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/webdav/webdav.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: missing progress stats i18n * refactor: use strutil to parse args * chore: update generated code files for consistency --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
243 lines
5.6 KiB
Go
243 lines
5.6 KiB
Go
package webdav
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
|
)
|
|
|
|
type Client struct {
|
|
BaseURL string
|
|
Username string
|
|
Password string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type WebdavMethod string
|
|
|
|
const (
|
|
WebdavMethodMkcol WebdavMethod = "MKCOL"
|
|
WebdavMethodPropfind WebdavMethod = "PROPFIND"
|
|
WebdavMethodPut WebdavMethod = "PUT"
|
|
WebdavMethodGet WebdavMethod = "GET"
|
|
)
|
|
|
|
// WebDAV XML structures for PROPFIND response
|
|
type Multistatus struct {
|
|
XMLName xml.Name `xml:"multistatus"`
|
|
Responses []Response `xml:"response"`
|
|
}
|
|
|
|
type Response struct {
|
|
Href string `xml:"href"`
|
|
Propstat Propstat `xml:"propstat"`
|
|
}
|
|
|
|
type Propstat struct {
|
|
Prop Prop `xml:"prop"`
|
|
Status string `xml:"status"`
|
|
}
|
|
|
|
type Prop struct {
|
|
ResourceType ResourceType `xml:"resourcetype"`
|
|
GetContentLength int64 `xml:"getcontentlength"`
|
|
GetLastModified string `xml:"getlastmodified"`
|
|
DisplayName string `xml:"displayname"`
|
|
}
|
|
|
|
type ResourceType struct {
|
|
Collection *struct{} `xml:"collection"`
|
|
}
|
|
|
|
func (rt ResourceType) IsCollection() bool {
|
|
return rt.Collection != nil
|
|
}
|
|
|
|
func NewClient(baseURL, username, password string, httpClient *http.Client) *Client {
|
|
if !strings.HasSuffix(baseURL, "/") {
|
|
baseURL += "/"
|
|
}
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
return &Client{
|
|
BaseURL: baseURL,
|
|
Username: username,
|
|
Password: password,
|
|
httpClient: httpClient,
|
|
}
|
|
}
|
|
|
|
func (c *Client) doRequest(ctx context.Context, method WebdavMethod, url string, body io.Reader) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, string(method), url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.Username != "" && c.Password != "" {
|
|
req.SetBasicAuth(c.Username, c.Password)
|
|
}
|
|
if method == WebdavMethodPropfind {
|
|
req.Header.Set("Depth", "1")
|
|
}
|
|
if method == WebdavMethodPut && ctx != nil {
|
|
if length := ctx.Value(ctxkey.ContentLength); length != nil {
|
|
if l, ok := length.(int64); ok {
|
|
req.ContentLength = l
|
|
}
|
|
}
|
|
}
|
|
return c.httpClient.Do(req)
|
|
}
|
|
|
|
func (c *Client) Exists(ctx context.Context, remotePath string) (bool, error) {
|
|
url := c.BaseURL + remotePath
|
|
resp, err := c.doRequest(ctx, WebdavMethodPropfind, url, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return true, nil
|
|
}
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("PROPFIND: %s", resp.Status)
|
|
}
|
|
|
|
func (c *Client) MkDir(ctx context.Context, dirPath string) error {
|
|
dirPath = strings.Trim(dirPath, "/")
|
|
if dirPath == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(dirPath, "/")
|
|
currentPath := ""
|
|
for i, part := range parts {
|
|
if i > 0 {
|
|
currentPath += "/"
|
|
}
|
|
currentPath += part
|
|
|
|
exists, err := c.Exists(ctx, currentPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
url := c.BaseURL + currentPath
|
|
resp, err := c.doRequest(ctx, WebdavMethodMkcol, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("MKCOL %s: %s", currentPath, resp.Status)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) WriteFile(ctx context.Context, remotePath string, content io.Reader) error {
|
|
u, err := url.Parse(c.BaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parts := strings.Split(strings.Trim(remotePath, "/"), "/")
|
|
u.Path = path.Join(u.Path, strings.Join(parts, "/"))
|
|
resp, err := c.doRequest(ctx, WebdavMethodPut, u.String(), content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("PUT: %s", resp.Status)
|
|
}
|
|
|
|
// ListDir lists files and directories in the given path
|
|
func (c *Client) ListDir(ctx context.Context, dirPath string) ([]Response, error) {
|
|
dirPath = strings.Trim(dirPath, "/")
|
|
u, err := url.Parse(c.BaseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.Path = path.Join(u.Path, dirPath)
|
|
if !strings.HasSuffix(u.Path, "/") {
|
|
u.Path += "/"
|
|
}
|
|
|
|
resp, err := c.doRequest(ctx, WebdavMethodPropfind, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusMultiStatus {
|
|
return nil, fmt.Errorf("PROPFIND: %s", resp.Status)
|
|
}
|
|
|
|
var multistatus Multistatus
|
|
if err := xml.NewDecoder(resp.Body).Decode(&multistatus); err != nil {
|
|
return nil, fmt.Errorf("failed to decode PROPFIND response: %w", err)
|
|
}
|
|
|
|
// Filter out the directory itself from results
|
|
var results []Response
|
|
basePath := u.Path
|
|
for _, r := range multistatus.Responses {
|
|
decodedHref, err := url.PathUnescape(r.Href)
|
|
if err != nil {
|
|
decodedHref = r.Href
|
|
}
|
|
// Skip the directory itself
|
|
if strings.TrimSuffix(decodedHref, "/") == strings.TrimSuffix(basePath, "/") {
|
|
continue
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// ReadFile downloads a file and returns a ReadCloser
|
|
func (c *Client) ReadFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
|
|
filePath = strings.Trim(filePath, "/")
|
|
u, err := url.Parse(c.BaseURL)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
u.Path = path.Join(u.Path, filePath)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if c.Username != "" && c.Password != "" {
|
|
req.SetBasicAuth(c.Username, c.Password)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil, 0, fmt.Errorf("GET: %s", resp.Status)
|
|
}
|
|
|
|
return resp.Body, resp.ContentLength, nil
|
|
}
|