diff --git a/drivers/all.go b/drivers/all.go index 5c3cc570..140908a8 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -32,6 +32,7 @@ import ( _ "github.com/alist-org/alist/v3/drivers/ftp" _ "github.com/alist-org/alist/v3/drivers/github" _ "github.com/alist-org/alist/v3/drivers/github_releases" + _ "github.com/alist-org/alist/v3/drivers/gofile" _ "github.com/alist-org/alist/v3/drivers/google_drive" _ "github.com/alist-org/alist/v3/drivers/google_photo" _ "github.com/alist-org/alist/v3/drivers/halalcloud" diff --git a/drivers/gofile/driver.go b/drivers/gofile/driver.go new file mode 100644 index 00000000..301eaef3 --- /dev/null +++ b/drivers/gofile/driver.go @@ -0,0 +1,261 @@ +package gofile + +import ( + "context" + "fmt" + + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/op" +) + +type Gofile struct { + model.Storage + Addition + + accountId string +} + +func (d *Gofile) Config() driver.Config { + return config +} + +func (d *Gofile) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *Gofile) Init(ctx context.Context) error { + if d.APIToken == "" { + return fmt.Errorf("API token is required") + } + + // Get account ID + accountId, err := d.getAccountId(ctx) + if err != nil { + return fmt.Errorf("failed to get account ID: %w", err) + } + d.accountId = accountId + + // Get account info to set root folder if not specified + if d.RootFolderID == "" { + accountInfo, err := d.getAccountInfo(ctx, accountId) + if err != nil { + return fmt.Errorf("failed to get account info: %w", err) + } + d.RootFolderID = accountInfo.Data.RootFolder + } + + // Save driver storage + op.MustSaveDriverStorage(d) + return nil +} + +func (d *Gofile) Drop(ctx context.Context) error { + return nil +} + +func (d *Gofile) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + var folderId string + if dir.GetID() == "" { + folderId = d.GetRootId() + } else { + folderId = dir.GetID() + } + + endpoint := fmt.Sprintf("/contents/%s", folderId) + + var response ContentsResponse + err := d.getJSON(ctx, endpoint, &response) + if err != nil { + return nil, err + } + + var objects []model.Obj + + // Process children or contents + contents := response.Data.Children + if contents == nil { + contents = response.Data.Contents + } + + for _, content := range contents { + objects = append(objects, d.convertContentToObj(content)) + } + + return objects, nil +} + +func (d *Gofile) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if file.IsDir() { + return nil, errs.NotFile + } + + // Create a direct link for the file + directLink, err := d.createDirectLink(ctx, file.GetID()) + if err != nil { + return nil, fmt.Errorf("failed to create direct link: %w", err) + } + + return &model.Link{ + URL: directLink, + }, nil +} + +func (d *Gofile) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + var parentId string + if parentDir.GetID() == "" { + parentId = d.GetRootId() + } else { + parentId = parentDir.GetID() + } + + data := map[string]interface{}{ + "parentFolderId": parentId, + "folderName": dirName, + } + + var response CreateFolderResponse + err := d.postJSON(ctx, "/contents/createFolder", data, &response) + if err != nil { + return nil, err + } + + return &model.Object{ + ID: response.Data.ID, + Name: response.Data.Name, + IsFolder: true, + }, nil +} + +func (d *Gofile) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + var dstId string + if dstDir.GetID() == "" { + dstId = d.GetRootId() + } else { + dstId = dstDir.GetID() + } + + data := map[string]interface{}{ + "contentsId": srcObj.GetID(), + "folderId": dstId, + } + + err := d.putJSON(ctx, "/contents/move", data, nil) + if err != nil { + return nil, err + } + + // Return updated object + return &model.Object{ + ID: srcObj.GetID(), + Name: srcObj.GetName(), + Size: srcObj.GetSize(), + Modified: srcObj.ModTime(), + IsFolder: srcObj.IsDir(), + }, nil +} + +func (d *Gofile) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + data := map[string]interface{}{ + "attribute": "name", + "attributeValue": newName, + } + + var response UpdateResponse + err := d.putJSON(ctx, fmt.Sprintf("/contents/%s/update", srcObj.GetID()), data, &response) + if err != nil { + return nil, err + } + + return &model.Object{ + ID: srcObj.GetID(), + Name: newName, + Size: srcObj.GetSize(), + Modified: srcObj.ModTime(), + IsFolder: srcObj.IsDir(), + }, nil +} + +func (d *Gofile) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + var dstId string + if dstDir.GetID() == "" { + dstId = d.GetRootId() + } else { + dstId = dstDir.GetID() + } + + data := map[string]interface{}{ + "contentsId": srcObj.GetID(), + "folderId": dstId, + } + + var response CopyResponse + err := d.postJSON(ctx, "/contents/copy", data, &response) + if err != nil { + return nil, err + } + + // Get the new ID from the response + newId := srcObj.GetID() + if response.Data.CopiedContents != nil { + if id, ok := response.Data.CopiedContents[srcObj.GetID()]; ok { + newId = id + } + } + + return &model.Object{ + ID: newId, + Name: srcObj.GetName(), + Size: srcObj.GetSize(), + Modified: srcObj.ModTime(), + IsFolder: srcObj.IsDir(), + }, nil +} + +func (d *Gofile) Remove(ctx context.Context, obj model.Obj) error { + data := map[string]interface{}{ + "contentsId": obj.GetID(), + } + + return d.deleteJSON(ctx, "/contents", data) +} + +func (d *Gofile) Put(ctx context.Context, dstDir model.Obj, fileStreamer model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + var folderId string + if dstDir.GetID() == "" { + folderId = d.GetRootId() + } else { + folderId = dstDir.GetID() + } + + response, err := d.uploadFile(ctx, folderId, fileStreamer, up) + if err != nil { + return nil, err + } + + return &model.Object{ + ID: response.Data.FileId, + Name: response.Data.FileName, + Size: fileStreamer.GetSize(), + IsFolder: false, + }, nil +} + +func (d *Gofile) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { + return nil, errs.NotImplement +} + +func (d *Gofile) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Gofile) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { + return nil, errs.NotImplement +} + +func (d *Gofile) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +var _ driver.Driver = (*Gofile)(nil) \ No newline at end of file diff --git a/drivers/gofile/meta.go b/drivers/gofile/meta.go new file mode 100644 index 00000000..b8126e33 --- /dev/null +++ b/drivers/gofile/meta.go @@ -0,0 +1,26 @@ +package gofile + +import ( + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/op" +) + +type Addition struct { + driver.RootID + APIToken string `json:"api_token" required:"true" help:"Get your API token from your Gofile profile page"` +} + +var config = driver.Config{ + Name: "Gofile", + DefaultRoot: "", + LocalSort: false, + OnlyProxy: false, + NoCache: false, + NoUpload: false, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Gofile{} + }) +} \ No newline at end of file diff --git a/drivers/gofile/types.go b/drivers/gofile/types.go new file mode 100644 index 00000000..93c9f5d2 --- /dev/null +++ b/drivers/gofile/types.go @@ -0,0 +1,124 @@ +package gofile + +import "time" + +type APIResponse struct { + Status string `json:"status"` + Data interface{} `json:"data"` +} + +type AccountResponse struct { + Status string `json:"status"` + Data struct { + ID string `json:"id"` + } `json:"data"` +} + +type AccountInfoResponse struct { + Status string `json:"status"` + Data struct { + ID string `json:"id"` + Type string `json:"type"` + Email string `json:"email"` + RootFolder string `json:"rootFolder"` + } `json:"data"` +} + +type Content struct { + ID string `json:"id"` + Type string `json:"type"` // "file" or "folder" + Name string `json:"name"` + Size int64 `json:"size,omitempty"` + CreateTime int64 `json:"createTime"` + ModTime int64 `json:"modTime,omitempty"` + DirectLink string `json:"directLink,omitempty"` + Children map[string]Content `json:"children,omitempty"` + ParentFolder string `json:"parentFolder,omitempty"` + MD5 string `json:"md5,omitempty"` + MimeType string `json:"mimeType,omitempty"` + Link string `json:"link,omitempty"` +} + +type ContentsResponse struct { + Status string `json:"status"` + Data struct { + IsOwner bool `json:"isOwner"` + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + ParentFolder string `json:"parentFolder"` + CreateTime int64 `json:"createTime"` + ChildrenList []string `json:"childrenList,omitempty"` + Children map[string]Content `json:"children,omitempty"` + Contents map[string]Content `json:"contents,omitempty"` + Public bool `json:"public,omitempty"` + Description string `json:"description,omitempty"` + Tags string `json:"tags,omitempty"` + Expiry int64 `json:"expiry,omitempty"` + } `json:"data"` +} + +type UploadResponse struct { + Status string `json:"status"` + Data struct { + DownloadPage string `json:"downloadPage"` + Code string `json:"code"` + ParentFolder string `json:"parentFolder"` + FileId string `json:"fileId"` + FileName string `json:"fileName"` + GuestToken string `json:"guestToken,omitempty"` + } `json:"data"` +} + +type DirectLinkResponse struct { + Status string `json:"status"` + Data struct { + DirectLink string `json:"directLink"` + ID string `json:"id"` + } `json:"data"` +} + +type CreateFolderResponse struct { + Status string `json:"status"` + Data struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + ParentFolder string `json:"parentFolder"` + CreateTime int64 `json:"createTime"` + } `json:"data"` +} + +type CopyResponse struct { + Status string `json:"status"` + Data struct { + CopiedContents map[string]string `json:"copiedContents"` // oldId -> newId mapping + } `json:"data"` +} + +type UpdateResponse struct { + Status string `json:"status"` + Data struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"data"` +} + +type ErrorResponse struct { + Status string `json:"status"` + Error struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error"` +} + +func (c *Content) ModifiedTime() time.Time { + if c.ModTime > 0 { + return time.Unix(c.ModTime, 0) + } + return time.Unix(c.CreateTime, 0) +} + +func (c *Content) IsDir() bool { + return c.Type == "folder" +} \ No newline at end of file diff --git a/drivers/gofile/util.go b/drivers/gofile/util.go new file mode 100644 index 00000000..5f39dae5 --- /dev/null +++ b/drivers/gofile/util.go @@ -0,0 +1,257 @@ +package gofile + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "path/filepath" + "strings" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/model" +) + +const ( + baseAPI = "https://api.gofile.io" + uploadAPI = "https://upload.gofile.io" +) + +func (d *Gofile) request(ctx context.Context, method, endpoint string, body io.Reader, headers map[string]string) (*http.Response, error) { + var url string + if strings.HasPrefix(endpoint, "http") { + url = endpoint + } else { + url = baseAPI + endpoint + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+d.APIToken) + req.Header.Set("User-Agent", "AList/3.0") + + for k, v := range headers { + req.Header.Set(k, v) + } + + return base.HttpClient.Do(req) +} + +func (d *Gofile) getJSON(ctx context.Context, endpoint string, result interface{}) error { + resp, err := d.request(ctx, "GET", endpoint, nil, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return d.handleError(resp) + } + + return json.NewDecoder(resp.Body).Decode(result) +} + +func (d *Gofile) postJSON(ctx context.Context, endpoint string, data interface{}, result interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + headers := map[string]string{ + "Content-Type": "application/json", + } + + resp, err := d.request(ctx, "POST", endpoint, bytes.NewBuffer(jsonData), headers) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return d.handleError(resp) + } + + if result != nil { + return json.NewDecoder(resp.Body).Decode(result) + } + + return nil +} + +func (d *Gofile) putJSON(ctx context.Context, endpoint string, data interface{}, result interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + headers := map[string]string{ + "Content-Type": "application/json", + } + + resp, err := d.request(ctx, "PUT", endpoint, bytes.NewBuffer(jsonData), headers) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return d.handleError(resp) + } + + if result != nil { + return json.NewDecoder(resp.Body).Decode(result) + } + + return nil +} + +func (d *Gofile) deleteJSON(ctx context.Context, endpoint string, data interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + headers := map[string]string{ + "Content-Type": "application/json", + } + + resp, err := d.request(ctx, "DELETE", endpoint, bytes.NewBuffer(jsonData), headers) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return d.handleError(resp) + } + + return nil +} + +func (d *Gofile) handleError(resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + + var errorResp ErrorResponse + if err := json.Unmarshal(body, &errorResp); err == nil { + return fmt.Errorf("gofile API error: %s (code: %s)", errorResp.Error.Message, errorResp.Error.Code) + } + + return fmt.Errorf("gofile API error: HTTP %d - %s", resp.StatusCode, string(body)) +} + +func (d *Gofile) uploadFile(ctx context.Context, folderId string, file model.FileStreamer, up driver.UpdateProgress) (*UploadResponse, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + if folderId != "" { + writer.WriteField("folderId", folderId) + } + + part, err := writer.CreateFormFile("file", filepath.Base(file.GetName())) + if err != nil { + return nil, err + } + + // Copy with progress tracking if available + if up != nil { + reader := &progressReader{ + reader: file, + total: file.GetSize(), + up: up, + } + _, err = io.Copy(part, reader) + } else { + _, err = io.Copy(part, file) + } + + if err != nil { + return nil, err + } + + writer.Close() + + headers := map[string]string{ + "Content-Type": writer.FormDataContentType(), + } + + resp, err := d.request(ctx, "POST", uploadAPI+"/uploadfile", &body, headers) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, d.handleError(resp) + } + + var result UploadResponse + err = json.NewDecoder(resp.Body).Decode(&result) + return &result, err +} + +func (d *Gofile) createDirectLink(ctx context.Context, contentId string) (string, error) { + data := map[string]interface{}{} + + var result DirectLinkResponse + err := d.postJSON(ctx, fmt.Sprintf("/contents/%s/directlinks", contentId), data, &result) + if err != nil { + return "", err + } + + return result.Data.DirectLink, nil +} + +func (d *Gofile) convertContentToObj(content Content) model.Obj { + return &model.ObjThumb{ + Object: model.Object{ + ID: content.ID, + Name: content.Name, + Size: content.Size, + Modified: content.ModifiedTime(), + IsFolder: content.IsDir(), + }, + } +} + +func (d *Gofile) getAccountId(ctx context.Context) (string, error) { + var result AccountResponse + err := d.getJSON(ctx, "/accounts/getid", &result) + if err != nil { + return "", err + } + return result.Data.ID, nil +} + +func (d *Gofile) getAccountInfo(ctx context.Context, accountId string) (*AccountInfoResponse, error) { + var result AccountInfoResponse + err := d.getJSON(ctx, fmt.Sprintf("/accounts/%s", accountId), &result) + if err != nil { + return nil, err + } + return &result, nil +} + +// progressReader wraps an io.Reader to track upload progress +type progressReader struct { + reader io.Reader + total int64 + read int64 + up driver.UpdateProgress +} + +func (pr *progressReader) Read(p []byte) (n int, err error) { + n, err = pr.reader.Read(p) + pr.read += int64(n) + if pr.up != nil && pr.total > 0 { + progress := float64(pr.read) * 100 / float64(pr.total) + pr.up(progress) + } + return n, err +}