mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-09-05 07:36:36 +08:00
feat: implement ListFiles and OpenFile methods for WebDAV and Alist storage
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
config "github.com/krau/SaveAny-Bot/config/storage"
|
config "github.com/krau/SaveAny-Bot/config/storage"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Alist struct {
|
type Alist struct {
|
||||||
@@ -215,3 +216,145 @@ func (a *Alist) Exists(ctx context.Context, storagePath string) bool {
|
|||||||
func (a *Alist) CannotStream() string {
|
func (a *Alist) CannotStream() string {
|
||||||
return "Alist does not support chunked transfer encoding"
|
return "Alist does not support chunked transfer encoding"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListFiles implements StorageListable interface
|
||||||
|
func (a *Alist) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
|
||||||
|
a.logger.Debugf("Listing files in directory: %s", dirPath)
|
||||||
|
|
||||||
|
reqBody := fsListRequest{
|
||||||
|
Path: dirPath,
|
||||||
|
Password: "",
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 0, // 0 means all files
|
||||||
|
Refresh: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/api/fs/list", bytes.NewBuffer(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", a.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to list files: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listResp fsListResponse
|
||||||
|
if err := json.Unmarshal(data, &listResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal list response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if listResp.Code != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to list files: %d, %s", listResp.Code, listResp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]storagetypes.FileInfo, 0, len(listResp.Data.Content))
|
||||||
|
for _, item := range listResp.Data.Content {
|
||||||
|
// Parse modified time
|
||||||
|
modTime, _ := time.Parse(time.RFC3339, item.Modified)
|
||||||
|
|
||||||
|
files = append(files, storagetypes.FileInfo{
|
||||||
|
Name: item.Name,
|
||||||
|
Path: path.Join(dirPath, item.Name),
|
||||||
|
Size: item.Size,
|
||||||
|
IsDir: item.IsDir,
|
||||||
|
ModTime: modTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logger.Debugf("Found %d files in directory %s", len(files), dirPath)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFile implements StorageReadable interface
|
||||||
|
func (a *Alist) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
|
||||||
|
a.logger.Debugf("Opening file: %s", filePath)
|
||||||
|
|
||||||
|
// First, get file info to get the raw_url
|
||||||
|
reqBody := map[string]any{
|
||||||
|
"path": filePath,
|
||||||
|
"password": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/api/fs/get", bytes.NewBuffer(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", a.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, 0, fmt.Errorf("failed to get file info: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var getResp fsGetResponse
|
||||||
|
if err := json.Unmarshal(data, &getResp); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to unmarshal get response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if getResp.Code != http.StatusOK {
|
||||||
|
return nil, 0, fmt.Errorf("failed to get file info: %d, %s", getResp.Code, getResp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if getResp.Data.IsDir {
|
||||||
|
return nil, 0, fmt.Errorf("path is a directory, not a file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file from raw_url
|
||||||
|
downloadURL := getResp.Data.RawURL
|
||||||
|
if downloadURL == "" {
|
||||||
|
// If no raw_url, construct download URL
|
||||||
|
downloadURL = a.baseURL + "/d" + filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to create download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadResp, err := a.client.Do(downloadReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to download file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if downloadResp.StatusCode != http.StatusOK {
|
||||||
|
downloadResp.Body.Close()
|
||||||
|
return nil, 0, fmt.Errorf("failed to download file: %s", downloadResp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logger.Debugf("Opened file %s, size: %d bytes", filePath, getResp.Data.Size)
|
||||||
|
return downloadResp.Body, getResp.Data.Size, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,4 +46,46 @@ type putResponse struct {
|
|||||||
type fsGetResponse struct {
|
type fsGetResponse struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
Data struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
Modified string `json:"modified"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
Thumb string `json:"thumb"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
RawURL string `json:"raw_url"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fsListRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PerPage int `json:"per_page"`
|
||||||
|
Refresh bool `json:"refresh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fsListResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data struct {
|
||||||
|
Content []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
Modified string `json:"modified"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
Thumb string `json:"thumb"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
} `json:"content"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Readme string `json:"readme"`
|
||||||
|
Header string `json:"header"`
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package webdav
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,8 +26,40 @@ const (
|
|||||||
WebdavMethodMkcol WebdavMethod = "MKCOL"
|
WebdavMethodMkcol WebdavMethod = "MKCOL"
|
||||||
WebdavMethodPropfind WebdavMethod = "PROPFIND"
|
WebdavMethodPropfind WebdavMethod = "PROPFIND"
|
||||||
WebdavMethodPut WebdavMethod = "PUT"
|
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 {
|
func NewClient(baseURL, username, password string, httpClient *http.Client) *Client {
|
||||||
if !strings.HasSuffix(baseURL, "/") {
|
if !strings.HasSuffix(baseURL, "/") {
|
||||||
baseURL += "/"
|
baseURL += "/"
|
||||||
@@ -131,5 +164,79 @@ func (c *Client) WriteFile(ctx context.Context, remotePath string, content io.Re
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("PUT: %s", resp.Status)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
config "github.com/krau/SaveAny-Bot/config/storage"
|
config "github.com/krau/SaveAny-Bot/config/storage"
|
||||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,3 +86,71 @@ func (w *Webdav) Exists(ctx context.Context, storagePath string) bool {
|
|||||||
}
|
}
|
||||||
return exists
|
return exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListFiles implements storage.StorageListable
|
||||||
|
func (w *Webdav) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
|
||||||
|
w.logger.Infof("Listing files in %s", dirPath)
|
||||||
|
|
||||||
|
// Join with base path
|
||||||
|
fullPath := path.Join(w.config.BasePath, dirPath)
|
||||||
|
|
||||||
|
responses, err := w.client.ListDir(ctx, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Errorf("Failed to list directory %s: %v", fullPath, err)
|
||||||
|
return nil, fmt.Errorf("failed to list directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]storagetypes.FileInfo, 0, len(responses))
|
||||||
|
for _, resp := range responses {
|
||||||
|
// Parse the href to get the file name
|
||||||
|
decodedHref, err := url.PathUnescape(resp.Href)
|
||||||
|
if err != nil {
|
||||||
|
decodedHref = resp.Href
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract filename from href
|
||||||
|
name := path.Base(strings.TrimSuffix(decodedHref, "/"))
|
||||||
|
if name == "" || name == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse modification time
|
||||||
|
var modTime time.Time
|
||||||
|
if resp.Propstat.Prop.GetLastModified != "" {
|
||||||
|
// Try RFC1123 format (standard for WebDAV)
|
||||||
|
modTime, _ = time.Parse(time.RFC1123, resp.Propstat.Prop.GetLastModified)
|
||||||
|
}
|
||||||
|
|
||||||
|
isDir := resp.Propstat.Prop.ResourceType.IsCollection()
|
||||||
|
|
||||||
|
fileInfo := storagetypes.FileInfo{
|
||||||
|
Name: name,
|
||||||
|
Path: strings.TrimPrefix(decodedHref, w.config.BasePath),
|
||||||
|
Size: resp.Propstat.Prop.GetContentLength,
|
||||||
|
IsDir: isDir,
|
||||||
|
ModTime: modTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, fileInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Debugf("Found %d files/directories in %s", len(files), dirPath)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFile implements storage.StorageReadable
|
||||||
|
func (w *Webdav) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
|
||||||
|
w.logger.Infof("Opening file %s", filePath)
|
||||||
|
|
||||||
|
// Join with base path
|
||||||
|
fullPath := path.Join(w.config.BasePath, filePath)
|
||||||
|
|
||||||
|
reader, size, err := w.client.ReadFile(ctx, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Errorf("Failed to open file %s: %v", fullPath, err)
|
||||||
|
return nil, 0, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Debugf("Opened file %s (size: %d bytes)", filePath, size)
|
||||||
|
return reader, size, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user