Add storage driver for Darkibox (https://darkibox.com/), an XFileSharing-based video hosting platform. Implements the Handler interface: - Put: two-step upload via upload server - Delete: file deletion via API - Source: direct link generation with quality selection - List: recursive folder/file listing with pagination - Folder management: auto-create path on upload Uses policy.AccessKey for the API key. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>pull/3429/head
parent
58b83ea1e4
commit
84cf0c5f9c
@ -0,0 +1,516 @@
|
||||
package darkibox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/request"
|
||||
)
|
||||
|
||||
const apiBase = "https://darkibox.com/api"
|
||||
|
||||
// Driver implements driver.Handler for darkibox.com video hosting.
|
||||
type Driver struct {
|
||||
policy *ent.StoragePolicy
|
||||
apiKey string
|
||||
httpClient request.Client
|
||||
l logging.Logger
|
||||
}
|
||||
|
||||
var features = &boolset.BooleanSet{}
|
||||
|
||||
func init() {
|
||||
boolset.Set(driver.HandlerCapabilityProxyRequired, true, features)
|
||||
}
|
||||
|
||||
// New creates a new Darkibox driver.
|
||||
func New(_ context.Context, policy *ent.StoragePolicy, _ conf.ConfigProvider, l logging.Logger) (*Driver, error) {
|
||||
return &Driver{
|
||||
policy: policy,
|
||||
apiKey: policy.AccessKey,
|
||||
httpClient: request.NewClientDeprecated(request.WithLogger(l)),
|
||||
l: l,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// apiResponse is the generic envelope returned by all Darkibox API calls.
|
||||
type apiResponse struct {
|
||||
Status int `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
// ── file/list result types ──────────────────────────────────────────
|
||||
|
||||
type fileListResult struct {
|
||||
Results int `json:"results"`
|
||||
Files []fileListEntry `json:"files"`
|
||||
}
|
||||
|
||||
type fileListEntry struct {
|
||||
FileCode string `json:"file_code"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name,omitempty"`
|
||||
FldID int `json:"fld_id"`
|
||||
Size int64 `json:"size"`
|
||||
Uploaded string `json:"uploaded"`
|
||||
}
|
||||
|
||||
// ── folder/list result types ────────────────────────────────────────
|
||||
|
||||
type folderListResult struct {
|
||||
Folders []folderEntry `json:"folders"`
|
||||
}
|
||||
|
||||
type folderEntry struct {
|
||||
FldID int `json:"fld_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ── file/direct_link result ─────────────────────────────────────────
|
||||
|
||||
type directLinkResult struct {
|
||||
Versions []directLinkVersion `json:"versions"`
|
||||
}
|
||||
|
||||
type directLinkVersion struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ── upload/server result ────────────────────────────────────────────
|
||||
|
||||
type uploadServerResult struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ── folder/create result ────────────────────────────────────────────
|
||||
|
||||
type folderCreateResult struct {
|
||||
FldID int `json:"fld_id"`
|
||||
}
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────────
|
||||
|
||||
// apiGet performs a GET request against the Darkibox API and decodes the
|
||||
// JSON envelope. The raw `result` field is returned for the caller to
|
||||
// unmarshal into the appropriate type.
|
||||
func (d *Driver) apiGet(ctx context.Context, endpoint string, params map[string]string) (*apiResponse, error) {
|
||||
u := apiBase + endpoint + "?key=" + d.apiKey
|
||||
for k, v := range params {
|
||||
u += "&" + k + "=" + v
|
||||
}
|
||||
|
||||
resp := d.httpClient.Request("GET", u, nil, request.WithContext(ctx))
|
||||
body, err := resp.GetResponse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("darkibox: request %s failed: %w", endpoint, err)
|
||||
}
|
||||
|
||||
var ar apiResponse
|
||||
if err := json.Unmarshal([]byte(body), &ar); err != nil {
|
||||
return nil, fmt.Errorf("darkibox: decode response for %s: %w", endpoint, err)
|
||||
}
|
||||
if ar.Status != 200 {
|
||||
return nil, fmt.Errorf("darkibox: %s returned status %d: %s", endpoint, ar.Status, ar.Msg)
|
||||
}
|
||||
return &ar, nil
|
||||
}
|
||||
|
||||
// ── Handler interface ───────────────────────────────────────────────
|
||||
|
||||
// Put uploads a file to Darkibox via the two-step upload flow:
|
||||
// 1. GET /api/upload/server to obtain the upload URL.
|
||||
// 2. POST multipart form to that URL with key, file, and fld_id.
|
||||
//
|
||||
// The file is stored under the folder derived from the save path. Folders
|
||||
// along the path are created automatically.
|
||||
func (d *Driver) Put(ctx context.Context, file *fs.UploadRequest) error {
|
||||
defer file.Close()
|
||||
|
||||
// Resolve (or create) the target folder.
|
||||
dir := path.Dir(file.Props.SavePath)
|
||||
fldID, err := d.ensureFolder(ctx, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("darkibox put: resolve folder: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: obtain the upload server URL.
|
||||
ar, err := d.apiGet(ctx, "/upload/server", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var srv uploadServerResult
|
||||
if err := json.Unmarshal(ar.Result, &srv); err != nil {
|
||||
return fmt.Errorf("darkibox: decode upload server: %w", err)
|
||||
}
|
||||
if srv.URL == "" {
|
||||
return errors.New("darkibox: empty upload server URL")
|
||||
}
|
||||
|
||||
// Step 2: multipart upload.
|
||||
pr, pw := io.Pipe()
|
||||
writer := multipart.NewWriter(pw)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
_ = writer.WriteField("key", d.apiKey)
|
||||
_ = writer.WriteField("fld_id", strconv.Itoa(fldID))
|
||||
|
||||
part, err := writer.CreateFormFile("file", path.Base(file.Props.SavePath))
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
errCh <- writer.Close()
|
||||
}()
|
||||
|
||||
resp := d.httpClient.Request("POST", srv.URL, pr,
|
||||
request.WithContext(ctx),
|
||||
request.WithHeader(http.Header{
|
||||
"Content-Type": {writer.FormDataContentType()},
|
||||
}),
|
||||
)
|
||||
|
||||
// Wait for the writer goroutine to finish.
|
||||
if writeErr := <-errCh; writeErr != nil {
|
||||
return fmt.Errorf("darkibox put: write multipart: %w", writeErr)
|
||||
}
|
||||
|
||||
body, err := resp.GetResponse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("darkibox put: upload request: %w", err)
|
||||
}
|
||||
|
||||
var uploadResp apiResponse
|
||||
if err := json.Unmarshal([]byte(body), &uploadResp); err != nil {
|
||||
return fmt.Errorf("darkibox put: decode upload response: %w", err)
|
||||
}
|
||||
if uploadResp.Status != 200 {
|
||||
return fmt.Errorf("darkibox put: upload returned status %d: %s", uploadResp.Status, uploadResp.Msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes files by their source paths (which are darkibox file codes).
|
||||
func (d *Driver) Delete(ctx context.Context, files ...string) ([]string, error) {
|
||||
var failed []string
|
||||
var lastErr error
|
||||
|
||||
for _, f := range files {
|
||||
code := fileCodeFromSource(f)
|
||||
_, err := d.apiGet(ctx, "/file/delete", map[string]string{
|
||||
"file_code": code,
|
||||
})
|
||||
if err != nil {
|
||||
failed = append(failed, f)
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return failed, lastErr
|
||||
}
|
||||
|
||||
// Source returns a direct download URL for the given entity.
|
||||
func (d *Driver) Source(ctx context.Context, e fs.Entity, _ *driver.GetSourceArgs) (string, error) {
|
||||
code := fileCodeFromSource(e.Source())
|
||||
ar, err := d.apiGet(ctx, "/file/direct_link", map[string]string{
|
||||
"file_code": code,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var dl directLinkResult
|
||||
if err := json.Unmarshal(ar.Result, &dl); err != nil {
|
||||
return "", fmt.Errorf("darkibox: decode direct_link: %w", err)
|
||||
}
|
||||
|
||||
// Pick the original ("o") version, fall back to first available.
|
||||
for _, v := range dl.Versions {
|
||||
if v.Name == "o" {
|
||||
return v.URL, nil
|
||||
}
|
||||
}
|
||||
if len(dl.Versions) > 0 {
|
||||
return dl.Versions[0].URL, nil
|
||||
}
|
||||
|
||||
return "", errors.New("darkibox: no direct link version available")
|
||||
}
|
||||
|
||||
// List lists files and folders under the given base path.
|
||||
func (d *Driver) List(ctx context.Context, base string, onProgress driver.ListProgressFunc, recursive bool) ([]fs.PhysicalObject, error) {
|
||||
base = strings.TrimPrefix(base, "/")
|
||||
|
||||
fldID, err := d.resolveFolderID(ctx, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return d.listFolder(ctx, fldID, base, onProgress, recursive)
|
||||
}
|
||||
|
||||
func (d *Driver) listFolder(ctx context.Context, fldID int, prefix string, onProgress driver.ListProgressFunc, recursive bool) ([]fs.PhysicalObject, error) {
|
||||
var objects []fs.PhysicalObject
|
||||
|
||||
// List sub-folders.
|
||||
ar, err := d.apiGet(ctx, "/folder/list", map[string]string{
|
||||
"fld_id": strconv.Itoa(fldID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var fl folderListResult
|
||||
if err := json.Unmarshal(ar.Result, &fl); err != nil {
|
||||
return nil, fmt.Errorf("darkibox: decode folder list: %w", err)
|
||||
}
|
||||
|
||||
for _, folder := range fl.Folders {
|
||||
rel := folder.Name
|
||||
if prefix != "" {
|
||||
rel = prefix + "/" + folder.Name
|
||||
}
|
||||
objects = append(objects, fs.PhysicalObject{
|
||||
Name: folder.Name,
|
||||
RelativePath: rel,
|
||||
Source: rel,
|
||||
Size: 0,
|
||||
IsDir: true,
|
||||
LastModify: time.Now(),
|
||||
})
|
||||
onProgress(1)
|
||||
|
||||
if recursive {
|
||||
children, err := d.listFolder(ctx, folder.FldID, rel, onProgress, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects = append(objects, children...)
|
||||
}
|
||||
}
|
||||
|
||||
// List files (paginated).
|
||||
page := 1
|
||||
for {
|
||||
far, err := d.apiGet(ctx, "/file/list", map[string]string{
|
||||
"fld_id": strconv.Itoa(fldID),
|
||||
"per_page": "200",
|
||||
"page": strconv.Itoa(page),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var flr fileListResult
|
||||
if err := json.Unmarshal(far.Result, &flr); err != nil {
|
||||
return nil, fmt.Errorf("darkibox: decode file list: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range flr.Files {
|
||||
name := f.Title
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
rel := name
|
||||
if prefix != "" {
|
||||
rel = prefix + "/" + name
|
||||
}
|
||||
|
||||
uploaded, _ := time.Parse("2006-01-02 15:04:05", f.Uploaded)
|
||||
|
||||
objects = append(objects, fs.PhysicalObject{
|
||||
Name: name,
|
||||
RelativePath: rel,
|
||||
Source: f.FileCode,
|
||||
Size: f.Size,
|
||||
IsDir: false,
|
||||
LastModify: uploaded,
|
||||
})
|
||||
onProgress(1)
|
||||
}
|
||||
|
||||
if len(flr.Files) < 200 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// Token returns upload credentials. For Darkibox the actual upload happens
|
||||
// server-side via Put, so we return a minimal credential pointing at the
|
||||
// Cloudreve relay endpoint.
|
||||
func (d *Driver) Token(ctx context.Context, uploadSession *fs.UploadSession, file *fs.UploadRequest) (*fs.UploadCredential, error) {
|
||||
return &fs.UploadCredential{
|
||||
SessionID: uploadSession.Props.UploadSessionID,
|
||||
Expires: uploadSession.Props.ExpireAt.Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelToken is a no-op for Darkibox.
|
||||
func (d *Driver) CancelToken(_ context.Context, _ *fs.UploadSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteUpload is a no-op for Darkibox (upload is atomic via Put).
|
||||
func (d *Driver) CompleteUpload(_ context.Context, _ *fs.UploadSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Thumb is not supported by Darkibox.
|
||||
func (d *Driver) Thumb(_ context.Context, _ *time.Time, _ string, _ fs.Entity) (string, error) {
|
||||
return "", errors.New("not implemented")
|
||||
}
|
||||
|
||||
// Open is not supported (no local filesystem access).
|
||||
func (d *Driver) Open(_ context.Context, _ string) (*os.File, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
// LocalPath is not supported.
|
||||
func (d *Driver) LocalPath(_ context.Context, _ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Capabilities returns the driver capabilities.
|
||||
func (d *Driver) Capabilities() *driver.Capabilities {
|
||||
return &driver.Capabilities{
|
||||
StaticFeatures: features,
|
||||
}
|
||||
}
|
||||
|
||||
// MediaMeta is not supported.
|
||||
func (d *Driver) MediaMeta(_ context.Context, _, _, _ string) ([]driver.MediaMeta, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ── Folder helpers ──────────────────────────────────────────────────
|
||||
|
||||
// resolveFolderID walks the path components starting from root (fld_id=0)
|
||||
// and returns the fld_id of the deepest folder. Returns 0 for the root.
|
||||
func (d *Driver) resolveFolderID(ctx context.Context, p string) (int, error) {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" || p == "." {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(p, "/")
|
||||
currentID := 0
|
||||
|
||||
for _, part := range parts {
|
||||
ar, err := d.apiGet(ctx, "/folder/list", map[string]string{
|
||||
"fld_id": strconv.Itoa(currentID),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var fl folderListResult
|
||||
if err := json.Unmarshal(ar.Result, &fl); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, folder := range fl.Folders {
|
||||
if folder.Name == part {
|
||||
currentID = folder.FldID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 0, fmt.Errorf("darkibox: folder %q not found under fld_id=%d", part, currentID)
|
||||
}
|
||||
}
|
||||
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
// ensureFolder is like resolveFolderID but creates missing folders along
|
||||
// the path.
|
||||
func (d *Driver) ensureFolder(ctx context.Context, p string) (int, error) {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" || p == "." {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(p, "/")
|
||||
currentID := 0
|
||||
|
||||
for _, part := range parts {
|
||||
ar, err := d.apiGet(ctx, "/folder/list", map[string]string{
|
||||
"fld_id": strconv.Itoa(currentID),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var fl folderListResult
|
||||
if err := json.Unmarshal(ar.Result, &fl); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, folder := range fl.Folders {
|
||||
if folder.Name == part {
|
||||
currentID = folder.FldID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Create the folder.
|
||||
car, err := d.apiGet(ctx, "/folder/create", map[string]string{
|
||||
"name": part,
|
||||
"parent_id": strconv.Itoa(currentID),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("darkibox: create folder %q: %w", part, err)
|
||||
}
|
||||
|
||||
var cr folderCreateResult
|
||||
if err := json.Unmarshal(car.Result, &cr); err != nil {
|
||||
return 0, fmt.Errorf("darkibox: decode folder create: %w", err)
|
||||
}
|
||||
currentID = cr.FldID
|
||||
}
|
||||
}
|
||||
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
// fileCodeFromSource extracts the file code from a source string.
|
||||
// The source may be a plain file code or a path ending with the code.
|
||||
func fileCodeFromSource(source string) string {
|
||||
source = strings.TrimSpace(source)
|
||||
if idx := strings.LastIndex(source, "/"); idx >= 0 {
|
||||
return source[idx+1:]
|
||||
}
|
||||
return source
|
||||
}
|
||||
Loading…
Reference in new issue