mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-29 18:05:37 +08:00
feat(downloads): add remote download workers
This commit is contained in:
@@ -11,6 +11,33 @@ env:
|
||||
E2E_CLOUD_PRO_PASSWORD: ${{ secrets.E2E_CLOUD_PRO_PASSWORD }}
|
||||
|
||||
jobs:
|
||||
downloader:
|
||||
name: Downloader (Go)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'saltbo/zpan'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: downloader
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: downloader/go.mod
|
||||
cache-dependency-path: downloader/go.sum
|
||||
- name: Check formatting
|
||||
run: |
|
||||
files="$(gofmt -l .)"
|
||||
if [ -n "$files" ]; then
|
||||
echo "$files"
|
||||
exit 1
|
||||
fi
|
||||
- run: go test ./...
|
||||
- name: Install live downloader engines
|
||||
run: sudo apt-get update && sudo apt-get install -y aria2 qbittorrent-nox
|
||||
working-directory: .
|
||||
- name: Verify live downloads
|
||||
run: LIVE_DOWNLOAD_VERIFY=1 go test ./internal/engine -run 'TestLive(DownloadThreeSourceTypes|QBittorrentDownloadTorrentURL)' -count=1 -v
|
||||
|
||||
check:
|
||||
name: Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/saltbo/zpan/downloader/internal/client"
|
||||
"github.com/saltbo/zpan/downloader/internal/config"
|
||||
"github.com/saltbo/zpan/downloader/internal/worker"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
if err := rootCommand().Execute(); err != nil {
|
||||
slog.Error("command failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func rootCommand() *cobra.Command {
|
||||
v := viper.New()
|
||||
var cfgFile string
|
||||
var logLevel string
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "zpan-downloader",
|
||||
Short: "Remote downloader for ZPan",
|
||||
}
|
||||
root.PersistentFlags().StringVar(&cfgFile, "config", config.DefaultConfigPath(), "config file")
|
||||
root.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
|
||||
root.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
setLogLevel(logLevel)
|
||||
v.SetConfigFile(cfgFile)
|
||||
}
|
||||
|
||||
root.AddCommand(runCommand(v))
|
||||
root.AddCommand(loginCommand(v, &cfgFile))
|
||||
root.AddCommand(configCommand(v, &cfgFile))
|
||||
return root
|
||||
}
|
||||
|
||||
func setLogLevel(level string) {
|
||||
var parsed slog.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
parsed = slog.LevelDebug
|
||||
case "info", "":
|
||||
parsed = slog.LevelInfo
|
||||
case "warn", "warning":
|
||||
parsed = slog.LevelWarn
|
||||
case "error":
|
||||
parsed = slog.LevelError
|
||||
default:
|
||||
slog.Warn("unknown log level, using info", "log_level", level)
|
||||
parsed = slog.LevelInfo
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: parsed})))
|
||||
}
|
||||
|
||||
func runCommand(v *viper.Viper) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Start the downloader worker",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
slog.Info("loading downloader config")
|
||||
cfg, err := config.Load(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("downloader config loaded",
|
||||
"server_url", cfg.ServerURL,
|
||||
"engine", cfg.Engine,
|
||||
"download_dir", cfg.DownloadDir,
|
||||
"poll_interval", cfg.PollInterval.String(),
|
||||
"max_concurrent_tasks", cfg.MaxConcurrentTasks,
|
||||
)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return worker.New(cfg).Run(ctx)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loginCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
|
||||
var name string
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Register this downloader with ZPan using device login",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
slog.Info("starting downloader device login")
|
||||
config.Defaults(v)
|
||||
v.SetConfigFile(*cfgFile)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
serverURL := strings.TrimRight(v.GetString("server_url"), "/")
|
||||
if serverURL == "" {
|
||||
return fmt.Errorf("server_url is required")
|
||||
}
|
||||
slog.Info("requesting device login code", "server_url", serverURL)
|
||||
if name == "" {
|
||||
hostname, _ := os.Hostname()
|
||||
name = hostname
|
||||
if name == "" {
|
||||
name = "zpan-downloader"
|
||||
}
|
||||
}
|
||||
|
||||
api := client.New(serverURL, "")
|
||||
ctx := cmd.Context()
|
||||
code, err := api.RequestDeviceCode(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to authorize the downloader:\n%s\n\n", code.VerificationURIComplete)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "User code: %s\n", code.UserCode)
|
||||
|
||||
slog.Info("waiting for device authorization", "user_code", code.UserCode)
|
||||
token, err := pollDeviceToken(ctx, api, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("device authorization completed")
|
||||
registered, err := api.CreateDownloader(ctx, token.AccessToken, client.CreateDownloaderRequest{
|
||||
Name: name,
|
||||
Heartbeat: loginHeartbeat(v),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("downloader registered", "downloader_id", registered.Downloader.ID)
|
||||
|
||||
v.Set("server_url", serverURL)
|
||||
v.Set("token", registered.Token)
|
||||
if err := os.MkdirAll(filepath.Dir(*cfgFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(*cfgFile); err == nil {
|
||||
if err := v.WriteConfigAs(*cfgFile); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := v.SafeWriteConfigAs(*cfgFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Downloader registered: %s\n", registered.Downloader.ID)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Config saved: %s\n", *cfgFile)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "downloader name")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage downloader configuration",
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Create a default config file",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config.Defaults(v)
|
||||
v.SetConfigFile(*cfgFile)
|
||||
if err := os.MkdirAll(filepath.Dir(*cfgFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.SafeWriteConfigAs(*cfgFile)
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pollDeviceToken(ctx context.Context, api *client.Client, code client.DeviceCode) (client.DeviceToken, error) {
|
||||
interval := time.Duration(code.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(code.ExpiresIn) * time.Second)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
return client.DeviceToken{}, fmt.Errorf("device login expired")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return client.DeviceToken{}, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
token, err := api.PollDeviceToken(ctx, code.DeviceCode)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
if !isPendingDeviceAuthError(err) {
|
||||
return client.DeviceToken{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPendingDeviceAuthError(err error) bool {
|
||||
message := err.Error()
|
||||
return strings.Contains(message, "authorization_pending") || strings.Contains(message, "slow_down")
|
||||
}
|
||||
|
||||
func loginHeartbeat(v *viper.Viper) client.Heartbeat {
|
||||
hostname, _ := os.Hostname()
|
||||
engine := v.GetString("engine")
|
||||
if engine == "" {
|
||||
engine = "builtin"
|
||||
}
|
||||
return client.Heartbeat{
|
||||
Version: worker.Version,
|
||||
Hostname: hostname,
|
||||
Platform: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Engine: engine,
|
||||
Capabilities: loginCapabilities(engine),
|
||||
MaxConcurrentTasks: v.GetInt("max_concurrent_tasks"),
|
||||
CurrentTasks: 0,
|
||||
DownloadBps: 0,
|
||||
UploadBps: 0,
|
||||
FreeDiskBytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func loginCapabilities(name string) []string {
|
||||
switch name {
|
||||
case "aria2", "qbittorrent":
|
||||
return []string{"http", "magnet", "torrent"}
|
||||
default:
|
||||
return []string{"http"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
module github.com/saltbo/zpan/downloader
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Braurbeki/arigo v0.0.7
|
||||
github.com/autobrr/go-qbittorrent v1.16.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver v1.5.0 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/avast/retry-go v3.0.0+incompatible // indirect
|
||||
github.com/cenkalti/hub v1.0.1-0.20160527103212-11382a9960d3 // indirect
|
||||
github.com/cenkalti/rpc2 v0.0.0-20180727162946-9642ea02d0aa // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.4.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/oapi-codegen/runtime v1.4.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
github.com/Braurbeki/arigo v0.0.7 h1:Q8duF/50OLcrSOrxHPdWrONmxDB8EyI/MnSbLLTdCj8=
|
||||
github.com/Braurbeki/arigo v0.0.7/go.mod h1:XPbQVYhH1+fWA2L+fFwlgtiDTIafqzx3A3ULnn9fPyQ=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/autobrr/go-qbittorrent v1.16.0 h1:H0zwzLOaCxvJTxJ3xe4+hV3rFSuxucsgKQxsGHydXCU=
|
||||
github.com/autobrr/go-qbittorrent v1.16.0/go.mod h1:s36a75VlatWPpHI+pNg4Ta6eB3fRxQvTZPfgMtOsQfY=
|
||||
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/cenk/hub v1.0.1 h1:RBwXNOF4a8KjD8BJ08XqN8KbrqaGiQLDrgvUGJSHuPA=
|
||||
github.com/cenk/hub v1.0.1/go.mod h1:rJM1LNAW0ppT8FMMuPK6c2NP/R2nH/UthtuRySSaf6Y=
|
||||
github.com/cenkalti/hub v1.0.1-0.20160527103212-11382a9960d3 h1:JoNNeZqjMj74cMtMUi456vOlL/4Kwk1C3sU6e62caJA=
|
||||
github.com/cenkalti/hub v1.0.1-0.20160527103212-11382a9960d3/go.mod h1:tcYwtS3a2d9NO/0xDXVJWx3IedurUjYCqFCmpi0lpHs=
|
||||
github.com/cenkalti/rpc2 v0.0.0-20180727162946-9642ea02d0aa h1:t+iWhuJE2aropY4uxKMVbyP+IJ29o422f7YAd73aTjg=
|
||||
github.com/cenkalti/rpc2 v0.0.0-20180727162946-9642ea02d0aa/go.mod h1:v2npkhrXyk5BCnkNIiPdRI23Uq6uWPUQGL2hnRcRr/M=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
|
||||
github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,332 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/saltbo/zpan/downloader/internal/openapi"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
api *openapi.ClientWithResponses
|
||||
}
|
||||
|
||||
type Page[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type DownloadTask struct {
|
||||
ID string `json:"id"`
|
||||
SourceType string `json:"sourceType"`
|
||||
SourceURI string `json:"sourceUri"`
|
||||
Name string `json:"name"`
|
||||
TargetFolder string `json:"targetFolder"`
|
||||
Status string `json:"status"`
|
||||
DownloadedBytes int64 `json:"downloadedBytes"`
|
||||
TotalBytes *int64 `json:"totalBytes"`
|
||||
DownloadBps int64 `json:"downloadBps"`
|
||||
UploadBps int64 `json:"uploadBps"`
|
||||
ResultObjectID string `json:"resultObjectId"`
|
||||
UploadToken string `json:"uploadToken"`
|
||||
AssignedDownloaderID string `json:"assignedDownloaderId"`
|
||||
}
|
||||
|
||||
type Heartbeat struct {
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
Engine string `json:"engine"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
MaxConcurrentTasks int `json:"maxConcurrentTasks"`
|
||||
CurrentTasks int `json:"currentTasks"`
|
||||
DownloadBps int64 `json:"downloadBps"`
|
||||
UploadBps int64 `json:"uploadBps"`
|
||||
FreeDiskBytes int64 `json:"freeDiskBytes"`
|
||||
}
|
||||
|
||||
type TaskPatch struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
DownloadedBytes *int64 `json:"downloadedBytes,omitempty"`
|
||||
TotalBytes *int64 `json:"totalBytes,omitempty"`
|
||||
DownloadBps *int64 `json:"downloadBps,omitempty"`
|
||||
UploadBps *int64 `json:"uploadBps,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
ResultObjectID *string `json:"resultObjectId,omitempty"`
|
||||
}
|
||||
|
||||
type ObjectDraft struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
}
|
||||
|
||||
type DeviceCode struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
type DeviceToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type CreateDownloaderRequest struct {
|
||||
Name string `json:"name"`
|
||||
Heartbeat Heartbeat `json:"heartbeat"`
|
||||
}
|
||||
|
||||
type CreateDownloaderResponse struct {
|
||||
Downloader struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"downloader"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func New(baseURL, token string) *Client {
|
||||
httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
api, err := openapi.NewClientWithResponses(strings.TrimRight(baseURL, "/"), openapi.WithHTTPClient(httpClient))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Heartbeat(ctx context.Context, heartbeat Heartbeat) error {
|
||||
body, err := jsonBody(heartbeat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.api.PostApiDownloaderHeartbeatWithBodyWithResponse(ctx, "application/json", body, bearer(c.token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectStatus("POST", "/api/downloader/heartbeat", res.StatusCode(), res.Body, http.StatusOK)
|
||||
}
|
||||
|
||||
func (c *Client) AssignedTasks(ctx context.Context) ([]DownloadTask, error) {
|
||||
statuses := []openapi.GetApiDownloadTasksParamsStatus{
|
||||
openapi.GetApiDownloadTasksParamsStatusAssigned,
|
||||
openapi.GetApiDownloadTasksParamsStatusBillingPaused,
|
||||
}
|
||||
tasks := make([]DownloadTask, 0)
|
||||
for _, status := range statuses {
|
||||
page := 1
|
||||
pageSize := 20
|
||||
assignedTo := openapi.Me
|
||||
res, err := c.api.GetApiDownloadTasksWithResponse(ctx, &openapi.GetApiDownloadTasksParams{
|
||||
AssignedTo: &assignedTo,
|
||||
Status: &status,
|
||||
Page: &page,
|
||||
PageSize: &pageSize,
|
||||
}, bearer(c.token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := expectStatus("GET", "/api/download-tasks", res.StatusCode(), res.Body, http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result Page[DownloadTask]
|
||||
if err := decodeJSON(res.Body, &result); err != nil {
|
||||
return nil, fmt.Errorf("GET /api/download-tasks failed: %w", err)
|
||||
}
|
||||
tasks = append(tasks, result.Items...)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (c *Client) RequestDeviceCode(ctx context.Context) (DeviceCode, error) {
|
||||
res, err := c.api.PostApiAuthDeviceCodeWithResponse(ctx, openapi.DeviceCodeRequest{
|
||||
ClientId: "zpan-downloader",
|
||||
Scope: "downloader:register",
|
||||
})
|
||||
if err != nil {
|
||||
return DeviceCode{}, err
|
||||
}
|
||||
if err := expectStatus("POST", "/api/auth/device/code", res.StatusCode(), res.Body, http.StatusOK); err != nil {
|
||||
return DeviceCode{}, err
|
||||
}
|
||||
if res.JSON200 == nil {
|
||||
return DeviceCode{}, fmt.Errorf("POST /api/auth/device/code failed: empty response")
|
||||
}
|
||||
return DeviceCode{
|
||||
DeviceCode: res.JSON200.DeviceCode,
|
||||
UserCode: res.JSON200.UserCode,
|
||||
VerificationURI: res.JSON200.VerificationUri,
|
||||
VerificationURIComplete: res.JSON200.VerificationUriComplete,
|
||||
ExpiresIn: res.JSON200.ExpiresIn,
|
||||
Interval: res.JSON200.Interval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) PollDeviceToken(ctx context.Context, deviceCode string) (DeviceToken, error) {
|
||||
res, err := c.api.PostApiAuthDeviceTokenWithResponse(ctx, openapi.DeviceTokenRequest{
|
||||
GrantType: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
DeviceCode: deviceCode,
|
||||
ClientId: "zpan-downloader",
|
||||
})
|
||||
if err != nil {
|
||||
return DeviceToken{}, err
|
||||
}
|
||||
if err := expectStatus("POST", "/api/auth/device/token", res.StatusCode(), res.Body, http.StatusOK); err != nil {
|
||||
return DeviceToken{}, err
|
||||
}
|
||||
if res.JSON200 == nil {
|
||||
return DeviceToken{}, fmt.Errorf("POST /api/auth/device/token failed: empty response")
|
||||
}
|
||||
return DeviceToken{
|
||||
AccessToken: res.JSON200.AccessToken,
|
||||
TokenType: res.JSON200.TokenType,
|
||||
ExpiresIn: res.JSON200.ExpiresIn,
|
||||
Scope: res.JSON200.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateDownloader(ctx context.Context, accessToken string, req CreateDownloaderRequest) (CreateDownloaderResponse, error) {
|
||||
body, err := jsonBody(req)
|
||||
if err != nil {
|
||||
return CreateDownloaderResponse{}, err
|
||||
}
|
||||
res, err := c.api.PostApiAdminDownloadersWithBodyWithResponse(ctx, "application/json", body, bearer(accessToken))
|
||||
if err != nil {
|
||||
return CreateDownloaderResponse{}, err
|
||||
}
|
||||
if err := expectStatus("POST", "/api/admin/downloaders", res.StatusCode(), res.Body, http.StatusCreated); err != nil {
|
||||
return CreateDownloaderResponse{}, err
|
||||
}
|
||||
var out CreateDownloaderResponse
|
||||
if err := decodeJSON(res.Body, &out); err != nil {
|
||||
return CreateDownloaderResponse{}, fmt.Errorf("POST /api/admin/downloaders failed: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateTask(ctx context.Context, id string, patch TaskPatch) (DownloadTask, error) {
|
||||
body, err := jsonBody(patch)
|
||||
if err != nil {
|
||||
return DownloadTask{}, err
|
||||
}
|
||||
res, err := c.api.PatchApiDownloadTasksIdWithBodyWithResponse(ctx, id, "application/json", body, bearer(c.token))
|
||||
if err != nil {
|
||||
return DownloadTask{}, err
|
||||
}
|
||||
if err := expectStatus("PATCH", "/api/download-tasks/"+id, res.StatusCode(), res.Body, http.StatusOK); err != nil {
|
||||
return DownloadTask{}, err
|
||||
}
|
||||
var task DownloadTask
|
||||
if err := decodeJSON(res.Body, &task); err != nil {
|
||||
return DownloadTask{}, fmt.Errorf("PATCH /api/download-tasks/%s failed: %w", id, err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateObject(ctx context.Context, token string, name string, size int64, parent string) (ObjectDraft, error) {
|
||||
body, err := jsonBody(struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Parent string `json:"parent"`
|
||||
Dirtype int `json:"dirtype"`
|
||||
}{
|
||||
Name: name,
|
||||
Type: "application/octet-stream",
|
||||
Size: size,
|
||||
Parent: parent,
|
||||
Dirtype: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return ObjectDraft{}, err
|
||||
}
|
||||
res, err := c.api.PostApiObjectsWithBodyWithResponse(ctx, "application/json", body, bearer(token))
|
||||
if err != nil {
|
||||
return ObjectDraft{}, err
|
||||
}
|
||||
if err := expectStatus("POST", "/api/objects", res.StatusCode(), res.Body, http.StatusOK, http.StatusCreated); err != nil {
|
||||
return ObjectDraft{}, err
|
||||
}
|
||||
var draft ObjectDraft
|
||||
if err := decodeJSON(res.Body, &draft); err != nil {
|
||||
return ObjectDraft{}, fmt.Errorf("POST /api/objects failed: %w", err)
|
||||
}
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
func (c *Client) ConfirmObject(ctx context.Context, token string, id string) error {
|
||||
body, err := jsonBody(struct {
|
||||
Action string `json:"action"`
|
||||
}{Action: "confirm"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.api.PatchApiObjectsIdWithBodyWithResponse(ctx, id, "application/json", body, bearer(token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectStatus("PATCH", "/api/objects/"+id, res.StatusCode(), res.Body, http.StatusOK)
|
||||
}
|
||||
|
||||
func jsonBody(value any) (*bytes.Reader, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func decodeJSON(data []byte, out any) error {
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("empty response body")
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func bearer(token string) openapi.RequestEditorFn {
|
||||
return func(ctx context.Context, req *http.Request) error {
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func expectStatus(method string, path string, actual int, body []byte, expected ...int) error {
|
||||
for _, status := range expected {
|
||||
if actual == status {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s %s failed: %s: %s", method, path, http.StatusText(actual), responseError(body))
|
||||
}
|
||||
|
||||
func responseError(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return "empty response body"
|
||||
}
|
||||
var problem struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &problem); err == nil && problem.Error != "" {
|
||||
return problem.Error
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerURL string
|
||||
Token string
|
||||
Engine string
|
||||
DownloadDir string
|
||||
PollInterval time.Duration
|
||||
MaxConcurrentTasks int
|
||||
Aria2URL string
|
||||
Aria2Secret string
|
||||
QBittorrentURL string
|
||||
QBittorrentUser string
|
||||
QBittorrentPass string
|
||||
}
|
||||
|
||||
func Defaults(v *viper.Viper) {
|
||||
home, _ := os.UserHomeDir()
|
||||
v.SetDefault("server_url", "http://localhost:5173")
|
||||
v.SetDefault("engine", "auto")
|
||||
v.SetDefault("download_dir", filepath.Join(home, "Downloads", "zpan"))
|
||||
v.SetDefault("poll_interval", "5s")
|
||||
v.SetDefault("max_concurrent_tasks", 2)
|
||||
v.SetDefault("aria2.url", "ws://127.0.0.1:6800/jsonrpc")
|
||||
v.SetDefault("qbittorrent.url", "http://127.0.0.1:8080")
|
||||
}
|
||||
|
||||
func Load(v *viper.Viper) (Config, error) {
|
||||
Defaults(v)
|
||||
v.SetEnvPrefix("zpan_downloader")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) && !errors.Is(err, os.ErrNotExist) {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(v.GetString("poll_interval"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
ServerURL: strings.TrimRight(v.GetString("server_url"), "/"),
|
||||
Token: v.GetString("token"),
|
||||
Engine: v.GetString("engine"),
|
||||
DownloadDir: v.GetString("download_dir"),
|
||||
PollInterval: interval,
|
||||
MaxConcurrentTasks: v.GetInt("max_concurrent_tasks"),
|
||||
Aria2URL: v.GetString("aria2.url"),
|
||||
Aria2Secret: v.GetString("aria2.secret"),
|
||||
QBittorrentURL: v.GetString("qbittorrent.url"),
|
||||
QBittorrentUser: v.GetString("qbittorrent.username"),
|
||||
QBittorrentPass: v.GetString("qbittorrent.password"),
|
||||
}
|
||||
if cfg.ServerURL == "" {
|
||||
return Config{}, errors.New("server_url is required")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return Config{}, errors.New("token is required")
|
||||
}
|
||||
if cfg.MaxConcurrentTasks < 1 {
|
||||
return Config{}, errors.New("max_concurrent_tasks must be at least 1")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "zpan-downloader.yaml"
|
||||
}
|
||||
return filepath.Join(home, ".config", "zpan-downloader", "config.yaml")
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Braurbeki/arigo"
|
||||
qbittorrent "github.com/autobrr/go-qbittorrent"
|
||||
"github.com/saltbo/zpan/downloader/internal/client"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Path string
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type Progress func(downloaded int64, total *int64, bps int64) error
|
||||
|
||||
type Engine interface {
|
||||
Check(ctx context.Context) error
|
||||
Download(ctx context.Context, task client.DownloadTask, progress Progress) (Result, error)
|
||||
}
|
||||
|
||||
type HTTP struct {
|
||||
Dir string
|
||||
}
|
||||
|
||||
func (h HTTP) Check(ctx context.Context) error {
|
||||
if err := os.MkdirAll(h.Dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(h.Dir, ".zpan-check-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := file.Name()
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (h HTTP) Download(ctx context.Context, task client.DownloadTask, progress Progress) (Result, error) {
|
||||
if task.SourceType != "http" {
|
||||
return Result{}, errors.New("http engine only supports http sources")
|
||||
}
|
||||
taskDir := filepath.Join(h.Dir, task.ID)
|
||||
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, task.SourceURI, nil)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return Result{}, errors.New(res.Status)
|
||||
}
|
||||
|
||||
name := outputName(task, filenameFromURL(req.URL))
|
||||
path := filepath.Join(taskDir, name)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var total *int64
|
||||
if res.ContentLength > 0 {
|
||||
total = &res.ContentLength
|
||||
}
|
||||
counter := &progressWriter{progress: progress, total: total, lastAt: time.Now()}
|
||||
if _, err := io.Copy(file, io.TeeReader(res.Body, counter)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := progress(counter.downloaded, total, 0); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{Path: path, Name: name, Size: counter.downloaded}, nil
|
||||
}
|
||||
|
||||
type Aria2 struct {
|
||||
URL string
|
||||
Secret string
|
||||
Dir string
|
||||
}
|
||||
|
||||
func (a Aria2) Check(ctx context.Context) error {
|
||||
client, err := a.client(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
version, err := client.GetVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if version.Version == "" {
|
||||
return errors.New("aria2 rpc did not return a version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a Aria2) Download(ctx context.Context, task client.DownloadTask, progress Progress) (Result, error) {
|
||||
taskDir := filepath.Join(a.Dir, task.ID)
|
||||
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
aria, err := a.client(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer aria.Close()
|
||||
|
||||
options := &arigo.Options{
|
||||
Dir: taskDir,
|
||||
FollowTorrent: true,
|
||||
BTSaveMetadata: true,
|
||||
SeedRatio: 0,
|
||||
SeedTime: 0,
|
||||
AllowOverwrite: true,
|
||||
AutoFileRenaming: false,
|
||||
}
|
||||
if task.Name != "" && task.SourceType == "http" {
|
||||
options.Out = task.Name
|
||||
}
|
||||
gid, err := aria.AddURI(arigo.URIs(task.SourceURI), options)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
initialProgress := progress
|
||||
if task.SourceType != "http" {
|
||||
initialProgress = func(downloaded int64, total *int64, bps int64) error { return nil }
|
||||
}
|
||||
status, err := waitAria2(ctx, aria, gid.GID, initialProgress)
|
||||
if err != nil {
|
||||
_ = aria.Remove(gid.GID)
|
||||
return Result{}, err
|
||||
}
|
||||
if len(status.FollowedBy) > 0 {
|
||||
childGID := status.FollowedBy[0]
|
||||
status, err = waitAria2(ctx, aria, childGID, progress)
|
||||
if err != nil {
|
||||
_ = aria.Remove(childGID)
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
files, err := aria.GetFiles(status.GID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return resultFromAria2Files(task, taskDir, files)
|
||||
}
|
||||
|
||||
func (a Aria2) client(ctx context.Context) (*arigo.Client, error) {
|
||||
return arigo.DialContext(ctx, a.URL, a.Secret)
|
||||
}
|
||||
|
||||
type QBittorrent struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
Dir string
|
||||
}
|
||||
|
||||
func (q QBittorrent) Check(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(q.URL, "/")+"/api/v2/app/version", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := (&http.Client{Timeout: 2 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return fmt.Errorf("qbittorrent web api returned %s", res.Status)
|
||||
}
|
||||
version, err := io.ReadAll(io.LimitReader(res.Body, 256))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(string(version)) == "" {
|
||||
return errors.New("qbittorrent web api did not return a version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q QBittorrent) login(ctx context.Context) (*qbittorrent.Client, error) {
|
||||
qbt := qbittorrent.NewClient(qbittorrent.Config{
|
||||
Host: q.URL,
|
||||
Username: q.Username,
|
||||
Password: q.Password,
|
||||
Timeout: 10,
|
||||
})
|
||||
if err := qbt.LoginCtx(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qbt, nil
|
||||
}
|
||||
|
||||
func (q QBittorrent) Download(ctx context.Context, task client.DownloadTask, progress Progress) (Result, error) {
|
||||
if task.SourceType == "http" {
|
||||
return HTTP{Dir: q.Dir}.Download(ctx, task, progress)
|
||||
}
|
||||
taskDir := filepath.Join(q.Dir, task.ID)
|
||||
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
qbt, err := q.login(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
tag := "zpan-task-" + task.ID
|
||||
options := (&qbittorrent.TorrentAddOptions{
|
||||
SavePath: taskDir,
|
||||
Category: "zpan",
|
||||
Tags: tag,
|
||||
LimitRatio: 0,
|
||||
LimitSeedTime: 0,
|
||||
SequentialDownload: false,
|
||||
}).Prepare()
|
||||
if task.Name != "" {
|
||||
options["rename"] = task.Name
|
||||
}
|
||||
if _, err := qbt.AddTorrentFromUrlCtx(ctx, task.SourceURI, options); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
torrent, err := waitQBittorrent(ctx, qbt, tag, progress)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
_ = qbt.DeleteTorrentsCtx(ctx, []string{torrent.Hash}, false)
|
||||
return resultFromPath(task, taskDir, torrent.Name)
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
progress Progress
|
||||
total *int64
|
||||
downloaded int64
|
||||
lastBytes int64
|
||||
lastAt time.Time
|
||||
}
|
||||
|
||||
func (p *progressWriter) Write(data []byte) (int, error) {
|
||||
n := len(data)
|
||||
p.downloaded += int64(n)
|
||||
now := time.Now()
|
||||
if now.Sub(p.lastAt) >= time.Second {
|
||||
bps := int64(float64(p.downloaded-p.lastBytes) / now.Sub(p.lastAt).Seconds())
|
||||
if err := p.progress(p.downloaded, p.total, bps); err != nil {
|
||||
return n, err
|
||||
}
|
||||
p.lastBytes = p.downloaded
|
||||
p.lastAt = now
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func waitAria2(ctx context.Context, aria *arigo.Client, gid string, progress Progress) (arigo.Status, error) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return arigo.Status{}, ctx.Err()
|
||||
case <-ticker.C:
|
||||
status, err := aria.TellStatus(gid)
|
||||
if err != nil {
|
||||
return arigo.Status{}, err
|
||||
}
|
||||
total := int64(status.TotalLength)
|
||||
completed := int64(status.CompletedLength)
|
||||
bps := int64(status.DownloadSpeed)
|
||||
var totalPtr *int64
|
||||
if total > 0 {
|
||||
totalPtr = &total
|
||||
}
|
||||
if err := progress(completed, totalPtr, bps); err != nil {
|
||||
_ = aria.ForcePause(gid)
|
||||
return arigo.Status{}, err
|
||||
}
|
||||
switch string(status.Status) {
|
||||
case "complete", string(arigo.StatusCompleted):
|
||||
return status, nil
|
||||
case string(arigo.StatusError), string(arigo.StatusRemoved):
|
||||
if status.ErrorMessage != "" {
|
||||
return arigo.Status{}, errors.New(status.ErrorMessage)
|
||||
}
|
||||
return arigo.Status{}, fmt.Errorf("aria2 download ended with status %s", status.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitQBittorrent(
|
||||
ctx context.Context,
|
||||
qbt *qbittorrent.Client,
|
||||
tag string,
|
||||
progress Progress,
|
||||
) (qbittorrent.Torrent, error) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return qbittorrent.Torrent{}, ctx.Err()
|
||||
case <-ticker.C:
|
||||
torrents, err := qbt.GetTorrentsCtx(ctx, qbittorrent.TorrentFilterOptions{Tag: tag})
|
||||
if err != nil {
|
||||
return qbittorrent.Torrent{}, err
|
||||
}
|
||||
if len(torrents) == 0 {
|
||||
continue
|
||||
}
|
||||
torrent := torrents[0]
|
||||
total := torrent.TotalSize
|
||||
if total <= 0 {
|
||||
total = torrent.Size
|
||||
}
|
||||
var totalPtr *int64
|
||||
if total > 0 {
|
||||
totalPtr = &total
|
||||
}
|
||||
if err := progress(torrent.Completed, totalPtr, torrent.DlSpeed); err != nil {
|
||||
_ = qbt.StopCtx(ctx, []string{torrent.Hash})
|
||||
return qbittorrent.Torrent{}, err
|
||||
}
|
||||
if torrent.Progress >= 1 || (torrent.AmountLeft == 0 && total > 0) {
|
||||
return torrent, nil
|
||||
}
|
||||
if isQBittorrentErrorState(torrent.State) {
|
||||
return qbittorrent.Torrent{}, fmt.Errorf("qbittorrent download ended with state %s", torrent.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resultFromAria2Files(task client.DownloadTask, taskDir string, files []arigo.File) (Result, error) {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file.Selected && file.Length > 0 {
|
||||
paths = append(paths, cleanDownloadedPath(taskDir, file.Path))
|
||||
}
|
||||
}
|
||||
if len(paths) == 1 {
|
||||
return resultFromFile(task, paths[0])
|
||||
}
|
||||
return resultFromPath(task, taskDir, task.Name)
|
||||
}
|
||||
|
||||
func resultFromPath(task client.DownloadTask, path string, fallbackName string) (Result, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
candidate := filepath.Join(path, fallbackName)
|
||||
if fallbackName != "" {
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
return resultFromPath(task, candidate, fallbackName)
|
||||
}
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return resultFromFile(task, path)
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
visible := make([]os.DirEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.Name(), ".") {
|
||||
visible = append(visible, entry)
|
||||
}
|
||||
}
|
||||
if len(visible) == 1 && !visible[0].IsDir() {
|
||||
return resultFromFile(task, filepath.Join(path, visible[0].Name()))
|
||||
}
|
||||
zipName := outputName(task, fallbackName)
|
||||
if !strings.HasSuffix(strings.ToLower(zipName), ".zip") {
|
||||
zipName += ".zip"
|
||||
}
|
||||
zipPath := filepath.Join(filepath.Dir(path), zipName)
|
||||
size, err := ZipDirectory(path, zipPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{Path: zipPath, Name: zipName, Size: size}, nil
|
||||
}
|
||||
|
||||
func resultFromFile(task client.DownloadTask, path string) (Result, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{Path: path, Name: outputName(task, filepath.Base(path)), Size: info.Size()}, nil
|
||||
}
|
||||
|
||||
func cleanDownloadedPath(baseDir string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return filepath.Join(baseDir, filepath.Clean(path))
|
||||
}
|
||||
|
||||
func outputName(task client.DownloadTask, fallback string) string {
|
||||
name := strings.TrimSpace(task.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(fallback)
|
||||
}
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
name = task.ID
|
||||
}
|
||||
return filepath.Base(name)
|
||||
}
|
||||
|
||||
func filenameFromURL(parsed *url.URL) string {
|
||||
name := filepath.Base(parsed.Path)
|
||||
if name == "." || name == "/" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func isQBittorrentErrorState(state qbittorrent.TorrentState) bool {
|
||||
value := strings.ToLower(string(state))
|
||||
return strings.Contains(value, "error") || strings.Contains(value, "missing")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/saltbo/zpan/downloader/internal/client"
|
||||
)
|
||||
|
||||
func TestHTTPDownload(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "11")
|
||||
_, _ = w.Write([]byte("hello world"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
progressCalls := 0
|
||||
result, err := (HTTP{Dir: dir}).Download(
|
||||
context.Background(),
|
||||
client.DownloadTask{ID: "task-1", SourceType: "http", SourceURI: server.URL + "/file.txt"},
|
||||
func(downloaded int64, total *int64, bps int64) error {
|
||||
progressCalls++
|
||||
if downloaded < 0 {
|
||||
t.Fatalf("downloaded bytes must not be negative")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Name != "file.txt" {
|
||||
t.Fatalf("expected file.txt, got %s", result.Name)
|
||||
}
|
||||
if result.Size != 11 {
|
||||
t.Fatalf("expected size 11, got %d", result.Size)
|
||||
}
|
||||
data, err := os.ReadFile(result.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "hello world" {
|
||||
t.Fatalf("unexpected file content: %q", string(data))
|
||||
}
|
||||
if progressCalls == 0 {
|
||||
t.Fatal("expected progress callback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPRejectsMagnet(t *testing.T) {
|
||||
_, err := (HTTP{Dir: t.TempDir()}).Download(
|
||||
context.Background(),
|
||||
client.DownloadTask{ID: "task-1", SourceType: "magnet", SourceURI: "magnet:?xt=urn:btih:abc"},
|
||||
func(downloaded int64, total *int64, bps int64) error { return nil },
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected magnet to be rejected by HTTP engine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBittorrentCheckUsesWebAPIVersion(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v2/app/version" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte("5.0.0"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := (QBittorrent{URL: server.URL, Dir: t.TempDir()}).Check(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBittorrentHTTPDelegatesToBuiltin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "11")
|
||||
_, _ = w.Write([]byte("hello qbit!"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := (QBittorrent{URL: "http://127.0.0.1:1", Dir: t.TempDir()}).Download(
|
||||
context.Background(),
|
||||
client.DownloadTask{ID: "task-1", SourceType: "http", SourceURI: server.URL + "/file.txt"},
|
||||
func(downloaded int64, total *int64, bps int64) error { return nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(result.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "hello qbit!" {
|
||||
t.Fatalf("unexpected file content: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultFromPathZipsDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
taskDir := filepath.Join(dir, "task-1")
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "folder", "a.txt"), []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "folder", "b.txt"), []byte("b"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := resultFromPath(client.DownloadTask{ID: "task-1", Name: "bundle"}, taskDir, "bundle")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Name != "bundle.zip" {
|
||||
t.Fatalf("expected bundle.zip, got %s", result.Name)
|
||||
}
|
||||
if result.Size <= 0 {
|
||||
t.Fatalf("expected zip size > 0, got %d", result.Size)
|
||||
}
|
||||
if _, err := os.Stat(result.Path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/saltbo/zpan/downloader/internal/client"
|
||||
)
|
||||
|
||||
func TestLiveDownloadThreeSourceTypes(t *testing.T) {
|
||||
if os.Getenv("LIVE_DOWNLOAD_VERIFY") != "1" {
|
||||
t.Skip("set LIVE_DOWNLOAD_VERIFY=1 to run live HTTP/magnet/torrent URL downloads")
|
||||
}
|
||||
aria2c, err := exec.LookPath("aria2c")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
root := t.TempDir()
|
||||
seedDir := filepath.Join(root, "seed")
|
||||
if err := os.MkdirAll(seedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtureName := "zpan-live-fixture.txt"
|
||||
fixtureBytes := bytes.Repeat([]byte("zpan remote download live verification\n"), 4096)
|
||||
fixturePath := filepath.Join(seedDir, fixtureName)
|
||||
if err := os.WriteFile(fixturePath, fixtureBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
httpServer := httptest.NewServer(http.FileServer(http.Dir(seedDir)))
|
||||
defer httpServer.Close()
|
||||
|
||||
tracker := newLocalTracker()
|
||||
trackerServer := httptest.NewServer(tracker)
|
||||
defer trackerServer.Close()
|
||||
|
||||
torrentBytes, infoHash := makeSingleFileTorrent(t, fixtureName, fixtureBytes, trackerServer.URL+"/announce", httpServer.URL+"/")
|
||||
torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-bittorrent")
|
||||
_, _ = w.Write(torrentBytes)
|
||||
}))
|
||||
defer torrentServer.Close()
|
||||
|
||||
torrentPath := filepath.Join(root, "fixture.torrent")
|
||||
if err := os.WriteFile(torrentPath, torrentBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seederPort, err := freePort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeder := exec.Command(
|
||||
aria2c,
|
||||
"--check-integrity=true",
|
||||
"--seed-ratio=1000",
|
||||
"--seed-time=120",
|
||||
"--bt-stop-timeout=120",
|
||||
"--enable-dht=false",
|
||||
"--enable-peer-exchange=false",
|
||||
fmt.Sprintf("--listen-port=%d", seederPort),
|
||||
"--dir="+seedDir,
|
||||
torrentPath,
|
||||
)
|
||||
seeder.Stdout = os.Stdout
|
||||
seeder.Stderr = os.Stderr
|
||||
if err := seeder.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stopProcess(seeder)
|
||||
if err := tracker.waitForPeer(20 * time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rpcPort, err := freePort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ariaRPC := exec.Command(
|
||||
aria2c,
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-all=false",
|
||||
fmt.Sprintf("--rpc-listen-port=%d", rpcPort),
|
||||
"--rpc-allow-origin-all=true",
|
||||
"--enable-dht=false",
|
||||
"--enable-peer-exchange=false",
|
||||
"--seed-time=0",
|
||||
"--bt-stop-timeout=120",
|
||||
"--dir="+filepath.Join(root, "rpc-default"),
|
||||
)
|
||||
ariaRPC.Stdout = os.Stdout
|
||||
ariaRPC.Stderr = os.Stderr
|
||||
if err := ariaRPC.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stopProcess(ariaRPC)
|
||||
if err := waitTCP("127.0.0.1", rpcPort, 10*time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
httpResult := runLiveDownload(t, ctx, HTTP{Dir: filepath.Join(root, "http")}, client.DownloadTask{
|
||||
ID: "live-http",
|
||||
SourceType: "http",
|
||||
SourceURI: httpServer.URL + "/" + fixtureName,
|
||||
Name: "http-fixture.txt",
|
||||
})
|
||||
magnetURL := fmt.Sprintf(
|
||||
"magnet:?xt=urn:btih:%s&dn=%s&tr=%s",
|
||||
hex.EncodeToString(infoHash),
|
||||
url.QueryEscape(fixtureName),
|
||||
url.QueryEscape(trackerServer.URL+"/announce"),
|
||||
)
|
||||
magnetResult := runLiveDownload(t, ctx, Aria2{URL: fmt.Sprintf("ws://127.0.0.1:%d/jsonrpc", rpcPort), Dir: filepath.Join(root, "magnet")}, client.DownloadTask{
|
||||
ID: "live-magnet",
|
||||
SourceType: "magnet",
|
||||
SourceURI: magnetURL,
|
||||
Name: "magnet-fixture.txt",
|
||||
})
|
||||
torrentResult := runLiveDownload(t, ctx, Aria2{URL: fmt.Sprintf("ws://127.0.0.1:%d/jsonrpc", rpcPort), Dir: filepath.Join(root, "torrent-url")}, client.DownloadTask{
|
||||
ID: "live-torrent-url",
|
||||
SourceType: "torrent_url",
|
||||
SourceURI: torrentServer.URL + "/fixture.torrent",
|
||||
Name: "torrent-url-fixture.txt",
|
||||
})
|
||||
|
||||
assertSameContent(t, httpResult.Path, fixtureBytes)
|
||||
assertSameContent(t, magnetResult.Path, fixtureBytes)
|
||||
assertSameContent(t, torrentResult.Path, fixtureBytes)
|
||||
t.Logf("HTTP result: %s (%d bytes)", httpResult.Path, httpResult.Size)
|
||||
t.Logf("magnet result: %s (%d bytes)", magnetResult.Path, magnetResult.Size)
|
||||
t.Logf("torrent_url result: %s (%d bytes)", torrentResult.Path, torrentResult.Size)
|
||||
}
|
||||
|
||||
func TestLiveQBittorrentDownloadTorrentURL(t *testing.T) {
|
||||
if os.Getenv("LIVE_DOWNLOAD_VERIFY") != "1" {
|
||||
t.Skip("set LIVE_DOWNLOAD_VERIFY=1 to run live qBittorrent downloads")
|
||||
}
|
||||
qbittorrentBinary, err := findQBittorrentForTest()
|
||||
if err != nil {
|
||||
t.Skip("qBittorrent is not installed")
|
||||
}
|
||||
aria2c, err := exec.LookPath("aria2c")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
root := t.TempDir()
|
||||
seedDir := filepath.Join(root, "seed")
|
||||
if err := os.MkdirAll(seedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtureName := "zpan-qbit-fixture.txt"
|
||||
fixtureBytes := bytes.Repeat([]byte("zpan qbit live verification\n"), 4096)
|
||||
fixturePath := filepath.Join(seedDir, fixtureName)
|
||||
if err := os.WriteFile(fixturePath, fixtureBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
httpServer := httptest.NewServer(http.FileServer(http.Dir(seedDir)))
|
||||
defer httpServer.Close()
|
||||
|
||||
tracker := newLocalTracker()
|
||||
trackerServer := httptest.NewServer(tracker)
|
||||
defer trackerServer.Close()
|
||||
|
||||
torrentBytes, _ := makeSingleFileTorrent(t, fixtureName, fixtureBytes, trackerServer.URL+"/announce", httpServer.URL+"/")
|
||||
torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-bittorrent")
|
||||
_, _ = w.Write(torrentBytes)
|
||||
}))
|
||||
defer torrentServer.Close()
|
||||
|
||||
torrentPath := filepath.Join(root, "fixture.torrent")
|
||||
if err := os.WriteFile(torrentPath, torrentBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seederPort, err := freePort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeder := exec.Command(
|
||||
aria2c,
|
||||
"--check-integrity=true",
|
||||
"--seed-ratio=1000",
|
||||
"--seed-time=120",
|
||||
"--bt-stop-timeout=120",
|
||||
"--enable-dht=false",
|
||||
"--enable-peer-exchange=false",
|
||||
fmt.Sprintf("--listen-port=%d", seederPort),
|
||||
"--dir="+seedDir,
|
||||
torrentPath,
|
||||
)
|
||||
seeder.Stdout = os.Stdout
|
||||
seeder.Stderr = os.Stderr
|
||||
if err := seeder.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stopProcess(seeder)
|
||||
if err := tracker.waitForPeer(20 * time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
webUIPort, err := freePort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qbit := startQBittorrentForTest(t, ctx, qbittorrentBinary, filepath.Join(root, "qbit-profile"), webUIPort)
|
||||
defer stopProcess(qbit)
|
||||
|
||||
result := runLiveDownload(t, ctx, QBittorrent{
|
||||
URL: "http://127.0.0.1:" + strconv.Itoa(webUIPort),
|
||||
Dir: filepath.Join(root, "qbit-downloads"),
|
||||
}, client.DownloadTask{
|
||||
ID: "live-qbit-torrent-url",
|
||||
SourceType: "torrent_url",
|
||||
SourceURI: torrentServer.URL + "/fixture.torrent",
|
||||
Name: "qbit-fixture.txt",
|
||||
})
|
||||
|
||||
assertSameContent(t, result.Path, fixtureBytes)
|
||||
t.Logf("qBittorrent torrent_url result: %s (%d bytes)", result.Path, result.Size)
|
||||
}
|
||||
|
||||
func runLiveDownload(t *testing.T, ctx context.Context, downloader Engine, task client.DownloadTask) Result {
|
||||
t.Helper()
|
||||
var lastDownloaded int64
|
||||
result, err := downloader.Download(ctx, task, func(downloaded int64, total *int64, bps int64) error {
|
||||
if downloaded < lastDownloaded {
|
||||
t.Fatalf("download progress moved backwards: %d < %d", downloaded, lastDownloaded)
|
||||
}
|
||||
lastDownloaded = downloaded
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Size <= 0 {
|
||||
t.Fatalf("expected non-empty result for %s", task.SourceType)
|
||||
}
|
||||
if _, err := os.Stat(result.Path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func startQBittorrentForTest(t *testing.T, ctx context.Context, binary string, profileDir string, webUIPort int) *exec.Cmd {
|
||||
t.Helper()
|
||||
configDir := filepath.Join(profileDir, "qBittorrent", "config")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := fmt.Sprintf(`[LegalNotice]
|
||||
Accepted=true
|
||||
|
||||
[Preferences]
|
||||
Downloads\SavePath=%s/
|
||||
WebUI\Address=127.0.0.1
|
||||
WebUI\AuthSubnetWhitelist=127.0.0.1
|
||||
WebUI\AuthSubnetWhitelistEnabled=true
|
||||
WebUI\LocalHostAuth=false
|
||||
WebUI\Port=%d
|
||||
`, filepath.ToSlash(filepath.Join(profileDir, "downloads")), webUIPort)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "qBittorrent.conf"), []byte(config), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(binary, "--profile="+profileDir, "--webui-port="+strconv.Itoa(webUIPort))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := QBittorrent{URL: "http://127.0.0.1:" + strconv.Itoa(webUIPort), Dir: t.TempDir()}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
err := engine.Check(checkCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return cmd
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
stopProcess(cmd)
|
||||
t.Fatalf("timed out waiting for qBittorrent Web UI: %v", lastErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func findQBittorrentForTest() (string, error) {
|
||||
for _, name := range []string{"qbittorrent-nox", "qbittorrent"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func makeSingleFileTorrent(
|
||||
t *testing.T,
|
||||
name string,
|
||||
content []byte,
|
||||
announce string,
|
||||
webSeed string,
|
||||
) ([]byte, []byte) {
|
||||
t.Helper()
|
||||
pieceHash := sha1.Sum(content)
|
||||
info := bencodeDict(map[string]any{
|
||||
"length": len(content),
|
||||
"name": name,
|
||||
"piece length": len(content),
|
||||
"pieces": pieceHash[:],
|
||||
})
|
||||
infoHash := sha1.Sum(info)
|
||||
torrent := bencodeDict(map[string]any{
|
||||
"announce": announce,
|
||||
"info": rawBencode(info),
|
||||
"url-list": webSeed,
|
||||
})
|
||||
return torrent, infoHash[:]
|
||||
}
|
||||
|
||||
type rawBencode []byte
|
||||
|
||||
func bencodeDict(values map[string]any) []byte {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var out bytes.Buffer
|
||||
out.WriteByte('d')
|
||||
for _, key := range keys {
|
||||
out.Write(bencodeString([]byte(key)))
|
||||
out.Write(bencodeValue(values[key]))
|
||||
}
|
||||
out.WriteByte('e')
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func bencodeValue(value any) []byte {
|
||||
switch v := value.(type) {
|
||||
case rawBencode:
|
||||
return []byte(v)
|
||||
case string:
|
||||
return bencodeString([]byte(v))
|
||||
case []byte:
|
||||
return bencodeString(v)
|
||||
case int:
|
||||
return []byte(fmt.Sprintf("i%de", v))
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported bencode value %T", value))
|
||||
}
|
||||
}
|
||||
|
||||
func bencodeString(value []byte) []byte {
|
||||
return []byte(strconv.Itoa(len(value)) + ":" + string(value))
|
||||
}
|
||||
|
||||
type localTracker struct {
|
||||
peerSeen chan struct{}
|
||||
peers map[string][]byte
|
||||
}
|
||||
|
||||
func newLocalTracker() *localTracker {
|
||||
return &localTracker{peerSeen: make(chan struct{}), peers: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (t *localTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerID := query.Get("peer_id")
|
||||
port, _ := strconv.Atoi(query.Get("port"))
|
||||
if peerID != "" && port > 0 {
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if host == "::1" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
t.peers[peerID] = compactPeer(host, port)
|
||||
select {
|
||||
case <-t.peerSeen:
|
||||
default:
|
||||
close(t.peerSeen)
|
||||
}
|
||||
}
|
||||
var peers []byte
|
||||
for id, peer := range t.peers {
|
||||
if id != peerID {
|
||||
peers = append(peers, peer...)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(bencodeDict(map[string]any{"interval": 5, "peers": peers}))
|
||||
}
|
||||
|
||||
func (t *localTracker) waitForPeer(timeout time.Duration) error {
|
||||
select {
|
||||
case <-t.peerSeen:
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("timed out waiting for seeder announce")
|
||||
}
|
||||
}
|
||||
|
||||
func compactPeer(host string, port int) []byte {
|
||||
ip := net.ParseIP(host).To4()
|
||||
if ip == nil {
|
||||
ip = net.ParseIP("127.0.0.1").To4()
|
||||
}
|
||||
return []byte{ip[0], ip[1], ip[2], ip[3], byte(port >> 8), byte(port)}
|
||||
}
|
||||
|
||||
func assertSameContent(t *testing.T, path string, expected []byte) {
|
||||
t.Helper()
|
||||
actual, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(actual, expected) {
|
||||
t.Fatalf("content mismatch for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func stopProcess(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Signal(os.Interrupt)
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
|
||||
func freePort() (int, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer listener.Close()
|
||||
return listener.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
func waitTCP(host string, port int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 500*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for %s:%d", host, port)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ZipDirectory(sourceDir string, destination string) (int64, error) {
|
||||
out, err := os.Create(destination)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
archive := zip.NewWriter(out)
|
||||
defer archive.Close()
|
||||
|
||||
if err := filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Clean(path) == filepath.Clean(destination) {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(sourceDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, err := archive.Create(filepath.ToSlash(rel))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
info, err := os.Stat(destination)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,567 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/saltbo/zpan/downloader/internal/client"
|
||||
"github.com/saltbo/zpan/downloader/internal/config"
|
||||
"github.com/saltbo/zpan/downloader/internal/engine"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
|
||||
var errBillingPaused = errors.New("billing paused")
|
||||
|
||||
type Worker struct {
|
||||
cfg config.Config
|
||||
api *client.Client
|
||||
engine engine.Engine
|
||||
logger *slog.Logger
|
||||
running map[string]struct{}
|
||||
started []*exec.Cmd
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(cfg config.Config) *Worker {
|
||||
return &Worker{
|
||||
cfg: cfg,
|
||||
api: client.New(cfg.ServerURL, cfg.Token),
|
||||
logger: slog.Default(),
|
||||
running: map[string]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
if err := os.MkdirAll(w.cfg.DownloadDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
w.logger.Info("downloader starting",
|
||||
"server_url", w.cfg.ServerURL,
|
||||
"engine", w.cfg.Engine,
|
||||
"download_dir", w.cfg.DownloadDir,
|
||||
"poll_interval", w.cfg.PollInterval.String(),
|
||||
"max_concurrent_tasks", w.cfg.MaxConcurrentTasks,
|
||||
)
|
||||
if err := w.resolveEngine(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.stopStartedEngines()
|
||||
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
w.logger.Info("checking downloader engine", "engine", w.cfg.Engine)
|
||||
if err := w.engine.Check(checkCtx); err != nil {
|
||||
w.logger.Error("downloader engine check failed", "engine", w.cfg.Engine, "error", err)
|
||||
return fmt.Errorf("engine %q is not available: %w", w.cfg.Engine, err)
|
||||
}
|
||||
w.logger.Info("downloader engine check passed", "engine", w.cfg.Engine)
|
||||
w.logger.Info("downloader started", "engine", w.cfg.Engine)
|
||||
ticker := time.NewTicker(w.cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
if err := w.tick(ctx); err != nil {
|
||||
w.logger.Error("downloader tick failed", "error", err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.logger.Info("downloader stopped", "reason", ctx.Err())
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := w.tick(ctx); err != nil {
|
||||
w.logger.Error("downloader tick failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) tick(ctx context.Context) error {
|
||||
if err := w.api.Heartbeat(ctx, w.heartbeat()); err != nil {
|
||||
return err
|
||||
}
|
||||
tasks, err := w.api.AssignedTasks(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.logger.Debug("poll completed", "assigned_tasks", len(tasks), "running", w.currentTasks())
|
||||
for _, task := range tasks {
|
||||
taskLogger := w.taskLogger(task)
|
||||
if task.UploadToken == "" {
|
||||
taskLogger.Warn("assigned task skipped because upload token is missing")
|
||||
continue
|
||||
}
|
||||
if !w.canStart(task.ID) {
|
||||
taskLogger.Debug("assigned task skipped because worker is already busy")
|
||||
continue
|
||||
}
|
||||
go w.process(ctx, task)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, task client.DownloadTask) {
|
||||
defer w.finish(task.ID)
|
||||
log := w.taskLogger(task)
|
||||
log.Info("task started", "source_uri", task.SourceURI, "target_folder", task.TargetFolder)
|
||||
if _, err := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{Status: "running"}); err != nil {
|
||||
log.Error("failed to mark task running", "error", err)
|
||||
}
|
||||
|
||||
var lastProgressLog time.Time
|
||||
result, err := w.engine.Download(ctx, task, func(downloaded int64, total *int64, bps int64) error {
|
||||
_, updateErr := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{
|
||||
DownloadedBytes: &downloaded,
|
||||
TotalBytes: total,
|
||||
DownloadBps: &bps,
|
||||
})
|
||||
if updateErr != nil && strings.Contains(updateErr.Error(), "insufficient_credits") {
|
||||
log.Warn("task paused by billing", "downloaded_bytes", downloaded, "total_bytes", optionalInt64(total), "bps", bps)
|
||||
return errBillingPaused
|
||||
}
|
||||
if updateErr != nil {
|
||||
log.Error("failed to report task progress", "downloaded_bytes", downloaded, "total_bytes", optionalInt64(total), "bps", bps, "error", updateErr)
|
||||
return updateErr
|
||||
}
|
||||
if time.Since(lastProgressLog) >= 10*time.Second {
|
||||
log.Debug("task download progress", "downloaded_bytes", downloaded, "total_bytes", optionalInt64(total), "bps", bps)
|
||||
lastProgressLog = time.Now()
|
||||
}
|
||||
return updateErr
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, errBillingPaused) {
|
||||
log.Warn("task stopped because credits are insufficient")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
log.Error("task download failed", "error", err)
|
||||
if _, updateErr := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{Status: "failed", ErrorMessage: &msg}); updateErr != nil {
|
||||
log.Error("failed to mark task failed", "error", updateErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("task download completed", "path", result.Path, "name", result.Name, "size", result.Size)
|
||||
if _, err := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{Status: "uploading"}); err != nil {
|
||||
log.Error("failed to mark task uploading", "error", err)
|
||||
}
|
||||
log.Info("creating remote object", "name", result.Name, "size", result.Size, "target_folder", task.TargetFolder)
|
||||
draft, err := w.api.CreateObject(ctx, task.UploadToken, result.Name, result.Size, task.TargetFolder)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
log.Error("failed to create remote object", "error", err)
|
||||
if _, updateErr := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{Status: "failed", ErrorMessage: &msg}); updateErr != nil {
|
||||
log.Error("failed to mark task failed", "error", updateErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Info("uploading file to object storage", "object_id", draft.ID, "path", result.Path)
|
||||
if err := uploadFile(ctx, draft.UploadURL, result.Path); err != nil {
|
||||
msg := err.Error()
|
||||
log.Error("failed to upload file to object storage", "object_id", draft.ID, "error", err)
|
||||
if _, updateErr := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{Status: "failed", ErrorMessage: &msg}); updateErr != nil {
|
||||
log.Error("failed to mark task failed", "error", updateErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Info("confirming uploaded object", "object_id", draft.ID)
|
||||
if err := w.api.ConfirmObject(ctx, task.UploadToken, draft.ID); err != nil {
|
||||
msg := err.Error()
|
||||
log.Error("failed to confirm uploaded object", "object_id", draft.ID, "error", err)
|
||||
if _, updateErr := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{Status: "failed", ErrorMessage: &msg}); updateErr != nil {
|
||||
log.Error("failed to mark task failed", "error", updateErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{Status: "completed", ResultObjectID: &draft.ID}); err != nil {
|
||||
log.Error("failed to mark task completed", "object_id", draft.ID, "error", err)
|
||||
return
|
||||
}
|
||||
log.Debug("task completed", "object_id", draft.ID)
|
||||
if err := os.Remove(result.Path); err != nil {
|
||||
log.Warn("failed to remove local downloaded file", "path", result.Path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) canStart(taskID string) bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if _, exists := w.running[taskID]; exists {
|
||||
return false
|
||||
}
|
||||
if len(w.running) >= w.cfg.MaxConcurrentTasks {
|
||||
return false
|
||||
}
|
||||
w.running[taskID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Worker) finish(taskID string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
delete(w.running, taskID)
|
||||
}
|
||||
|
||||
func (w *Worker) heartbeat() client.Heartbeat {
|
||||
hostname, _ := os.Hostname()
|
||||
return client.Heartbeat{
|
||||
Version: Version,
|
||||
Hostname: hostname,
|
||||
Platform: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Engine: w.cfg.Engine,
|
||||
Capabilities: capabilities(w.cfg.Engine),
|
||||
MaxConcurrentTasks: w.cfg.MaxConcurrentTasks,
|
||||
CurrentTasks: w.currentTasks(),
|
||||
DownloadBps: 0,
|
||||
UploadBps: 0,
|
||||
FreeDiskBytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) currentTasks() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(w.running)
|
||||
}
|
||||
|
||||
type engineCandidate struct {
|
||||
name string
|
||||
binary []string
|
||||
engine func(config.Config) engine.Engine
|
||||
start func(context.Context, config.Config) (*exec.Cmd, error)
|
||||
canAuto bool
|
||||
}
|
||||
|
||||
func (w *Worker) resolveEngine(ctx context.Context) error {
|
||||
if w.cfg.Engine == "" || w.cfg.Engine == "auto" {
|
||||
return w.resolveAutoEngine(ctx)
|
||||
}
|
||||
candidate, ok := engineCandidateByName(w.cfg.Engine)
|
||||
if !ok {
|
||||
w.engine = selectEngine(w.cfg)
|
||||
return nil
|
||||
}
|
||||
w.logger.Info("checking configured downloader engine", "engine", candidate.name)
|
||||
if w.checkCandidate(ctx, candidate) == nil {
|
||||
w.useCandidate(candidate, "configured engine is already running")
|
||||
return nil
|
||||
}
|
||||
if !candidate.canAuto {
|
||||
w.useCandidate(candidate, "configured built-in engine")
|
||||
return nil
|
||||
}
|
||||
if err := w.startCandidate(ctx, candidate); err != nil {
|
||||
w.logger.Warn("configured downloader engine could not be started", "engine", candidate.name, "error", err)
|
||||
w.engine = candidate.engine(w.cfg)
|
||||
return nil
|
||||
}
|
||||
w.useCandidate(candidate, "configured engine started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) resolveAutoEngine(ctx context.Context) error {
|
||||
candidates := externalEngineCandidates()
|
||||
w.logger.Info("auto selecting downloader engine", "priority", "aria2,qbittorrent,builtin")
|
||||
for _, candidate := range candidates {
|
||||
w.logger.Info("checking downloader engine availability", "engine", candidate.name)
|
||||
if err := w.checkCandidate(ctx, candidate); err == nil {
|
||||
w.useCandidate(candidate, "engine is already running")
|
||||
return nil
|
||||
} else {
|
||||
w.logger.Info("downloader engine is not running", "engine", candidate.name, "error", err)
|
||||
}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
w.logger.Info("checking downloader engine binary", "engine", candidate.name, "binaries", strings.Join(candidate.binary, ","))
|
||||
if err := w.startCandidate(ctx, candidate); err != nil {
|
||||
w.logger.Info("downloader engine is not available for auto start", "engine", candidate.name, "error", err)
|
||||
continue
|
||||
}
|
||||
w.useCandidate(candidate, "engine binary found and started")
|
||||
return nil
|
||||
}
|
||||
w.cfg.Engine = "builtin"
|
||||
w.engine = engine.HTTP{Dir: w.cfg.DownloadDir}
|
||||
w.logger.Info("using built-in downloader engine", "reason", "no external downloader engine is running or installed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) useCandidate(candidate engineCandidate, reason string) {
|
||||
w.cfg.Engine = candidate.name
|
||||
w.engine = candidate.engine(w.cfg)
|
||||
w.logger.Info("selected downloader engine", "engine", candidate.name, "reason", reason)
|
||||
}
|
||||
|
||||
func (w *Worker) checkCandidate(ctx context.Context, candidate engineCandidate) error {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
return candidate.engine(w.cfg).Check(checkCtx)
|
||||
}
|
||||
|
||||
func (w *Worker) startCandidate(ctx context.Context, candidate engineCandidate) error {
|
||||
if candidate.start == nil {
|
||||
return fmt.Errorf("%s cannot be auto started", candidate.name)
|
||||
}
|
||||
w.logger.Info("starting downloader engine", "engine", candidate.name)
|
||||
cmd, err := candidate.start(ctx, w.cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
w.logger.Info("downloader engine process started", "engine", candidate.name, "pid", cmd.Process.Pid)
|
||||
}
|
||||
w.started = append(w.started, cmd)
|
||||
if err := waitForEngine(ctx, candidate.engine(w.cfg)); err != nil {
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) stopStartedEngines() {
|
||||
for _, cmd := range w.started {
|
||||
if cmd.Process == nil {
|
||||
continue
|
||||
}
|
||||
w.logger.Info("stopping auto-started downloader engine", "pid", cmd.Process.Pid)
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func engineCandidateByName(name string) (engineCandidate, bool) {
|
||||
candidates := append(externalEngineCandidates(), builtinEngineCandidate())
|
||||
for _, candidate := range candidates {
|
||||
if candidate.name == name {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return engineCandidate{}, false
|
||||
}
|
||||
|
||||
func externalEngineCandidates() []engineCandidate {
|
||||
return []engineCandidate{
|
||||
{
|
||||
name: "aria2",
|
||||
binary: []string{"aria2c"},
|
||||
engine: func(cfg config.Config) engine.Engine {
|
||||
return engine.Aria2{URL: cfg.Aria2URL, Secret: cfg.Aria2Secret, Dir: cfg.DownloadDir}
|
||||
},
|
||||
start: startAria2,
|
||||
canAuto: true,
|
||||
},
|
||||
{
|
||||
name: "qbittorrent",
|
||||
binary: []string{"qbittorrent-nox", "qbittorrent"},
|
||||
engine: func(cfg config.Config) engine.Engine {
|
||||
return engine.QBittorrent{URL: cfg.QBittorrentURL, Username: cfg.QBittorrentUser, Password: cfg.QBittorrentPass, Dir: cfg.DownloadDir}
|
||||
},
|
||||
start: startQBittorrent,
|
||||
canAuto: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func builtinEngineCandidate() engineCandidate {
|
||||
return engineCandidate{
|
||||
name: "builtin",
|
||||
engine: func(cfg config.Config) engine.Engine { return engine.HTTP{Dir: cfg.DownloadDir} },
|
||||
}
|
||||
}
|
||||
|
||||
func selectEngine(cfg config.Config) engine.Engine {
|
||||
switch cfg.Engine {
|
||||
case "aria2":
|
||||
return engine.Aria2{URL: cfg.Aria2URL, Secret: cfg.Aria2Secret, Dir: cfg.DownloadDir}
|
||||
case "qbittorrent":
|
||||
return engine.QBittorrent{
|
||||
URL: cfg.QBittorrentURL,
|
||||
Username: cfg.QBittorrentUser,
|
||||
Password: cfg.QBittorrentPass,
|
||||
Dir: cfg.DownloadDir,
|
||||
}
|
||||
default:
|
||||
return engine.HTTP{Dir: cfg.DownloadDir}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForEngine(ctx context.Context, downloader engine.Engine) error {
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
err := downloader.Check(checkCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func startAria2(ctx context.Context, cfg config.Config) (*exec.Cmd, error) {
|
||||
path, err := exec.LookPath("aria2c")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rpcURL, err := parseLocalEngineURL(cfg.Aria2URL, "6800")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-all=false",
|
||||
"--rpc-listen-port=" + rpcURL.port,
|
||||
"--dir=" + cfg.DownloadDir,
|
||||
"--continue=true",
|
||||
"--allow-overwrite=true",
|
||||
"--auto-file-renaming=false",
|
||||
}
|
||||
if cfg.Aria2Secret != "" {
|
||||
args = append(args, "--rpc-secret="+cfg.Aria2Secret)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func startQBittorrent(ctx context.Context, cfg config.Config) (*exec.Cmd, error) {
|
||||
path, err := lookPathAny("qbittorrent-nox", "qbittorrent")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webURL, err := parseLocalEngineURL(cfg.QBittorrentURL, "8080")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{}
|
||||
if strings.Contains(filepathBase(path), "qbittorrent-nox") {
|
||||
args = append(args, "--webui-port="+webURL.port)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func lookPathAny(names ...string) (string, error) {
|
||||
var lastErr error
|
||||
for _, name := range names {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
type localEngineURL struct {
|
||||
port string
|
||||
}
|
||||
|
||||
func parseLocalEngineURL(raw string, defaultPort string) (localEngineURL, error) {
|
||||
normalized := raw
|
||||
if strings.HasPrefix(normalized, "ws://") {
|
||||
normalized = "http://" + strings.TrimPrefix(normalized, "ws://")
|
||||
}
|
||||
if strings.HasPrefix(normalized, "wss://") {
|
||||
normalized = "https://" + strings.TrimPrefix(normalized, "wss://")
|
||||
}
|
||||
parsed, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
return localEngineURL{}, err
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host != "" && host != "127.0.0.1" && host != "localhost" && host != "::1" {
|
||||
return localEngineURL{}, fmt.Errorf("auto start only supports local engine URLs, got %s", host)
|
||||
}
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
port = defaultPort
|
||||
}
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
return localEngineURL{}, fmt.Errorf("invalid engine port %q", port)
|
||||
}
|
||||
return localEngineURL{port: port}, nil
|
||||
}
|
||||
|
||||
func filepathBase(path string) string {
|
||||
parts := strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == '\\' })
|
||||
if len(parts) == 0 {
|
||||
return path
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
func capabilities(name string) []string {
|
||||
switch name {
|
||||
case "aria2", "qbittorrent":
|
||||
return []string{"http", "magnet", "torrent"}
|
||||
default:
|
||||
return []string{"http"}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadFile(ctx context.Context, url, path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return fmt.Errorf("upload failed: %s", res.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) taskLogger(task client.DownloadTask) *slog.Logger {
|
||||
return w.logger.With(
|
||||
"task_id", task.ID,
|
||||
"source_type", task.SourceType,
|
||||
"name", task.Name,
|
||||
"status", task.Status,
|
||||
)
|
||||
}
|
||||
|
||||
func optionalInt64(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
CREATE TABLE `download_tasks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`org_id` text NOT NULL,
|
||||
`created_by_user_id` text NOT NULL,
|
||||
`source_type` text NOT NULL,
|
||||
`source_uri` text NOT NULL,
|
||||
`name` text,
|
||||
`target_folder` text DEFAULT '' NOT NULL,
|
||||
`assigned_downloader_id` text,
|
||||
`status` text NOT NULL,
|
||||
`downloaded_bytes` integer DEFAULT 0 NOT NULL,
|
||||
`total_bytes` integer,
|
||||
`authorized_bytes` integer DEFAULT 0 NOT NULL,
|
||||
`billed_bytes` integer DEFAULT 0 NOT NULL,
|
||||
`billed_credits` integer DEFAULT 0 NOT NULL,
|
||||
`billing_status` text DEFAULT 'none' NOT NULL,
|
||||
`download_bps` integer DEFAULT 0 NOT NULL,
|
||||
`upload_bps` integer DEFAULT 0 NOT NULL,
|
||||
`error_message` text,
|
||||
`result_object_id` text,
|
||||
`upload_token_hash` text,
|
||||
`upload_token_jti` text,
|
||||
`upload_token_expires_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`assigned_at` integer,
|
||||
`started_at` integer,
|
||||
`finished_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `download_tasks_org_created_idx` ON `download_tasks` (`org_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `download_tasks_org_status_idx` ON `download_tasks` (`org_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `download_tasks_downloader_idx` ON `download_tasks` (`assigned_downloader_id`,`status`);--> statement-breakpoint
|
||||
CREATE TABLE `downloaders` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`token_hash` text NOT NULL,
|
||||
`token_jti` text NOT NULL,
|
||||
`status` text DEFAULT 'offline' NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`version` text DEFAULT 'unknown' NOT NULL,
|
||||
`hostname` text DEFAULT 'unknown' NOT NULL,
|
||||
`platform` text DEFAULT 'unknown' NOT NULL,
|
||||
`arch` text DEFAULT 'unknown' NOT NULL,
|
||||
`engine` text DEFAULT 'builtin' NOT NULL,
|
||||
`capabilities` text DEFAULT '[]' NOT NULL,
|
||||
`max_concurrent_tasks` integer DEFAULT 1 NOT NULL,
|
||||
`current_tasks` integer DEFAULT 0 NOT NULL,
|
||||
`download_bps` integer DEFAULT 0 NOT NULL,
|
||||
`upload_bps` integer DEFAULT 0 NOT NULL,
|
||||
`free_disk_bytes` integer DEFAULT 0 NOT NULL,
|
||||
`remote_download_credit_billing_enabled` integer DEFAULT false NOT NULL,
|
||||
`remote_download_credit_unit_bytes` integer DEFAULT 104857600 NOT NULL,
|
||||
`remote_download_credit_per_unit` integer DEFAULT 1 NOT NULL,
|
||||
`last_heartbeat_at` integer,
|
||||
`created_by` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `downloaders_token_jti_unique` ON `downloaders` (`token_jti`);--> statement-breakpoint
|
||||
CREATE INDEX `downloaders_status_idx` ON `downloaders` (`status`);--> statement-breakpoint
|
||||
CREATE INDEX `downloaders_enabled_idx` ON `downloaders` (`enabled`);--> statement-breakpoint
|
||||
CREATE INDEX `downloaders_created_idx` ON `downloaders` (`created_at`);--> statement-breakpoint
|
||||
CREATE TABLE `object_upload_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`org_id` text NOT NULL,
|
||||
`object_id` text NOT NULL,
|
||||
`storage_id` text NOT NULL,
|
||||
`storage_key` text NOT NULL,
|
||||
`upload_id` text NOT NULL,
|
||||
`part_size` integer NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`created_by` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `object_upload_sessions_object_idx` ON `object_upload_sessions` (`org_id`,`object_id`);--> statement-breakpoint
|
||||
CREATE INDEX `object_upload_sessions_expires_idx` ON `object_upload_sessions` (`expires_at`);--> statement-breakpoint
|
||||
CREATE TABLE `remote_download_usage_reports` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`org_id` text NOT NULL,
|
||||
`downloader_id` text NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`event_id` text NOT NULL,
|
||||
`unit_index` integer NOT NULL,
|
||||
`unit_bytes` integer NOT NULL,
|
||||
`credits_per_unit` integer NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`error` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `remote_download_usage_reports_event_id_unique` ON `remote_download_usage_reports` (`event_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `remote_download_usage_task_unit_uniq` ON `remote_download_usage_reports` (`task_id`,`unit_index`);--> statement-breakpoint
|
||||
CREATE INDEX `remote_download_usage_org_idx` ON `remote_download_usage_reports` (`org_id`);--> statement-breakpoint
|
||||
CREATE INDEX `remote_download_usage_status_idx` ON `remote_download_usage_reports` (`status`);--> statement-breakpoint
|
||||
CREATE TABLE `deviceCode` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`device_code` text NOT NULL,
|
||||
`user_code` text NOT NULL,
|
||||
`user_id` text,
|
||||
`client_id` text,
|
||||
`scope` text,
|
||||
`status` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`last_polled_at` integer,
|
||||
`polling_interval` integer,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `deviceCode_device_code_idx` ON `deviceCode` (`device_code`);--> statement-breakpoint
|
||||
CREATE INDEX `deviceCode_user_code_idx` ON `deviceCode` (`user_code`);--> statement-breakpoint
|
||||
CREATE INDEX `deviceCode_status_idx` ON `deviceCode` (`status`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -239,6 +239,13 @@
|
||||
"when": 1780452954859,
|
||||
"tag": "0034_gorgeous_hydra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "6",
|
||||
"when": 1780459022634,
|
||||
"tag": "0035_puzzling_hemingway",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-1
@@ -35,7 +35,8 @@
|
||||
"e2e:cloud": "node scripts/run-cloud-e2e.mjs",
|
||||
"e2e:cloud:cf": "node scripts/run-cloud-e2e.mjs --runtime cf",
|
||||
"e2e:archive": "node scripts/run-cloud-e2e.mjs --local --with-s3-mock --spec archive.spec.ts",
|
||||
"e2e:archive:cf": "node scripts/run-cloud-e2e.mjs --runtime cf --local --with-s3-mock --spec archive.spec.ts"
|
||||
"e2e:archive:cf": "node scripts/run-cloud-e2e.mjs --runtime cf --local --with-s3-mock --spec archive.spec.ts",
|
||||
"openapi:downloader": "tsx scripts/generate-downloader-openapi.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
@@ -50,6 +51,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/zod-openapi": "^1.4.0",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@libsql/client": "^0.17.2",
|
||||
|
||||
Generated
+46
@@ -52,6 +52,9 @@ importers:
|
||||
'@hono/node-server':
|
||||
specifier: ^1.13.0
|
||||
version: 1.19.13(hono@4.12.18)
|
||||
'@hono/zod-openapi':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(hono@4.12.18)(zod@4.4.3)
|
||||
'@hono/zod-validator':
|
||||
specifier: ^0.7.6
|
||||
version: 0.7.6(hono@4.12.18)(zod@4.4.3)
|
||||
@@ -305,6 +308,11 @@ packages:
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@asteasolutions/zod-to-openapi@8.5.0':
|
||||
resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -1500,12 +1508,25 @@ packages:
|
||||
wrangler:
|
||||
optional: true
|
||||
|
||||
'@hono/zod-openapi@1.4.0':
|
||||
resolution: {integrity: sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
peerDependencies:
|
||||
hono: '>=4.10.0'
|
||||
zod: ^4.0.0
|
||||
|
||||
'@hono/zod-validator@0.7.6':
|
||||
resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==}
|
||||
peerDependencies:
|
||||
hono: '>=3.9.0'
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@hono/zod-validator@0.8.0':
|
||||
resolution: {integrity: sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w==}
|
||||
peerDependencies:
|
||||
hono: '>=4.10.0'
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@hookform/resolvers@5.2.2':
|
||||
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
||||
peerDependencies:
|
||||
@@ -4650,6 +4671,9 @@ packages:
|
||||
oniguruma-to-es@4.3.6:
|
||||
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||
|
||||
openapi3-ts@4.6.0:
|
||||
resolution: {integrity: sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -5611,6 +5635,11 @@ snapshots:
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
'@asteasolutions/zod-to-openapi@8.5.0(zod@4.4.3)':
|
||||
dependencies:
|
||||
openapi3-ts: 4.6.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
@@ -6665,11 +6694,24 @@ snapshots:
|
||||
miniflare: 4.20260526.0
|
||||
wrangler: 4.95.0(@cloudflare/workers-types@4.20260505.1)
|
||||
|
||||
'@hono/zod-openapi@1.4.0(hono@4.12.18)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@asteasolutions/zod-to-openapi': 8.5.0(zod@4.4.3)
|
||||
'@hono/zod-validator': 0.8.0(hono@4.12.18)(zod@4.4.3)
|
||||
hono: 4.12.18
|
||||
openapi3-ts: 4.6.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@hono/zod-validator@0.7.6(hono@4.12.18)(zod@4.4.3)':
|
||||
dependencies:
|
||||
hono: 4.12.18
|
||||
zod: 4.4.3
|
||||
|
||||
'@hono/zod-validator@0.8.0(hono@4.12.18)(zod@4.4.3)':
|
||||
dependencies:
|
||||
hono: 4.12.18
|
||||
zod: 4.4.3
|
||||
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.5))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
@@ -9917,6 +9959,10 @@ snapshots:
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
openapi3-ts@4.6.0:
|
||||
dependencies:
|
||||
yaml: 2.9.0
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { downloaderOpenAPIDocument } from '../server/openapi/downloader'
|
||||
|
||||
const output = resolve('docs/openapi/downloader.json')
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
await writeFile(output, `${JSON.stringify(downloaderOpenAPIDocument(), null, 2)}\n`)
|
||||
@@ -6,6 +6,7 @@ import { imageHostingDomain } from './middleware/image-hosting-domain'
|
||||
import { accessLog } from './middleware/logger'
|
||||
import type { Env } from './middleware/platform'
|
||||
import { platformMiddleware } from './middleware/platform'
|
||||
import { downloaderOpenAPIDocument } from './openapi/downloader'
|
||||
import type { Platform } from './platform/interface'
|
||||
import { adminAnnouncements, announcements } from './routes/announcements'
|
||||
import { adminAudit } from './routes/audit'
|
||||
@@ -13,6 +14,8 @@ import { adminAuthProviders, publicAuthProviders } from './routes/auth-providers
|
||||
import backgroundJobs from './routes/background-jobs'
|
||||
import { brandingAdmin, publicBranding } from './routes/branding'
|
||||
import { adminCloudStore, cloudStore, cloudStoreWebhooks } from './routes/cloud-store'
|
||||
import downloadTasks from './routes/download-tasks'
|
||||
import downloaders, { downloaderSelfRoute } from './routes/downloaders'
|
||||
import emailConfig from './routes/email-config'
|
||||
import ihost from './routes/ihost'
|
||||
import ihostConfig from './routes/ihost-config'
|
||||
@@ -58,6 +61,8 @@ export function createApp(platform: Platform, auth: Auth) {
|
||||
return a.handler(c.req.raw)
|
||||
})
|
||||
|
||||
app.get('/api/openapi/downloader.json', (c) => c.json(downloaderOpenAPIDocument()))
|
||||
|
||||
app.all('/dav', (c) => c.redirect('/dav/', 308))
|
||||
app.route('/dav', webdav)
|
||||
|
||||
@@ -100,12 +105,15 @@ export function createApp(platform: Platform, auth: Auth) {
|
||||
app.route('/api/admin/auth-providers', adminAuthProviders)
|
||||
app.route('/api/notifications', notifications)
|
||||
app.route('/api/background-jobs', backgroundJobs)
|
||||
app.route('/api/download-tasks', downloadTasks)
|
||||
app.route('/api/downloader', downloaderSelfRoute)
|
||||
app.route('/api/ihost', ihost)
|
||||
app.route('/api/ihost/config', ihostConfig)
|
||||
app.route('/api/licensing', licensingAdmin)
|
||||
app.route('/api/admin/branding', brandingAdmin)
|
||||
app.route('/api/admin/announcements', adminAnnouncements)
|
||||
app.route('/api/admin/audit', adminAudit)
|
||||
app.route('/api/admin/downloaders', downloaders)
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok' }))
|
||||
|
||||
@@ -139,6 +147,9 @@ export type TeamsRoute = typeof teams
|
||||
export type PublicTeamsRoute = typeof publicTeams
|
||||
export type NotificationsRoute = typeof notifications
|
||||
export type BackgroundJobsRoute = typeof backgroundJobs
|
||||
export type DownloadTasksRoute = typeof downloadTasks
|
||||
export type DownloadersRoute = typeof downloaders
|
||||
export type DownloaderSelfRoute = typeof downloaderSelfRoute
|
||||
export type IhostRoute = typeof ihost
|
||||
export type IhostConfigRoute = typeof ihostConfig
|
||||
export type MeRoute = typeof me
|
||||
|
||||
+7
-1
@@ -1,7 +1,7 @@
|
||||
import { apiKey } from '@better-auth/api-key'
|
||||
import { APIError, type BetterAuthPlugin, betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { admin, captcha, organization, username } from 'better-auth/plugins'
|
||||
import { admin, bearer, captcha, deviceAuthorization, organization, username } from 'better-auth/plugins'
|
||||
import { genericOAuth } from 'better-auth/plugins/generic-oauth'
|
||||
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
|
||||
import { count, eq, like } from 'drizzle-orm'
|
||||
@@ -277,6 +277,12 @@ export async function createAuth(
|
||||
scopes: c.scopes,
|
||||
})),
|
||||
}),
|
||||
bearer(),
|
||||
deviceAuthorization({
|
||||
schema: {},
|
||||
verificationUri: '/device',
|
||||
validateClient: async (clientId) => clientId === 'zpan-downloader',
|
||||
}),
|
||||
apiKey([
|
||||
{
|
||||
configId: 'default',
|
||||
|
||||
@@ -183,6 +183,34 @@ export const apikey = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const deviceCode = sqliteTable(
|
||||
'deviceCode',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
deviceCode: text('device_code').notNull(),
|
||||
userCode: text('user_code').notNull(),
|
||||
userId: text('user_id'),
|
||||
clientId: text('client_id'),
|
||||
scope: text('scope'),
|
||||
status: text('status').notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
lastPolledAt: integer('last_polled_at', { mode: 'timestamp_ms' }),
|
||||
pollingInterval: integer('polling_interval'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('deviceCode_device_code_idx').on(table.deviceCode),
|
||||
index('deviceCode_user_code_idx').on(table.userCode),
|
||||
index('deviceCode_status_idx').on(table.status),
|
||||
],
|
||||
)
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
|
||||
@@ -287,6 +287,128 @@ export const backgroundJobs = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const downloaders = sqliteTable(
|
||||
'downloaders',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
tokenJti: text('token_jti').notNull().unique(),
|
||||
status: text('status').notNull().default('offline'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
version: text('version').notNull().default('unknown'),
|
||||
hostname: text('hostname').notNull().default('unknown'),
|
||||
platform: text('platform').notNull().default('unknown'),
|
||||
arch: text('arch').notNull().default('unknown'),
|
||||
engine: text('engine').notNull().default('builtin'),
|
||||
capabilities: text('capabilities').notNull().default('[]'),
|
||||
maxConcurrentTasks: integer('max_concurrent_tasks').notNull().default(1),
|
||||
currentTasks: integer('current_tasks').notNull().default(0),
|
||||
downloadBps: integer('download_bps').notNull().default(0),
|
||||
uploadBps: integer('upload_bps').notNull().default(0),
|
||||
freeDiskBytes: integer('free_disk_bytes').notNull().default(0),
|
||||
remoteDownloadCreditBillingEnabled: integer('remote_download_credit_billing_enabled', {
|
||||
mode: 'boolean',
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
remoteDownloadCreditUnitBytes: integer('remote_download_credit_unit_bytes').notNull().default(104857600),
|
||||
remoteDownloadCreditPerUnit: integer('remote_download_credit_per_unit').notNull().default(1),
|
||||
lastHeartbeatAt: integer('last_heartbeat_at', { mode: 'timestamp_ms' }),
|
||||
createdBy: text('created_by').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('downloaders_status_idx').on(t.status),
|
||||
index('downloaders_enabled_idx').on(t.enabled),
|
||||
index('downloaders_created_idx').on(t.createdAt),
|
||||
],
|
||||
)
|
||||
|
||||
export const downloadTasks = sqliteTable(
|
||||
'download_tasks',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
orgId: text('org_id').notNull(),
|
||||
createdByUserId: text('created_by_user_id').notNull(),
|
||||
sourceType: text('source_type').notNull(),
|
||||
sourceUri: text('source_uri').notNull(),
|
||||
name: text('name'),
|
||||
targetFolder: text('target_folder').notNull().default(''),
|
||||
assignedDownloaderId: text('assigned_downloader_id'),
|
||||
status: text('status').notNull(),
|
||||
downloadedBytes: integer('downloaded_bytes').notNull().default(0),
|
||||
totalBytes: integer('total_bytes'),
|
||||
authorizedBytes: integer('authorized_bytes').notNull().default(0),
|
||||
billedBytes: integer('billed_bytes').notNull().default(0),
|
||||
billedCredits: integer('billed_credits').notNull().default(0),
|
||||
billingStatus: text('billing_status').notNull().default('none'),
|
||||
downloadBps: integer('download_bps').notNull().default(0),
|
||||
uploadBps: integer('upload_bps').notNull().default(0),
|
||||
errorMessage: text('error_message'),
|
||||
resultObjectId: text('result_object_id'),
|
||||
uploadTokenHash: text('upload_token_hash'),
|
||||
uploadTokenJti: text('upload_token_jti'),
|
||||
uploadTokenExpiresAt: integer('upload_token_expires_at', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
assignedAt: integer('assigned_at', { mode: 'timestamp_ms' }),
|
||||
startedAt: integer('started_at', { mode: 'timestamp_ms' }),
|
||||
finishedAt: integer('finished_at', { mode: 'timestamp_ms' }),
|
||||
},
|
||||
(t) => [
|
||||
index('download_tasks_org_created_idx').on(t.orgId, t.createdAt),
|
||||
index('download_tasks_org_status_idx').on(t.orgId, t.status),
|
||||
index('download_tasks_downloader_idx').on(t.assignedDownloaderId, t.status),
|
||||
],
|
||||
)
|
||||
|
||||
export const objectUploadSessions = sqliteTable(
|
||||
'object_upload_sessions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
orgId: text('org_id').notNull(),
|
||||
objectId: text('object_id').notNull(),
|
||||
storageId: text('storage_id').notNull(),
|
||||
storageKey: text('storage_key').notNull(),
|
||||
uploadId: text('upload_id').notNull(),
|
||||
partSize: integer('part_size').notNull(),
|
||||
status: text('status').notNull(),
|
||||
createdBy: text('created_by').notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('object_upload_sessions_object_idx').on(t.orgId, t.objectId),
|
||||
index('object_upload_sessions_expires_idx').on(t.expiresAt),
|
||||
],
|
||||
)
|
||||
|
||||
export const remoteDownloadUsageReports = sqliteTable(
|
||||
'remote_download_usage_reports',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
orgId: text('org_id').notNull(),
|
||||
downloaderId: text('downloader_id').notNull(),
|
||||
taskId: text('task_id').notNull(),
|
||||
eventId: text('event_id').notNull().unique(),
|
||||
unitIndex: integer('unit_index').notNull(),
|
||||
unitBytes: integer('unit_bytes').notNull(),
|
||||
creditsPerUnit: integer('credits_per_unit').notNull(),
|
||||
status: text('status').notNull(),
|
||||
error: text('error'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('remote_download_usage_task_unit_uniq').on(t.taskId, t.unitIndex),
|
||||
index('remote_download_usage_org_idx').on(t.orgId),
|
||||
index('remote_download_usage_status_idx').on(t.status),
|
||||
],
|
||||
)
|
||||
|
||||
export const announcements = sqliteTable(
|
||||
'announcements',
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { resolveDownloaderToken, resolveTaskUploadToken } from '../services/download-tokens'
|
||||
import { findPersonalOrg, getMemberRole, isPersonalOrg } from '../services/org'
|
||||
import type { Env } from './platform'
|
||||
|
||||
@@ -18,6 +19,30 @@ type SessionWithPlugins = {
|
||||
}
|
||||
|
||||
export const authMiddleware = createMiddleware<Env>(async (c, next) => {
|
||||
const authHeader = c.req.raw.headers.get('Authorization')
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice('Bearer '.length).trim()
|
||||
const platform = c.get('platform')
|
||||
const taskUpload = await resolveTaskUploadToken(platform.db, platform, token)
|
||||
if (taskUpload) {
|
||||
c.set('principal', { ...taskUpload, kind: 'download-task-upload', authMethod: 'bearer' })
|
||||
c.set('userId', null)
|
||||
c.set('userRole', null)
|
||||
c.set('orgId', taskUpload.orgId)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
const downloader = await resolveDownloaderToken(platform, token)
|
||||
if (downloader) {
|
||||
c.set('principal', { kind: 'downloader', downloaderId: downloader.downloaderId, authMethod: 'bearer' })
|
||||
c.set('userId', null)
|
||||
c.set('userRole', null)
|
||||
c.set('orgId', null)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const auth = c.get('auth')
|
||||
const result = (await auth.api.getSession({ headers: c.req.raw.headers })) as SessionWithPlugins | null
|
||||
|
||||
@@ -35,13 +60,27 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
|
||||
if (result?.user?.id) {
|
||||
const orgId = result.session?.activeOrganizationId ?? (await findPersonalOrg(c.get('platform').db, result.user.id))
|
||||
c.set('orgId', orgId)
|
||||
c.set('principal', {
|
||||
kind: 'user',
|
||||
userId: result.user.id,
|
||||
role: result.user.role,
|
||||
orgId,
|
||||
authMethod: authHeader?.startsWith('Bearer ') ? 'bearer' : 'cookie',
|
||||
})
|
||||
} else {
|
||||
c.set('orgId', null)
|
||||
c.set('principal', null)
|
||||
}
|
||||
|
||||
await next()
|
||||
})
|
||||
|
||||
export const requireDownloader = createMiddleware<Env>(async (c, next) => {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401)
|
||||
await next()
|
||||
})
|
||||
|
||||
export const requireAuth = createMiddleware<Env>(async (c, next) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) {
|
||||
|
||||
@@ -6,15 +6,41 @@ export type Env = {
|
||||
Variables: {
|
||||
platform: Platform
|
||||
auth: Auth
|
||||
principal: AuthPrincipal | null
|
||||
userId: string | null
|
||||
userRole: string | null
|
||||
orgId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthPrincipal =
|
||||
| {
|
||||
kind: 'user'
|
||||
userId: string
|
||||
role?: string
|
||||
orgId: string | null
|
||||
authMethod: 'cookie' | 'bearer'
|
||||
}
|
||||
| {
|
||||
kind: 'downloader'
|
||||
downloaderId: string
|
||||
authMethod: 'bearer'
|
||||
}
|
||||
| {
|
||||
kind: 'download-task-upload'
|
||||
downloaderId: string
|
||||
taskId: string
|
||||
orgId: string
|
||||
targetFolder: string
|
||||
createdByUserId: string
|
||||
scopes: string[]
|
||||
authMethod: 'bearer'
|
||||
}
|
||||
|
||||
export const platformMiddleware = (platform: Platform, auth: Auth) =>
|
||||
createMiddleware<Env>(async (c, next) => {
|
||||
c.set('platform', platform)
|
||||
c.set('auth', auth)
|
||||
c.set('principal', null)
|
||||
await next()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestApp } from '../test/setup'
|
||||
|
||||
describe('downloader OpenAPI', () => {
|
||||
it('serves downloader documentation from the real app route', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi/downloader.json')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const doc = (await res.json()) as { paths: Record<string, unknown> }
|
||||
expect(Object.keys(doc.paths)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'/api/auth/device/code',
|
||||
'/api/auth/device/token',
|
||||
'/api/download-tasks',
|
||||
'/api/download-tasks/{id}',
|
||||
'/api/downloader/heartbeat',
|
||||
'/api/admin/downloaders',
|
||||
'/api/admin/downloaders/{id}',
|
||||
'/api/objects',
|
||||
'/api/objects/{id}',
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { createMatterSchema } from '@shared/schemas'
|
||||
import downloadTasks from '../routes/download-tasks'
|
||||
import downloaders, { downloaderSelfRoute } from '../routes/downloaders'
|
||||
|
||||
const errorSchema = z.object({ error: z.string() }).openapi('ErrorResponse')
|
||||
|
||||
const deviceCodeRequestSchema = z
|
||||
.object({
|
||||
client_id: z.string(),
|
||||
scope: z.string(),
|
||||
})
|
||||
.openapi('DeviceCodeRequest')
|
||||
|
||||
const deviceCodeSchema = z
|
||||
.object({
|
||||
device_code: z.string(),
|
||||
user_code: z.string(),
|
||||
verification_uri: z.string(),
|
||||
verification_uri_complete: z.string(),
|
||||
expires_in: z.number().int(),
|
||||
interval: z.number().int(),
|
||||
})
|
||||
.openapi('DeviceCode')
|
||||
|
||||
const deviceTokenRequestSchema = z
|
||||
.object({
|
||||
grant_type: z.string(),
|
||||
device_code: z.string(),
|
||||
client_id: z.string(),
|
||||
})
|
||||
.openapi('DeviceTokenRequest')
|
||||
|
||||
const deviceTokenSchema = z
|
||||
.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number().int(),
|
||||
scope: z.string(),
|
||||
})
|
||||
.openapi('DeviceToken')
|
||||
|
||||
const objectDraftSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
uploadUrl: z.string(),
|
||||
})
|
||||
.openapi('ObjectDraft')
|
||||
|
||||
const confirmObjectRequestSchema = z
|
||||
.object({
|
||||
action: z.enum(['confirm']),
|
||||
})
|
||||
.openapi('ConfirmObjectRequest')
|
||||
|
||||
function jsonResponse(schema: z.ZodType, description: string) {
|
||||
return {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema,
|
||||
},
|
||||
},
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
function mountBetterAuthDeviceRoutes(app: OpenAPIHono) {
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/api/auth/device/code',
|
||||
request: {
|
||||
body: {
|
||||
content: { 'application/json': { schema: deviceCodeRequestSchema } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: jsonResponse(deviceCodeSchema, 'Device authorization code'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json({} as z.infer<typeof deviceCodeSchema>, 200),
|
||||
)
|
||||
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/api/auth/device/token',
|
||||
request: {
|
||||
body: {
|
||||
content: { 'application/json': { schema: deviceTokenRequestSchema } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: jsonResponse(deviceTokenSchema, 'Device access token'),
|
||||
400: jsonResponse(errorSchema, 'Device login error'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json({} as z.infer<typeof deviceTokenSchema>, 200),
|
||||
)
|
||||
}
|
||||
|
||||
function mountObjectUploadRoutes(app: OpenAPIHono) {
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/api/objects',
|
||||
request: {
|
||||
body: {
|
||||
content: { 'application/json': { schema: createMatterSchema } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: jsonResponse(objectDraftSchema, 'Object draft with upload URL'),
|
||||
201: jsonResponse(objectDraftSchema, 'Object draft with upload URL'),
|
||||
403: jsonResponse(errorSchema, 'Forbidden'),
|
||||
409: jsonResponse(errorSchema, 'Name conflict'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json({} as z.infer<typeof objectDraftSchema>, 200),
|
||||
)
|
||||
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: 'patch',
|
||||
path: '/api/objects/{id}',
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: {
|
||||
content: { 'application/json': { schema: confirmObjectRequestSchema } },
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: jsonResponse(objectDraftSchema, 'Confirmed object'),
|
||||
403: jsonResponse(errorSchema, 'Forbidden'),
|
||||
404: jsonResponse(errorSchema, 'Not found'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json({} as z.infer<typeof objectDraftSchema>, 200),
|
||||
)
|
||||
}
|
||||
|
||||
export function createDownloaderOpenAPIApp() {
|
||||
const app = new OpenAPIHono()
|
||||
mountBetterAuthDeviceRoutes(app)
|
||||
app.route('/api/download-tasks', downloadTasks)
|
||||
app.route('/api/downloader', downloaderSelfRoute)
|
||||
app.route('/api/admin/downloaders', downloaders)
|
||||
mountObjectUploadRoutes(app)
|
||||
return app
|
||||
}
|
||||
|
||||
export function downloaderOpenAPIDocument() {
|
||||
return createDownloaderOpenAPIApp().getOpenAPIDocument({
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'ZPan Downloader API',
|
||||
version: '0.1.0',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { S3Service } from '../services/s3.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'presignUpload').mockResolvedValue('https://presigned-upload.example.com')
|
||||
vi.spyOn(S3Service.prototype, 'createMultipartUpload').mockResolvedValue('upload-1')
|
||||
vi.spyOn(S3Service.prototype, 'presignUploadPart').mockResolvedValue('https://presigned-part.example.com')
|
||||
vi.spyOn(S3Service.prototype, 'completeMultipartUpload').mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const heartbeat = {
|
||||
version: 'test-1.0.0',
|
||||
hostname: 'test-downloader',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
engine: 'aria2',
|
||||
capabilities: ['http', 'magnet', 'torrent'],
|
||||
maxConcurrentTasks: 2,
|
||||
currentTasks: 0,
|
||||
downloadBps: 0,
|
||||
uploadBps: 0,
|
||||
freeDiskBytes: 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'remote-download-storage', 'Remote Download Storage', 'private', 'test-bucket',
|
||||
'https://s3.example.com', 'auto', 'test-access-key', 'test-secret-key',
|
||||
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
async function registerDownloaderThroughDeviceLogin(
|
||||
app: Awaited<ReturnType<typeof createTestApp>>['app'],
|
||||
name: string,
|
||||
headers?: { Cookie: string },
|
||||
) {
|
||||
const admin = headers ?? (await adminHeaders(app))
|
||||
|
||||
const codeRes = await app.request('/api/auth/device/code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: 'zpan-downloader', scope: 'downloader:register' }),
|
||||
})
|
||||
expect(codeRes.status).toBe(200)
|
||||
const code = (await codeRes.json()) as { device_code: string; user_code: string }
|
||||
|
||||
const verifyRes = await app.request(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`, {
|
||||
headers: admin,
|
||||
})
|
||||
expect(verifyRes.status).toBe(200)
|
||||
await expect(verifyRes.json()).resolves.toMatchObject({ status: 'pending' })
|
||||
|
||||
const approveRes = await app.request('/api/auth/device/approve', {
|
||||
method: 'POST',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userCode: code.user_code }),
|
||||
})
|
||||
expect(approveRes.status).toBe(200)
|
||||
|
||||
const tokenRes = await app.request('/api/auth/device/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
device_code: code.device_code,
|
||||
client_id: 'zpan-downloader',
|
||||
}),
|
||||
})
|
||||
expect(tokenRes.status).toBe(200)
|
||||
const token = (await tokenRes.json()) as { access_token: string }
|
||||
|
||||
const createDownloaderRes = await app.request('/api/admin/downloaders', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.access_token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, heartbeat }),
|
||||
})
|
||||
expect(createDownloaderRes.status).toBe(201)
|
||||
return createDownloaderRes.json() as Promise<{ downloader: { id: string; name: string }; token: string }>
|
||||
}
|
||||
|
||||
describe('Download tasks API integration', () => {
|
||||
it('registers a downloader through BetterAuth device login', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const created = await registerDownloaderThroughDeviceLogin(app, 'device-login-downloader')
|
||||
expect(created.downloader.name).toBe('device-login-downloader')
|
||||
expect(created.token).toBeTruthy()
|
||||
})
|
||||
|
||||
it('deletes a downloader and returns unfinished tasks to the queue', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const admin = await adminHeaders(app)
|
||||
const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'delete-me', admin)
|
||||
const downloaderHeaders = {
|
||||
Authorization: `Bearer ${createdDownloader.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
expect(
|
||||
await app.request('/api/downloader/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({ ...heartbeat, currentTasks: 0 }),
|
||||
}),
|
||||
).toHaveProperty('status', 200)
|
||||
|
||||
const user = await authedHeaders(app, 'delete-downloader-user@example.com')
|
||||
const createTaskRes = await app.request('/api/download-tasks', {
|
||||
method: 'POST',
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: { type: 'http', uri: 'https://example.com/delete-me.txt' },
|
||||
targetFolder: '',
|
||||
}),
|
||||
})
|
||||
expect(createTaskRes.status).toBe(201)
|
||||
const task = (await createTaskRes.json()) as { id: string; status: string; assignedDownloaderId: string }
|
||||
expect(task.status).toBe('assigned')
|
||||
expect(task.assignedDownloaderId).toBe(createdDownloader.downloader.id)
|
||||
|
||||
const deleteRes = await app.request(`/api/admin/downloaders/${createdDownloader.downloader.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: admin,
|
||||
})
|
||||
expect(deleteRes.status).toBe(200)
|
||||
await expect(deleteRes.json()).resolves.toEqual({ id: createdDownloader.downloader.id, deleted: true })
|
||||
|
||||
const taskRes = await app.request(`/api/download-tasks/${task.id}`, { headers: user })
|
||||
expect(taskRes.status).toBe(200)
|
||||
await expect(taskRes.json()).resolves.toMatchObject({ status: 'queued', assignedDownloaderId: null })
|
||||
})
|
||||
|
||||
it('runs the remote download task upload flow through the standard object upload API', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
|
||||
const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'vps-1')
|
||||
const downloaderHeaders = {
|
||||
Authorization: `Bearer ${createdDownloader.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const heartbeatRes = await app.request('/api/downloader/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({ ...heartbeat, currentTasks: 0, downloadBps: 128_000 }),
|
||||
})
|
||||
expect(heartbeatRes.status).toBe(200)
|
||||
|
||||
const user = await authedHeaders(app, 'downloader-user@example.com')
|
||||
const createTaskRes = await app.request('/api/download-tasks', {
|
||||
method: 'POST',
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: { type: 'http', uri: 'https://example.com/fixture.txt' },
|
||||
targetFolder: 'Remote Downloads',
|
||||
name: 'fixture.txt',
|
||||
}),
|
||||
})
|
||||
expect(createTaskRes.status).toBe(201)
|
||||
const createdTask = (await createTaskRes.json()) as {
|
||||
id: string
|
||||
assignedDownloaderId: string
|
||||
status: string
|
||||
uploadToken?: string
|
||||
}
|
||||
expect(createdTask.status).toBe('assigned')
|
||||
expect(createdTask.assignedDownloaderId).toBe(createdDownloader.downloader.id)
|
||||
expect(createdTask.uploadToken).toBeUndefined()
|
||||
|
||||
const assignedRes = await app.request('/api/download-tasks?assignedTo=me', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(assignedRes.status).toBe(200)
|
||||
const assigned = (await assignedRes.json()) as {
|
||||
items: Array<{ id: string; uploadToken?: string; status: string }>
|
||||
}
|
||||
const assignedTask = assigned.items.find((item) => item.id === createdTask.id)
|
||||
expect(assignedTask?.status).toBe('assigned')
|
||||
expect(assignedTask?.uploadToken).toBeTruthy()
|
||||
const uploadHeaders = {
|
||||
Authorization: `Bearer ${assignedTask?.uploadToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const runningRes = await app.request(`/api/download-tasks/${createdTask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({
|
||||
status: 'running',
|
||||
downloadedBytes: 10 * 1024 * 1024,
|
||||
totalBytes: 10 * 1024 * 1024,
|
||||
downloadBps: 512_000,
|
||||
}),
|
||||
})
|
||||
expect(runningRes.status).toBe(200)
|
||||
|
||||
const createObjectRes = await app.request('/api/objects', {
|
||||
method: 'POST',
|
||||
headers: uploadHeaders,
|
||||
body: JSON.stringify({
|
||||
name: 'fixture.txt',
|
||||
type: 'text/plain',
|
||||
size: 10 * 1024 * 1024,
|
||||
parent: 'Remote Downloads',
|
||||
}),
|
||||
})
|
||||
expect(createObjectRes.status).toBe(201)
|
||||
const object = (await createObjectRes.json()) as { id: string; status: string; uploadUrl: string }
|
||||
expect(object.status).toBe('draft')
|
||||
expect(object.uploadUrl).toBe('https://presigned-upload.example.com')
|
||||
|
||||
const sessionRes = await app.request(`/api/objects/${object.id}/uploads`, {
|
||||
method: 'POST',
|
||||
headers: uploadHeaders,
|
||||
body: JSON.stringify({ partSize: 5 * 1024 * 1024 }),
|
||||
})
|
||||
expect(sessionRes.status).toBe(201)
|
||||
const session = (await sessionRes.json()) as { id: string; uploadId: string; status: string }
|
||||
expect(session.uploadId).toBe('upload-1')
|
||||
expect(session.status).toBe('active')
|
||||
|
||||
const partsRes = await app.request(`/api/objects/${object.id}/uploads/${session.id}/parts`, {
|
||||
method: 'POST',
|
||||
headers: uploadHeaders,
|
||||
body: JSON.stringify({ partNumbers: [1] }),
|
||||
})
|
||||
expect(partsRes.status).toBe(200)
|
||||
const parts = (await partsRes.json()) as { parts: Array<{ partNumber: number; url: string }> }
|
||||
expect(parts.parts).toEqual([{ partNumber: 1, url: 'https://presigned-part.example.com' }])
|
||||
|
||||
const completeUploadRes = await app.request(`/api/objects/${object.id}/uploads/${session.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: uploadHeaders,
|
||||
body: JSON.stringify({ action: 'complete', parts: [{ partNumber: 1, etag: 'etag-1' }] }),
|
||||
})
|
||||
expect(completeUploadRes.status).toBe(200)
|
||||
|
||||
const confirmRes = await app.request(`/api/objects/${object.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: uploadHeaders,
|
||||
body: JSON.stringify({ action: 'confirm', onConflict: 'fail' }),
|
||||
})
|
||||
expect(confirmRes.status).toBe(200)
|
||||
const confirmed = (await confirmRes.json()) as { id: string; status: string }
|
||||
expect(confirmed.status).toBe('active')
|
||||
|
||||
const completeTaskRes = await app.request(`/api/download-tasks/${createdTask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({
|
||||
status: 'completed',
|
||||
downloadedBytes: 10 * 1024 * 1024,
|
||||
totalBytes: 10 * 1024 * 1024,
|
||||
resultObjectId: object.id,
|
||||
}),
|
||||
})
|
||||
expect(completeTaskRes.status).toBe(200)
|
||||
|
||||
const taskRes = await app.request(`/api/download-tasks/${createdTask.id}`, { headers: user })
|
||||
expect(taskRes.status).toBe(200)
|
||||
const task = (await taskRes.json()) as { status: string; resultObjectId: string; downloadedBytes: number }
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.resultObjectId).toBe(object.id)
|
||||
expect(task.downloadedBytes).toBe(10 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { createDownloadTaskSchema, listDownloadTasksQuerySchema, updateDownloadTaskSchema } from '@shared/schemas'
|
||||
import type { Context } from 'hono'
|
||||
import { requireAuth, requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
createDownloadTask,
|
||||
DownloadError,
|
||||
getDownloadTask,
|
||||
listDownloadTasks,
|
||||
updateDownloadTask,
|
||||
} from '../services/downloads'
|
||||
|
||||
const errorSchema = z.object({ error: z.string() })
|
||||
const int64Schema = () => z.number().int().openapi({ type: 'integer', format: 'int64' })
|
||||
const nullableInt64Schema = () =>
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.openapi({ type: 'integer', format: 'int64', nullable: true } as never)
|
||||
|
||||
type OpenAPIContext = Context<Env> & {
|
||||
req: Context<Env>['req'] & {
|
||||
valid(target: 'json' | 'query'): unknown
|
||||
param(name: string): string
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTaskSchema = z.object({
|
||||
id: z.string(),
|
||||
sourceType: z.enum(['http', 'magnet', 'torrent_url']),
|
||||
sourceUri: z.string(),
|
||||
name: z.string(),
|
||||
targetFolder: z.string(),
|
||||
status: z.enum(['queued', 'assigned', 'running', 'billing_paused', 'uploading', 'completed', 'failed', 'canceled']),
|
||||
downloadedBytes: int64Schema(),
|
||||
totalBytes: nullableInt64Schema(),
|
||||
downloadBps: int64Schema(),
|
||||
uploadBps: int64Schema(),
|
||||
resultObjectId: z.string().nullable().optional(),
|
||||
uploadToken: z.string().optional(),
|
||||
assignedDownloaderId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
const downloadTaskPageSchema = z.object({
|
||||
items: z.array(downloadTaskSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
|
||||
function jsonResponse(schema: z.ZodType, description: string) {
|
||||
return { content: { 'application/json': { schema } }, description }
|
||||
}
|
||||
|
||||
const listRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
request: { query: listDownloadTasksQuerySchema },
|
||||
responses: {
|
||||
200: jsonResponse(downloadTaskPageSchema, 'Download tasks'),
|
||||
401: jsonResponse(errorSchema, 'Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const createRouteDoc = createRoute({
|
||||
method: 'post',
|
||||
path: '/',
|
||||
middleware: [requireAuth, requireTeamRole('editor')] as const,
|
||||
request: { body: { content: { 'application/json': { schema: createDownloadTaskSchema } }, required: true } },
|
||||
responses: {
|
||||
201: jsonResponse(downloadTaskSchema, 'Created download task'),
|
||||
401: jsonResponse(errorSchema, 'Unauthorized'),
|
||||
402: jsonResponse(errorSchema, 'Insufficient credits'),
|
||||
409: jsonResponse(errorSchema, 'Download task conflict'),
|
||||
},
|
||||
})
|
||||
|
||||
const getRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/{id}',
|
||||
middleware: [requireAuth] as const,
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
200: jsonResponse(downloadTaskSchema, 'Download task'),
|
||||
404: jsonResponse(errorSchema, 'Not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const updateRoute = createRoute({
|
||||
method: 'patch',
|
||||
path: '/{id}',
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: { content: { 'application/json': { schema: updateDownloadTaskSchema } }, required: true },
|
||||
},
|
||||
responses: {
|
||||
200: jsonResponse(downloadTaskSchema, 'Updated download task'),
|
||||
401: jsonResponse(errorSchema, 'Unauthorized'),
|
||||
402: jsonResponse(errorSchema, 'Insufficient credits'),
|
||||
404: jsonResponse(errorSchema, 'Not found'),
|
||||
409: jsonResponse(errorSchema, 'Download task conflict'),
|
||||
},
|
||||
})
|
||||
|
||||
const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, (async (c: OpenAPIContext) => {
|
||||
const principal = c.get('principal')
|
||||
const query = c.req.valid('query') as z.infer<typeof listDownloadTasksQuerySchema>
|
||||
if (query.assignedTo === 'me') {
|
||||
if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401)
|
||||
const result = await listDownloadTasks(c.get('platform'), {
|
||||
downloaderId: principal.downloaderId,
|
||||
status: query.status,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
includeUploadToken: true,
|
||||
})
|
||||
return c.json({ ...result, page: query.page, pageSize: query.pageSize })
|
||||
}
|
||||
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const result = await listDownloadTasks(c.get('platform'), {
|
||||
orgId,
|
||||
status: query.status,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
})
|
||||
return c.json({ ...result, page: query.page, pageSize: query.pageSize })
|
||||
}) as never)
|
||||
.openapi(createRouteDoc, (async (c: OpenAPIContext) => {
|
||||
const orgId = c.get('orgId')
|
||||
const userId = c.get('userId')
|
||||
if (!orgId || !userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
return downloadTaskResponse(
|
||||
c,
|
||||
async () =>
|
||||
createDownloadTask(
|
||||
c.get('platform'),
|
||||
orgId,
|
||||
userId,
|
||||
c.req.valid('json') as z.infer<typeof createDownloadTaskSchema>,
|
||||
),
|
||||
201,
|
||||
)
|
||||
}) as never)
|
||||
.openapi(getRoute, (async (c: OpenAPIContext) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const id = c.req.param('id') as string
|
||||
return downloadTaskResponse(c, async () => getDownloadTask(c.get('platform'), orgId, id))
|
||||
}) as never)
|
||||
.openapi(updateRoute, (async (c: OpenAPIContext) => {
|
||||
const principal = c.get('principal')
|
||||
const id = c.req.param('id') as string
|
||||
if (principal?.kind === 'downloader') {
|
||||
return downloadTaskResponse(
|
||||
c,
|
||||
async () =>
|
||||
updateDownloadTask(c.get('platform'), id, c.req.valid('json') as z.infer<typeof updateDownloadTaskSchema>, {
|
||||
downloaderId: principal.downloaderId,
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
return downloadTaskResponse(c, async () =>
|
||||
updateDownloadTask(c.get('platform'), id, c.req.valid('json') as z.infer<typeof updateDownloadTaskSchema>, {
|
||||
orgId,
|
||||
}),
|
||||
)
|
||||
}) as never)
|
||||
|
||||
export default downloadTasksRoute
|
||||
|
||||
async function downloadTaskResponse(c: Context<Env>, action: () => Promise<unknown>, status: 200 | 201 = 200) {
|
||||
try {
|
||||
return c.json(await action(), status)
|
||||
} catch (error) {
|
||||
if (error instanceof DownloadError) {
|
||||
if (error.code === 'not_found') return c.json({ error: 'Not found' }, 404)
|
||||
if (error.code === 'forbidden') return c.json({ error: 'Forbidden' }, 403)
|
||||
if (error.code === 'billing_paused') return c.json({ error: 'insufficient_credits' }, 402)
|
||||
return c.json({ error: error.message }, 409)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { createDownloaderSchema, downloaderHeartbeatSchema, updateDownloaderSchema } from '@shared/schemas'
|
||||
import type { Context } from 'hono'
|
||||
import { requireAdmin, requireDownloader } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
createDownloader,
|
||||
DownloadError,
|
||||
deleteDownloader,
|
||||
listDownloaders,
|
||||
recordDownloaderHeartbeat,
|
||||
updateDownloader,
|
||||
} from '../services/downloads'
|
||||
|
||||
const errorSchema = z.object({ error: z.string() })
|
||||
const int64Schema = () => z.number().int().openapi({ type: 'integer', format: 'int64' })
|
||||
|
||||
type OpenAPIContext = Context<Env> & {
|
||||
req: Context<Env>['req'] & {
|
||||
valid(target: 'json'): unknown
|
||||
param(name: string): string
|
||||
}
|
||||
}
|
||||
|
||||
const downloaderHeartbeatResponseSchema = z.object({
|
||||
version: z.string(),
|
||||
hostname: z.string(),
|
||||
platform: z.string(),
|
||||
arch: z.string(),
|
||||
engine: z.enum(['builtin', 'aria2', 'qbittorrent']),
|
||||
capabilities: z.array(z.string()),
|
||||
maxConcurrentTasks: z.number().int(),
|
||||
currentTasks: z.number().int(),
|
||||
downloadBps: int64Schema(),
|
||||
uploadBps: int64Schema(),
|
||||
freeDiskBytes: int64Schema(),
|
||||
})
|
||||
|
||||
const downloaderSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
status: z.enum(['online', 'offline', 'disabled']).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
heartbeat: downloaderHeartbeatResponseSchema.optional(),
|
||||
})
|
||||
|
||||
const downloaderListSchema = z.object({
|
||||
items: z.array(downloaderSchema),
|
||||
total: z.number().int(),
|
||||
})
|
||||
|
||||
const createDownloaderResponseSchema = z.object({
|
||||
downloader: downloaderSchema,
|
||||
token: z.string(),
|
||||
})
|
||||
|
||||
const deleteDownloaderResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
deleted: z.boolean(),
|
||||
})
|
||||
|
||||
function jsonResponse(schema: z.ZodType, description: string) {
|
||||
return { content: { 'application/json': { schema } }, description }
|
||||
}
|
||||
|
||||
const listRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
middleware: [requireAdmin] as const,
|
||||
responses: {
|
||||
200: jsonResponse(downloaderListSchema, 'Downloaders'),
|
||||
401: jsonResponse(errorSchema, 'Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const createRouteDoc = createRoute({
|
||||
method: 'post',
|
||||
path: '/',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { body: { content: { 'application/json': { schema: createDownloaderSchema } }, required: true } },
|
||||
responses: {
|
||||
201: jsonResponse(createDownloaderResponseSchema, 'Downloader registration'),
|
||||
401: jsonResponse(errorSchema, 'Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const updateRoute = createRoute({
|
||||
method: 'patch',
|
||||
path: '/{id}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: { content: { 'application/json': { schema: updateDownloaderSchema } }, required: true },
|
||||
},
|
||||
responses: {
|
||||
200: jsonResponse(downloaderSchema, 'Updated downloader'),
|
||||
404: jsonResponse(errorSchema, 'Not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const deleteRoute = createRoute({
|
||||
method: 'delete',
|
||||
path: '/{id}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
200: jsonResponse(deleteDownloaderResponseSchema, 'Deleted downloader'),
|
||||
404: jsonResponse(errorSchema, 'Not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const heartbeatRoute = createRoute({
|
||||
method: 'post',
|
||||
path: '/heartbeat',
|
||||
middleware: [requireDownloader] as const,
|
||||
request: { body: { content: { 'application/json': { schema: downloaderHeartbeatSchema } }, required: true } },
|
||||
responses: {
|
||||
200: jsonResponse(downloaderSchema, 'Updated downloader'),
|
||||
401: jsonResponse(errorSchema, 'Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const downloadersRoute = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, (async (c: OpenAPIContext) => {
|
||||
const items = await listDownloaders(c.get('platform'))
|
||||
return c.json({ items, total: items.length })
|
||||
}) as never)
|
||||
.openapi(createRouteDoc, (async (c: OpenAPIContext) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const result = await createDownloader(
|
||||
c.get('platform'),
|
||||
c.req.valid('json') as z.infer<typeof createDownloaderSchema>,
|
||||
userId,
|
||||
)
|
||||
return c.json(result, 201)
|
||||
}) as never)
|
||||
.openapi(updateRoute, (async (c: OpenAPIContext) => {
|
||||
const id = c.req.param('id') as string
|
||||
return downloadResponse(c, async () =>
|
||||
updateDownloader(c.get('platform'), id, c.req.valid('json') as z.infer<typeof updateDownloaderSchema>),
|
||||
)
|
||||
}) as never)
|
||||
.openapi(deleteRoute, (async (c: OpenAPIContext) => {
|
||||
const id = c.req.param('id') as string
|
||||
return downloadResponse(c, async () => deleteDownloader(c.get('platform'), id))
|
||||
}) as never)
|
||||
|
||||
export const downloaderSelfRoute = new OpenAPIHono<Env>().openapi(heartbeatRoute, (async (c: OpenAPIContext) => {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401)
|
||||
return downloadResponse(c, async () =>
|
||||
recordDownloaderHeartbeat(
|
||||
c.get('platform'),
|
||||
principal.downloaderId,
|
||||
c.req.valid('json') as z.infer<typeof downloaderHeartbeatSchema>,
|
||||
),
|
||||
)
|
||||
}) as never)
|
||||
|
||||
export default downloadersRoute
|
||||
|
||||
async function downloadResponse(c: Context<Env>, action: () => Promise<unknown>) {
|
||||
try {
|
||||
return c.json(await action())
|
||||
} catch (error) {
|
||||
if (error instanceof DownloadError && error.code === 'not_found') return c.json({ error: 'Not found' }, 404)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ describe('Objects API', () => {
|
||||
body: JSON.stringify({ name: 'test.txt', type: 'text/plain' }),
|
||||
})
|
||||
expect(res.status).toBe(500)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Storage not configured' })
|
||||
})
|
||||
|
||||
it('GET /api/objects lists active objects in root', async () => {
|
||||
|
||||
+189
-8
@@ -1,17 +1,23 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import type { Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import {
|
||||
batchDeleteSchema,
|
||||
batchPatchSchema,
|
||||
copyMatterSchema,
|
||||
createMatterSchema,
|
||||
createObjectUploadSessionSchema,
|
||||
patchMatterSchema,
|
||||
patchObjectUploadSessionSchema,
|
||||
presignObjectUploadPartsSchema,
|
||||
} from '../../shared/schemas'
|
||||
import type { Storage as S3Storage } from '../../shared/types'
|
||||
import { requireAuth, requireTeamRole } from '../middleware/auth'
|
||||
import { requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { recordActivity } from '../services/activity'
|
||||
import { assertTaskUploadAllowed } from '../services/downloads'
|
||||
import { consumeTrafficIfQuotaAllows, refundTraffic } from '../services/effective-quota'
|
||||
import {
|
||||
batchMove,
|
||||
@@ -30,6 +36,13 @@ import {
|
||||
updateMatter,
|
||||
} from '../services/matter'
|
||||
import { NameConflictError } from '../services/matter-name-conflict'
|
||||
import {
|
||||
createObjectUploadSession,
|
||||
ObjectUploadSessionError,
|
||||
patchObjectUploadSession,
|
||||
presignObjectUploadParts,
|
||||
} from '../services/object-upload-sessions'
|
||||
import { getMemberRole, isPersonalOrg } from '../services/org'
|
||||
import { buildObjectKey } from '../services/path-template'
|
||||
import { purgeRecursively } from '../services/purge'
|
||||
import { S3Service } from '../services/s3'
|
||||
@@ -52,8 +65,41 @@ function conflictBody(err: NameConflictError) {
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_LEVELS: Record<string, number> = { owner: 3, editor: 2, viewer: 1, member: 1 }
|
||||
|
||||
const requireObjectCreateAccess = createMiddleware<Env>(async (c, next) => {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind === 'download-task-upload') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
if (!(await hasEditorAccess(c))) {
|
||||
return c.json({ error: c.get('userId') ? 'Forbidden' : 'Unauthorized' }, c.get('userId') ? 403 : 401)
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
const requireObjectPatchAccess = createMiddleware<Env>(async (c, next) => {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind === 'download-task-upload') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
if (!(await hasEditorAccess(c))) {
|
||||
return c.json({ error: c.get('userId') ? 'Forbidden' : 'Unauthorized' }, c.get('userId') ? 403 : 401)
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use(requireAuth)
|
||||
.use(async (c, next) => {
|
||||
const principal = c.get('principal')
|
||||
if (c.get('userId') || principal?.kind === 'download-task-upload') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
})
|
||||
.get('/', requireTeamRole('viewer'), async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
@@ -69,16 +115,36 @@ const app = new Hono<Env>()
|
||||
const result = await listMatters(db, orgId, { parent, status, typeFilter, search, page, pageSize })
|
||||
return c.json(result)
|
||||
})
|
||||
.post('/', requireTeamRole('editor'), zValidator('json', createMatterSchema), async (c) => {
|
||||
.post('/', requireObjectCreateAccess, zValidator('json', createMatterSchema), async (c) => {
|
||||
const principal = c.get('principal')
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')!
|
||||
const userId = principal?.kind === 'download-task-upload' ? principal.createdByUserId : (c.get('userId') as string)
|
||||
const actorId =
|
||||
principal?.kind === 'download-task-upload' ? `downloader:${principal.downloaderId}` : (c.get('userId') as string)
|
||||
const { name, type, size, parent, dirtype, onConflict } = c.req.valid('json')
|
||||
const isFolder = dirtype !== DirType.FILE
|
||||
if (principal?.kind === 'download-task-upload') {
|
||||
if (isFolder) return c.json({ error: 'Download task upload cannot create folders' }, 403)
|
||||
if (parent !== principal.targetFolder)
|
||||
return c.json({ error: 'Target folder is outside task authorization' }, 403)
|
||||
await assertTaskUploadAllowed(c.get('platform'), {
|
||||
taskId: principal.taskId,
|
||||
downloaderId: principal.downloaderId,
|
||||
})
|
||||
}
|
||||
|
||||
const storage = (await selectStorage(db, 'private')) as unknown as S3Storage
|
||||
let storage: S3Storage
|
||||
try {
|
||||
storage = (await selectStorage(db, 'private')) as unknown as S3Storage
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'No available storage') {
|
||||
return c.json({ error: 'Storage not configured' }, 500)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const objectKey = isFolder
|
||||
? ''
|
||||
: buildObjectKey({
|
||||
@@ -90,7 +156,6 @@ const app = new Hono<Env>()
|
||||
try {
|
||||
const matter = await createMatter(db, {
|
||||
orgId,
|
||||
userId,
|
||||
name,
|
||||
type: isFolder ? 'folder' : type,
|
||||
size: isFolder ? 0 : size,
|
||||
@@ -100,6 +165,7 @@ const app = new Hono<Env>()
|
||||
storageId: storage.id,
|
||||
status: isFolder ? 'active' : 'draft',
|
||||
onConflict,
|
||||
userId: actorId,
|
||||
})
|
||||
if (isFolder) return c.json(matter, 201)
|
||||
const contentDisposition = `attachment; filename="${name.replace(/"/g, '\\"')}"; filename*=UTF-8''${encodeURIComponent(name)}`
|
||||
@@ -182,6 +248,81 @@ const app = new Hono<Env>()
|
||||
return c.json({ error: (e as Error).message }, 400)
|
||||
}
|
||||
})
|
||||
.post('/:id/uploads', requireObjectCreateAccess, zValidator('json', createObjectUploadSessionSchema), async (c) =>
|
||||
objectUploadResponse(
|
||||
c,
|
||||
async () => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) throw new ObjectUploadSessionError('not_found')
|
||||
const matter = await getMatter(c.get('platform').db, c.req.param('id'), orgId)
|
||||
if (!matter || matter.status !== 'draft' || matter.dirtype !== DirType.FILE || !matter.object) {
|
||||
throw new ObjectUploadSessionError('not_found')
|
||||
}
|
||||
const storage = (await getStorage(c.get('platform').db, matter.storageId)) as unknown as S3Storage | null
|
||||
if (!storage) throw new ObjectUploadSessionError('not_found')
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind === 'download-task-upload') {
|
||||
if (matter.parent !== principal.targetFolder) throw new ObjectUploadSessionError('invalid_state')
|
||||
await assertTaskUploadAllowed(c.get('platform'), {
|
||||
taskId: principal.taskId,
|
||||
downloaderId: principal.downloaderId,
|
||||
})
|
||||
}
|
||||
return createObjectUploadSession(c.get('platform').db, s3, {
|
||||
orgId,
|
||||
objectId: matter.id,
|
||||
storage,
|
||||
storageKey: matter.object,
|
||||
contentType: matter.type,
|
||||
partSize: c.req.valid('json').partSize,
|
||||
actorId: actorId(c),
|
||||
})
|
||||
},
|
||||
201,
|
||||
),
|
||||
)
|
||||
.post(
|
||||
'/:id/uploads/:uploadSessionId/parts',
|
||||
requireObjectCreateAccess,
|
||||
zValidator('json', presignObjectUploadPartsSchema),
|
||||
async (c) =>
|
||||
objectUploadResponse(c, async () => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) throw new ObjectUploadSessionError('not_found')
|
||||
const matter = await getMatter(c.get('platform').db, c.req.param('id'), orgId)
|
||||
if (!matter) throw new ObjectUploadSessionError('not_found')
|
||||
const storage = (await getStorage(c.get('platform').db, matter.storageId)) as unknown as S3Storage | null
|
||||
if (!storage) throw new ObjectUploadSessionError('not_found')
|
||||
return presignObjectUploadParts(c.get('platform').db, s3, {
|
||||
orgId,
|
||||
objectId: matter.id,
|
||||
sessionId: c.req.param('uploadSessionId'),
|
||||
storage,
|
||||
partNumbers: c.req.valid('json').partNumbers,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
'/:id/uploads/:uploadSessionId',
|
||||
requireObjectCreateAccess,
|
||||
zValidator('json', patchObjectUploadSessionSchema),
|
||||
async (c) =>
|
||||
objectUploadResponse(c, async () => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) throw new ObjectUploadSessionError('not_found')
|
||||
const matter = await getMatter(c.get('platform').db, c.req.param('id'), orgId)
|
||||
if (!matter) throw new ObjectUploadSessionError('not_found')
|
||||
const storage = (await getStorage(c.get('platform').db, matter.storageId)) as unknown as S3Storage | null
|
||||
if (!storage) throw new ObjectUploadSessionError('not_found')
|
||||
return patchObjectUploadSession(c.get('platform').db, s3, {
|
||||
orgId,
|
||||
objectId: matter.id,
|
||||
sessionId: c.req.param('uploadSessionId'),
|
||||
storage,
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.get('/:id', requireTeamRole('viewer'), async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
@@ -219,13 +360,24 @@ const app = new Hono<Env>()
|
||||
|
||||
return c.json({ ...matter, downloadUrl })
|
||||
})
|
||||
.patch('/:id', requireTeamRole('editor'), zValidator('json', patchMatterSchema), async (c) => {
|
||||
.patch('/:id', requireObjectPatchAccess, zValidator('json', patchMatterSchema), async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')!
|
||||
const userId = actorId(c)
|
||||
const body = c.req.valid('json')
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind === 'download-task-upload') {
|
||||
if (body.action !== 'confirm')
|
||||
return c.json({ error: 'Download task upload token can only confirm uploads' }, 403)
|
||||
const matter = await getMatter(db, c.req.param('id'), orgId)
|
||||
if (!matter || matter.parent !== principal.targetFolder) return c.json({ error: 'Forbidden' }, 403)
|
||||
await assertTaskUploadAllowed(c.get('platform'), {
|
||||
taskId: principal.taskId,
|
||||
downloaderId: principal.downloaderId,
|
||||
})
|
||||
}
|
||||
|
||||
switch (body.action) {
|
||||
case 'update': {
|
||||
@@ -344,3 +496,32 @@ const app = new Hono<Env>()
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
async function hasEditorAccess(c: Context<Env>): Promise<boolean> {
|
||||
const orgId = c.get('orgId')
|
||||
const userId = c.get('userId')
|
||||
if (!orgId || !userId) return false
|
||||
const role = await getMemberRole(c.get('platform').db, orgId, userId)
|
||||
if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.editor
|
||||
return isPersonalOrg(c.get('platform').db, orgId)
|
||||
}
|
||||
|
||||
function actorId(c: Context<Env>): string {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind === 'download-task-upload') return `downloader:${principal.downloaderId}`
|
||||
return c.get('userId') ?? 'system'
|
||||
}
|
||||
|
||||
async function objectUploadResponse(c: Context<Env>, action: () => Promise<unknown>, status: 200 | 201 = 200) {
|
||||
try {
|
||||
return c.json(await action(), status)
|
||||
} catch (error) {
|
||||
if (error instanceof ObjectUploadSessionError) {
|
||||
return c.json(
|
||||
{ error: error.code === 'not_found' ? 'Not found' : 'Invalid upload session state' },
|
||||
error.code === 'not_found' ? 404 : 409,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { downloaders, downloadTasks } from '../db/schema'
|
||||
import type { Database, Platform } from '../platform/interface'
|
||||
|
||||
const TOKEN_VERSION = 1
|
||||
|
||||
const downloaderTokenSchema = z.object({
|
||||
v: z.literal(TOKEN_VERSION),
|
||||
typ: z.literal('downloader'),
|
||||
downloaderId: z.string().min(1),
|
||||
jti: z.string().min(1),
|
||||
iat: z.number().int(),
|
||||
})
|
||||
|
||||
const taskUploadTokenSchema = z.object({
|
||||
v: z.literal(TOKEN_VERSION),
|
||||
typ: z.literal('download-task-upload'),
|
||||
taskId: z.string().min(1),
|
||||
downloaderId: z.string().min(1),
|
||||
orgId: z.string().min(1),
|
||||
targetFolder: z.string(),
|
||||
createdByUserId: z.string().min(1),
|
||||
scopes: z.array(z.string().min(1)),
|
||||
jti: z.string().min(1),
|
||||
iat: z.number().int(),
|
||||
exp: z.number().int(),
|
||||
})
|
||||
|
||||
export type DownloaderTokenClaims = z.infer<typeof downloaderTokenSchema>
|
||||
export type TaskUploadTokenClaims = z.infer<typeof taskUploadTokenSchema>
|
||||
export type DownloadTokenClaims = DownloaderTokenClaims | TaskUploadTokenClaims
|
||||
|
||||
export async function signDownloadToken(platform: Platform, claims: DownloadTokenClaims): Promise<string> {
|
||||
const payload = base64UrlEncode(JSON.stringify(claims))
|
||||
const signature = await signPayload(platform, payload)
|
||||
return `${payload}.${signature}`
|
||||
}
|
||||
|
||||
export async function verifyDownloadToken(platform: Platform, token: string): Promise<DownloadTokenClaims | null> {
|
||||
const [payload, signature, extra] = token.split('.')
|
||||
if (!payload || !signature || extra !== undefined) return null
|
||||
const expected = await signPayload(platform, payload)
|
||||
if (!constantTimeEqual(signature, expected)) return null
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(base64UrlDecode(payload))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const downloader = downloaderTokenSchema.safeParse(raw)
|
||||
if (downloader.success) return downloader.data
|
||||
const task = taskUploadTokenSchema.safeParse(raw)
|
||||
if (!task.success) return null
|
||||
if (task.data.exp <= Math.floor(Date.now() / 1000)) return null
|
||||
return task.data
|
||||
}
|
||||
|
||||
export async function hashDownloadToken(platform: Platform, token: string): Promise<string> {
|
||||
const key = await hmacKey(secret(platform))
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`hash:${token}`))
|
||||
return base64UrlEncodeBytes(new Uint8Array(signature))
|
||||
}
|
||||
|
||||
export async function resolveDownloaderToken(
|
||||
platform: Platform,
|
||||
token: string,
|
||||
): Promise<{ downloaderId: string } | null> {
|
||||
const claims = await verifyDownloadToken(platform, token)
|
||||
if (!claims || claims.typ !== 'downloader') return null
|
||||
const hash = await hashDownloadToken(platform, token)
|
||||
const rows = await platform.db
|
||||
.select({
|
||||
id: downloaders.id,
|
||||
enabled: downloaders.enabled,
|
||||
tokenHash: downloaders.tokenHash,
|
||||
tokenJti: downloaders.tokenJti,
|
||||
})
|
||||
.from(downloaders)
|
||||
.where(eq(downloaders.id, claims.downloaderId))
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
if (!row?.enabled || row.tokenHash !== hash || row.tokenJti !== claims.jti) return null
|
||||
return { downloaderId: row.id }
|
||||
}
|
||||
|
||||
export async function resolveTaskUploadToken(
|
||||
db: Database,
|
||||
platform: Platform,
|
||||
token: string,
|
||||
): Promise<TaskUploadTokenClaims | null> {
|
||||
const claims = await verifyDownloadToken(platform, token)
|
||||
if (!claims || claims.typ !== 'download-task-upload') return null
|
||||
const hash = await hashDownloadToken(platform, token)
|
||||
const rows = await db
|
||||
.select({
|
||||
id: downloadTasks.id,
|
||||
assignedDownloaderId: downloadTasks.assignedDownloaderId,
|
||||
status: downloadTasks.status,
|
||||
orgId: downloadTasks.orgId,
|
||||
targetFolder: downloadTasks.targetFolder,
|
||||
uploadTokenHash: downloadTasks.uploadTokenHash,
|
||||
uploadTokenJti: downloadTasks.uploadTokenJti,
|
||||
uploadTokenExpiresAt: downloadTasks.uploadTokenExpiresAt,
|
||||
})
|
||||
.from(downloadTasks)
|
||||
.where(eq(downloadTasks.id, claims.taskId))
|
||||
.limit(1)
|
||||
const task = rows[0]
|
||||
if (!task) return null
|
||||
if (task.assignedDownloaderId !== claims.downloaderId) return null
|
||||
if (task.orgId !== claims.orgId || task.targetFolder !== claims.targetFolder) return null
|
||||
if (task.uploadTokenHash !== hash || task.uploadTokenJti !== claims.jti) return null
|
||||
if (task.uploadTokenExpiresAt && task.uploadTokenExpiresAt.getTime() <= Date.now()) return null
|
||||
if (!['assigned', 'running', 'uploading'].includes(task.status)) return null
|
||||
return claims
|
||||
}
|
||||
|
||||
async function signPayload(platform: Platform, payload: string): Promise<string> {
|
||||
const key = await hmacKey(secret(platform))
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
|
||||
return base64UrlEncodeBytes(new Uint8Array(signature))
|
||||
}
|
||||
|
||||
async function hmacKey(value: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', new TextEncoder().encode(value), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
||||
'sign',
|
||||
])
|
||||
}
|
||||
|
||||
function secret(platform: Platform): string {
|
||||
const value = platform.getEnv('BETTER_AUTH_SECRET') ?? platform.getEnv('DOWNLOAD_TOKEN_SECRET')
|
||||
if (!value) throw new Error('download_token_secret_missing')
|
||||
return value
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
return base64UrlEncodeBytes(new TextEncoder().encode(value))
|
||||
}
|
||||
|
||||
function base64UrlEncodeBytes(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): string {
|
||||
const padded = value
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
.padEnd(Math.ceil(value.length / 4) * 4, '=')
|
||||
return atob(padded)
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
let result = 0
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||
}
|
||||
return result === 0
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import type {
|
||||
CreateDownloaderInput,
|
||||
CreateDownloadTaskInput,
|
||||
DownloaderHeartbeatInput,
|
||||
UpdateDownloaderInput,
|
||||
UpdateDownloadTaskInput,
|
||||
} from '@shared/schemas'
|
||||
import type { Downloader, DownloadTask } from '@shared/types'
|
||||
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { downloaders, downloadTasks } from '../db/schema'
|
||||
import type { Platform } from '../platform/interface'
|
||||
import { hashDownloadToken, signDownloadToken } from './download-tokens'
|
||||
import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage'
|
||||
|
||||
const DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES = 100 * 1024 * 1024
|
||||
const UPLOAD_TOKEN_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
export class DownloadError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'not_found'
|
||||
| 'forbidden'
|
||||
| 'no_downloader'
|
||||
| 'invalid_state'
|
||||
| 'billing_paused'
|
||||
| 'unsupported_source',
|
||||
message = code,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'DownloadError'
|
||||
}
|
||||
}
|
||||
|
||||
type DownloaderRow = typeof downloaders.$inferSelect
|
||||
type DownloadTaskRow = typeof downloadTasks.$inferSelect
|
||||
|
||||
export async function createDownloader(
|
||||
platform: Platform,
|
||||
input: CreateDownloaderInput,
|
||||
userId: string,
|
||||
): Promise<{ downloader: Downloader; token: string }> {
|
||||
const now = new Date()
|
||||
const id = nanoid()
|
||||
const jti = nanoid()
|
||||
const token = await signDownloadToken(platform, {
|
||||
v: 1,
|
||||
typ: 'downloader',
|
||||
downloaderId: id,
|
||||
jti,
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
})
|
||||
await platform.db.insert(downloaders).values({
|
||||
id,
|
||||
name: input.name,
|
||||
tokenHash: await hashDownloadToken(platform, token),
|
||||
tokenJti: jti,
|
||||
status: 'offline',
|
||||
enabled: true,
|
||||
version: input.heartbeat.version,
|
||||
hostname: input.heartbeat.hostname,
|
||||
platform: input.heartbeat.platform,
|
||||
arch: input.heartbeat.arch,
|
||||
engine: input.heartbeat.engine,
|
||||
capabilities: JSON.stringify(input.heartbeat.capabilities),
|
||||
maxConcurrentTasks: input.heartbeat.maxConcurrentTasks,
|
||||
currentTasks: input.heartbeat.currentTasks,
|
||||
downloadBps: input.heartbeat.downloadBps,
|
||||
uploadBps: input.heartbeat.uploadBps,
|
||||
freeDiskBytes: input.heartbeat.freeDiskBytes,
|
||||
remoteDownloadCreditBillingEnabled: false,
|
||||
remoteDownloadCreditUnitBytes: DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES,
|
||||
remoteDownloadCreditPerUnit: 1,
|
||||
lastHeartbeatAt: null,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { downloader: await getDownloader(platform, id), token }
|
||||
}
|
||||
|
||||
export async function listDownloaders(platform: Platform): Promise<Downloader[]> {
|
||||
const rows = await platform.db.select().from(downloaders).orderBy(desc(downloaders.createdAt))
|
||||
return rows.map(toDownloader)
|
||||
}
|
||||
|
||||
export async function getDownloader(platform: Platform, id: string): Promise<Downloader> {
|
||||
const rows = await platform.db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
return toDownloader(rows[0])
|
||||
}
|
||||
|
||||
export async function updateDownloader(
|
||||
platform: Platform,
|
||||
id: string,
|
||||
input: UpdateDownloaderInput,
|
||||
): Promise<Downloader> {
|
||||
const rows = await platform.db.select({ id: downloaders.id }).from(downloaders).where(eq(downloaders.id, id)).limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
const now = new Date()
|
||||
await platform.db
|
||||
.update(downloaders)
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.enabled !== undefined
|
||||
? { enabled: input.enabled, status: input.enabled ? 'offline' : 'disabled' }
|
||||
: {}),
|
||||
...(input.remoteDownloadCreditBillingEnabled !== undefined
|
||||
? { remoteDownloadCreditBillingEnabled: input.remoteDownloadCreditBillingEnabled }
|
||||
: {}),
|
||||
...(input.remoteDownloadCreditUnitBytes !== undefined
|
||||
? { remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes }
|
||||
: {}),
|
||||
...(input.remoteDownloadCreditPerUnit !== undefined
|
||||
? { remoteDownloadCreditPerUnit: input.remoteDownloadCreditPerUnit }
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(downloaders.id, id))
|
||||
return getDownloader(platform, id)
|
||||
}
|
||||
|
||||
export async function deleteDownloader(platform: Platform, id: string): Promise<{ id: string; deleted: true }> {
|
||||
const rows = await platform.db.select({ id: downloaders.id }).from(downloaders).where(eq(downloaders.id, id)).limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
const now = new Date()
|
||||
await platform.db
|
||||
.update(downloadTasks)
|
||||
.set({
|
||||
status: 'queued',
|
||||
assignedDownloaderId: null,
|
||||
uploadTokenHash: null,
|
||||
uploadTokenJti: null,
|
||||
uploadTokenExpiresAt: null,
|
||||
assignedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(downloadTasks.assignedDownloaderId, id),
|
||||
inArray(downloadTasks.status, ['queued', 'assigned', 'running', 'billing_paused', 'uploading']),
|
||||
),
|
||||
)
|
||||
await platform.db.delete(downloaders).where(eq(downloaders.id, id))
|
||||
return { id, deleted: true }
|
||||
}
|
||||
|
||||
export async function recordDownloaderHeartbeat(
|
||||
platform: Platform,
|
||||
downloaderId: string,
|
||||
heartbeat: DownloaderHeartbeatInput,
|
||||
): Promise<Downloader> {
|
||||
const rows = await platform.db
|
||||
.select({ id: downloaders.id, enabled: downloaders.enabled })
|
||||
.from(downloaders)
|
||||
.where(eq(downloaders.id, downloaderId))
|
||||
.limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
const now = new Date()
|
||||
await platform.db
|
||||
.update(downloaders)
|
||||
.set({
|
||||
status: rows[0].enabled ? 'online' : 'disabled',
|
||||
version: heartbeat.version,
|
||||
hostname: heartbeat.hostname,
|
||||
platform: heartbeat.platform,
|
||||
arch: heartbeat.arch,
|
||||
engine: heartbeat.engine,
|
||||
capabilities: JSON.stringify(heartbeat.capabilities),
|
||||
maxConcurrentTasks: heartbeat.maxConcurrentTasks,
|
||||
currentTasks: heartbeat.currentTasks,
|
||||
downloadBps: heartbeat.downloadBps,
|
||||
uploadBps: heartbeat.uploadBps,
|
||||
freeDiskBytes: heartbeat.freeDiskBytes,
|
||||
lastHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(downloaders.id, downloaderId))
|
||||
await assignQueuedTasks(platform)
|
||||
return getDownloader(platform, downloaderId)
|
||||
}
|
||||
|
||||
export async function createDownloadTask(
|
||||
platform: Platform,
|
||||
orgId: string,
|
||||
userId: string,
|
||||
input: CreateDownloadTaskInput,
|
||||
): Promise<DownloadTask> {
|
||||
const now = new Date()
|
||||
const id = nanoid()
|
||||
const assigned = await selectDownloader(platform, input.source.type)
|
||||
const uploadToken = assigned
|
||||
? await createTaskUploadToken(platform, {
|
||||
taskId: id,
|
||||
downloaderId: assigned.id,
|
||||
orgId,
|
||||
targetFolder: input.targetFolder,
|
||||
createdByUserId: userId,
|
||||
})
|
||||
: null
|
||||
await platform.db.insert(downloadTasks).values({
|
||||
id,
|
||||
orgId,
|
||||
createdByUserId: userId,
|
||||
sourceType: input.source.type,
|
||||
sourceUri: input.source.uri,
|
||||
name: input.name ?? null,
|
||||
targetFolder: input.targetFolder,
|
||||
assignedDownloaderId: assigned?.id ?? null,
|
||||
status: assigned ? 'assigned' : 'queued',
|
||||
uploadTokenHash: uploadToken?.hash ?? null,
|
||||
uploadTokenJti: uploadToken?.jti ?? null,
|
||||
uploadTokenExpiresAt: uploadToken?.expiresAt ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
assignedAt: assigned ? now : null,
|
||||
})
|
||||
return getDownloadTask(platform, orgId, id)
|
||||
}
|
||||
|
||||
export async function listDownloadTasks(
|
||||
platform: Platform,
|
||||
opts: {
|
||||
orgId?: string
|
||||
downloaderId?: string
|
||||
status?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
includeUploadToken?: boolean
|
||||
},
|
||||
): Promise<{ items: DownloadTask[]; total: number }> {
|
||||
const offset = (opts.page - 1) * opts.pageSize
|
||||
const filters = []
|
||||
if (opts.orgId) filters.push(eq(downloadTasks.orgId, opts.orgId))
|
||||
if (opts.downloaderId) filters.push(eq(downloadTasks.assignedDownloaderId, opts.downloaderId))
|
||||
if (opts.status) filters.push(eq(downloadTasks.status, opts.status))
|
||||
const where = filters.length ? and(...filters) : undefined
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
platform.db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(where)
|
||||
.orderBy(desc(downloadTasks.createdAt))
|
||||
.limit(opts.pageSize)
|
||||
.offset(offset),
|
||||
platform.db.select({ count: count() }).from(downloadTasks).where(where),
|
||||
])
|
||||
return {
|
||||
items: await Promise.all(
|
||||
rows.map((row) =>
|
||||
toDownloadTaskWithToken(
|
||||
platform,
|
||||
row,
|
||||
(opts.includeUploadToken ?? false) && (row.status === 'assigned' || row.status === 'billing_paused'),
|
||||
),
|
||||
),
|
||||
),
|
||||
total: totalRows[0]?.count ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDownloadTask(platform: Platform, orgId: string, id: string): Promise<DownloadTask> {
|
||||
const rows = await platform.db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId)))
|
||||
.limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
return toDownloadTask(rows[0])
|
||||
}
|
||||
|
||||
export async function updateDownloadTask(
|
||||
platform: Platform,
|
||||
id: string,
|
||||
input: UpdateDownloadTaskInput,
|
||||
actor: { orgId?: string; downloaderId?: string },
|
||||
): Promise<DownloadTask> {
|
||||
const rows = await platform.db.select().from(downloadTasks).where(eq(downloadTasks.id, id)).limit(1)
|
||||
const task = rows[0]
|
||||
if (!task) throw new DownloadError('not_found')
|
||||
if (actor.orgId && task.orgId !== actor.orgId) throw new DownloadError('not_found')
|
||||
if (actor.downloaderId && task.assignedDownloaderId !== actor.downloaderId) throw new DownloadError('forbidden')
|
||||
if (actor.orgId && !actor.downloaderId) {
|
||||
const onlyCancel =
|
||||
input.status === 'canceled' &&
|
||||
input.downloadedBytes === undefined &&
|
||||
input.totalBytes === undefined &&
|
||||
input.downloadBps === undefined &&
|
||||
input.uploadBps === undefined &&
|
||||
input.errorMessage === undefined &&
|
||||
input.resultObjectId === undefined
|
||||
if (!onlyCancel) throw new DownloadError('forbidden')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let status = input.status ?? task.status
|
||||
let authorizedBytes = task.authorizedBytes
|
||||
let billedBytes = task.billedBytes
|
||||
let billedCredits = task.billedCredits
|
||||
let billingStatus = task.billingStatus
|
||||
const downloadedBytes =
|
||||
actor.downloaderId && input.downloadedBytes !== undefined
|
||||
? Math.max(input.downloadedBytes, task.downloadedBytes)
|
||||
: (input.downloadedBytes ?? task.downloadedBytes)
|
||||
|
||||
if (actor.downloaderId && downloadedBytes > task.downloadedBytes) {
|
||||
const downloader = await loadDownloaderRow(platform, actor.downloaderId)
|
||||
const targetUnits = Math.ceil(downloadedBytes / downloader.remoteDownloadCreditUnitBytes)
|
||||
const currentUnits = Math.ceil(task.billedBytes / downloader.remoteDownloadCreditUnitBytes)
|
||||
try {
|
||||
for (let unit = currentUnits + 1; unit <= targetUnits; unit += 1) {
|
||||
await reportRemoteDownloadUnit({
|
||||
platform,
|
||||
orgId: task.orgId,
|
||||
downloaderId: actor.downloaderId,
|
||||
taskId: task.id,
|
||||
unitIndex: unit,
|
||||
unitBytes: downloader.remoteDownloadCreditUnitBytes,
|
||||
creditsPerUnit: downloader.remoteDownloadCreditPerUnit,
|
||||
enabled: downloader.remoteDownloadCreditBillingEnabled,
|
||||
})
|
||||
billedCredits += downloader.remoteDownloadCreditBillingEnabled ? downloader.remoteDownloadCreditPerUnit : 0
|
||||
}
|
||||
if (targetUnits > currentUnits) {
|
||||
billedBytes = targetUnits * downloader.remoteDownloadCreditUnitBytes
|
||||
authorizedBytes = billedBytes
|
||||
billingStatus = 'ok'
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteDownloadBillingBlockedError) {
|
||||
status = 'billing_paused'
|
||||
billingStatus = 'insufficient_credits'
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await platform.db
|
||||
.update(downloadTasks)
|
||||
.set({
|
||||
status,
|
||||
downloadedBytes,
|
||||
totalBytes: input.totalBytes === undefined ? task.totalBytes : input.totalBytes,
|
||||
authorizedBytes,
|
||||
billedBytes,
|
||||
billedCredits,
|
||||
billingStatus,
|
||||
downloadBps: input.downloadBps ?? task.downloadBps,
|
||||
uploadBps: input.uploadBps ?? task.uploadBps,
|
||||
errorMessage: input.errorMessage === undefined ? task.errorMessage : input.errorMessage,
|
||||
resultObjectId: input.resultObjectId === undefined ? task.resultObjectId : input.resultObjectId,
|
||||
startedAt: task.startedAt ?? (status === 'running' ? now : null),
|
||||
finishedAt: ['completed', 'failed', 'canceled'].includes(status) ? now : task.finishedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(downloadTasks.id, id))
|
||||
|
||||
return getDownloadTask(platform, task.orgId, id)
|
||||
}
|
||||
|
||||
export async function assertTaskUploadAllowed(platform: Platform, params: { taskId: string; downloaderId: string }) {
|
||||
const rows = await platform.db.select().from(downloadTasks).where(eq(downloadTasks.id, params.taskId)).limit(1)
|
||||
const task = rows[0]
|
||||
if (!task || task.assignedDownloaderId !== params.downloaderId) throw new DownloadError('forbidden')
|
||||
if (!['assigned', 'running', 'uploading'].includes(task.status)) throw new DownloadError('invalid_state')
|
||||
return task
|
||||
}
|
||||
|
||||
async function assignQueuedTasks(platform: Platform): Promise<void> {
|
||||
const tasks = await platform.db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(eq(downloadTasks.status, 'queued'))
|
||||
.orderBy(asc(downloadTasks.createdAt))
|
||||
.limit(20)
|
||||
for (const task of tasks) {
|
||||
const downloader = await selectDownloader(platform, task.sourceType)
|
||||
if (!downloader) continue
|
||||
const token = await createTaskUploadToken(platform, {
|
||||
taskId: task.id,
|
||||
downloaderId: downloader.id,
|
||||
orgId: task.orgId,
|
||||
targetFolder: task.targetFolder,
|
||||
createdByUserId: task.createdByUserId,
|
||||
})
|
||||
const now = new Date()
|
||||
await platform.db
|
||||
.update(downloadTasks)
|
||||
.set({
|
||||
status: 'assigned',
|
||||
assignedDownloaderId: downloader.id,
|
||||
uploadTokenHash: token.hash,
|
||||
uploadTokenJti: token.jti,
|
||||
uploadTokenExpiresAt: token.expiresAt,
|
||||
assignedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(downloadTasks.id, task.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDownloader(platform: Platform, sourceType: string): Promise<DownloaderRow | null> {
|
||||
const needed = sourceType === 'http' ? ['http'] : ['magnet', 'torrent']
|
||||
const rows = await platform.db
|
||||
.select()
|
||||
.from(downloaders)
|
||||
.where(and(eq(downloaders.enabled, true), eq(downloaders.status, 'online')))
|
||||
.orderBy(asc(downloaders.currentTasks), asc(downloaders.downloadBps))
|
||||
return (
|
||||
rows.find((row) => {
|
||||
const capabilities = parseCapabilities(row.capabilities)
|
||||
return needed.some((capability) => capabilities.includes(capability))
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
async function createTaskUploadToken(
|
||||
platform: Platform,
|
||||
params: { taskId: string; downloaderId: string; orgId: string; targetFolder: string; createdByUserId: string },
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const jti = nanoid()
|
||||
const expiresAt = new Date((now + UPLOAD_TOKEN_TTL_SECONDS) * 1000)
|
||||
const token = await signDownloadToken(platform, {
|
||||
v: 1,
|
||||
typ: 'download-task-upload',
|
||||
taskId: params.taskId,
|
||||
downloaderId: params.downloaderId,
|
||||
orgId: params.orgId,
|
||||
targetFolder: params.targetFolder,
|
||||
createdByUserId: params.createdByUserId,
|
||||
scopes: ['objects:create', 'objects:upload', 'objects:confirm'],
|
||||
jti,
|
||||
iat: now,
|
||||
exp: now + UPLOAD_TOKEN_TTL_SECONDS,
|
||||
})
|
||||
return { token, hash: await hashDownloadToken(platform, token), jti, expiresAt }
|
||||
}
|
||||
|
||||
async function toDownloadTaskWithToken(platform: Platform, row: DownloadTaskRow, includeUploadToken: boolean) {
|
||||
const task = toDownloadTask(row)
|
||||
if (includeUploadToken && row.assignedDownloaderId && row.uploadTokenJti) {
|
||||
const token = await createTaskUploadToken(platform, {
|
||||
taskId: row.id,
|
||||
downloaderId: row.assignedDownloaderId,
|
||||
orgId: row.orgId,
|
||||
targetFolder: row.targetFolder,
|
||||
createdByUserId: row.createdByUserId,
|
||||
})
|
||||
await platform.db
|
||||
.update(downloadTasks)
|
||||
.set({ uploadTokenHash: token.hash, uploadTokenJti: token.jti, uploadTokenExpiresAt: token.expiresAt })
|
||||
.where(eq(downloadTasks.id, row.id))
|
||||
task.uploadToken = token.token
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
async function loadDownloaderRow(platform: Platform, id: string): Promise<DownloaderRow> {
|
||||
const rows = await platform.db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
function toDownloader(row: DownloaderRow): Downloader {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
status: row.enabled ? (row.status as Downloader['status']) : 'disabled',
|
||||
enabled: row.enabled,
|
||||
version: row.version,
|
||||
hostname: row.hostname,
|
||||
platform: row.platform,
|
||||
arch: row.arch,
|
||||
engine: row.engine as Downloader['engine'],
|
||||
capabilities: parseCapabilities(row.capabilities),
|
||||
maxConcurrentTasks: row.maxConcurrentTasks,
|
||||
currentTasks: row.currentTasks,
|
||||
downloadBps: row.downloadBps,
|
||||
uploadBps: row.uploadBps,
|
||||
freeDiskBytes: row.freeDiskBytes,
|
||||
remoteDownloadCreditBillingEnabled: row.remoteDownloadCreditBillingEnabled,
|
||||
remoteDownloadCreditUnitBytes: row.remoteDownloadCreditUnitBytes,
|
||||
remoteDownloadCreditPerUnit: row.remoteDownloadCreditPerUnit,
|
||||
lastHeartbeatAt: row.lastHeartbeatAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function toDownloadTask(row: DownloadTaskRow): DownloadTask {
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
createdByUserId: row.createdByUserId,
|
||||
sourceType: row.sourceType as DownloadTask['sourceType'],
|
||||
sourceUri: row.sourceUri,
|
||||
name: row.name,
|
||||
targetFolder: row.targetFolder,
|
||||
assignedDownloaderId: row.assignedDownloaderId,
|
||||
status: row.status as DownloadTask['status'],
|
||||
downloadedBytes: row.downloadedBytes,
|
||||
totalBytes: row.totalBytes,
|
||||
authorizedBytes: row.authorizedBytes,
|
||||
billedBytes: row.billedBytes,
|
||||
billedCredits: row.billedCredits,
|
||||
billingStatus: row.billingStatus,
|
||||
downloadBps: row.downloadBps,
|
||||
uploadBps: row.uploadBps,
|
||||
errorMessage: row.errorMessage,
|
||||
resultObjectId: row.resultObjectId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
startedAt: row.startedAt?.toISOString() ?? null,
|
||||
finishedAt: row.finishedAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseCapabilities(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { PatchObjectUploadSessionInput } from '@shared/schemas'
|
||||
import type { ObjectUploadSession, Storage as S3Storage } from '@shared/types'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { objectUploadSessions } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import type { S3Service } from './s3'
|
||||
|
||||
const DEFAULT_PART_SIZE = 16 * 1024 * 1024
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export class ObjectUploadSessionError extends Error {
|
||||
constructor(
|
||||
readonly code: 'not_found' | 'invalid_state',
|
||||
message = code,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ObjectUploadSessionError'
|
||||
}
|
||||
}
|
||||
|
||||
type SessionRow = typeof objectUploadSessions.$inferSelect
|
||||
|
||||
export async function createObjectUploadSession(
|
||||
db: Database,
|
||||
s3: S3Service,
|
||||
params: {
|
||||
orgId: string
|
||||
objectId: string
|
||||
storage: S3Storage
|
||||
storageKey: string
|
||||
contentType: string
|
||||
partSize?: number
|
||||
actorId: string
|
||||
},
|
||||
): Promise<ObjectUploadSession> {
|
||||
const now = new Date()
|
||||
const uploadId = await s3.createMultipartUpload(params.storage, params.storageKey, params.contentType)
|
||||
const row: typeof objectUploadSessions.$inferInsert = {
|
||||
id: nanoid(),
|
||||
orgId: params.orgId,
|
||||
objectId: params.objectId,
|
||||
storageId: params.storage.id,
|
||||
storageKey: params.storageKey,
|
||||
uploadId,
|
||||
partSize: params.partSize ?? DEFAULT_PART_SIZE,
|
||||
status: 'active',
|
||||
createdBy: params.actorId,
|
||||
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.insert(objectUploadSessions).values(row)
|
||||
return toDto(row as SessionRow)
|
||||
}
|
||||
|
||||
export async function getObjectUploadSession(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
objectId: string,
|
||||
id: string,
|
||||
): Promise<ObjectUploadSession> {
|
||||
const row = await getRow(db, orgId, objectId, id)
|
||||
if (!row) throw new ObjectUploadSessionError('not_found')
|
||||
return toDto(row)
|
||||
}
|
||||
|
||||
export async function presignObjectUploadParts(
|
||||
db: Database,
|
||||
s3: S3Service,
|
||||
params: {
|
||||
orgId: string
|
||||
objectId: string
|
||||
sessionId: string
|
||||
storage: S3Storage
|
||||
partNumbers: number[]
|
||||
},
|
||||
): Promise<{ uploadId: string; partSize: number; parts: Array<{ partNumber: number; url: string }> }> {
|
||||
const row = await getRow(db, params.orgId, params.objectId, params.sessionId)
|
||||
if (!row) throw new ObjectUploadSessionError('not_found')
|
||||
if (row.status !== 'active' || row.expiresAt.getTime() <= Date.now()) {
|
||||
throw new ObjectUploadSessionError('invalid_state')
|
||||
}
|
||||
const parts = await Promise.all(
|
||||
params.partNumbers.map(async (partNumber) => ({
|
||||
partNumber,
|
||||
url: await s3.presignUploadPart(params.storage, row.storageKey, row.uploadId, partNumber),
|
||||
})),
|
||||
)
|
||||
return { uploadId: row.uploadId, partSize: row.partSize, parts }
|
||||
}
|
||||
|
||||
export async function patchObjectUploadSession(
|
||||
db: Database,
|
||||
s3: S3Service,
|
||||
params: {
|
||||
orgId: string
|
||||
objectId: string
|
||||
sessionId: string
|
||||
storage: S3Storage
|
||||
input: PatchObjectUploadSessionInput
|
||||
},
|
||||
): Promise<ObjectUploadSession> {
|
||||
const row = await getRow(db, params.orgId, params.objectId, params.sessionId)
|
||||
if (!row) throw new ObjectUploadSessionError('not_found')
|
||||
if (row.status !== 'active') throw new ObjectUploadSessionError('invalid_state')
|
||||
const now = new Date()
|
||||
if (params.input.action === 'complete') {
|
||||
await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts)
|
||||
await db
|
||||
.update(objectUploadSessions)
|
||||
.set({ status: 'completed', updatedAt: now })
|
||||
.where(eq(objectUploadSessions.id, row.id))
|
||||
} else {
|
||||
await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId)
|
||||
await db
|
||||
.update(objectUploadSessions)
|
||||
.set({ status: 'aborted', updatedAt: now })
|
||||
.where(eq(objectUploadSessions.id, row.id))
|
||||
}
|
||||
return getObjectUploadSession(db, params.orgId, params.objectId, params.sessionId)
|
||||
}
|
||||
|
||||
async function getRow(db: Database, orgId: string, objectId: string, id: string): Promise<SessionRow | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(objectUploadSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(objectUploadSessions.id, id),
|
||||
eq(objectUploadSessions.orgId, orgId),
|
||||
eq(objectUploadSessions.objectId, objectId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
function toDto(row: SessionRow): ObjectUploadSession {
|
||||
return {
|
||||
id: row.id,
|
||||
objectId: row.objectId,
|
||||
uploadId: row.uploadId,
|
||||
partSize: row.partSize,
|
||||
status: row.status as ObjectUploadSession['status'],
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { remoteDownloadUsageReports } from '../db/schema'
|
||||
import { loadActiveLicenseBinding } from '../licensing/license-state'
|
||||
import type { Platform } from '../platform/interface'
|
||||
import { postBoundCloudJson } from './licensing-cloud'
|
||||
|
||||
export class RemoteDownloadBillingBlockedError extends Error {
|
||||
constructor() {
|
||||
super('insufficient_credits')
|
||||
this.name = 'RemoteDownloadBillingBlockedError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportRemoteDownloadUnit(params: {
|
||||
platform: Platform
|
||||
orgId: string
|
||||
downloaderId: string
|
||||
taskId: string
|
||||
unitIndex: number
|
||||
unitBytes: number
|
||||
creditsPerUnit: number
|
||||
enabled: boolean
|
||||
}): Promise<void> {
|
||||
if (!params.enabled) return
|
||||
const eventId = `remote_download:${params.taskId}:${params.unitIndex}`
|
||||
const existing = await params.platform.db
|
||||
.select()
|
||||
.from(remoteDownloadUsageReports)
|
||||
.where(eq(remoteDownloadUsageReports.eventId, eventId))
|
||||
.limit(1)
|
||||
if (existing[0]?.status === 'reported') return
|
||||
if (existing[0]?.status === 'blocked') throw new RemoteDownloadBillingBlockedError()
|
||||
|
||||
const now = new Date()
|
||||
if (!existing[0]) {
|
||||
await params.platform.db.insert(remoteDownloadUsageReports).values({
|
||||
id: nanoid(),
|
||||
orgId: params.orgId,
|
||||
downloaderId: params.downloaderId,
|
||||
taskId: params.taskId,
|
||||
eventId,
|
||||
unitIndex: params.unitIndex,
|
||||
unitBytes: params.unitBytes,
|
||||
creditsPerUnit: params.creditsPerUnit,
|
||||
status: 'pending',
|
||||
error: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
const binding = await loadActiveLicenseBinding(params.platform.db)
|
||||
if (!binding?.refreshToken || !binding.cloudStoreId) {
|
||||
await mark(params.platform, eventId, 'reported', null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await postBoundCloudJson(
|
||||
params.platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT,
|
||||
`/api/stores/${encodeURIComponent(binding.cloudStoreId)}/billing/usage-events`,
|
||||
binding.refreshToken,
|
||||
{
|
||||
resource: 'remote_download',
|
||||
unit: 'byte',
|
||||
bytes: params.unitBytes,
|
||||
eventId,
|
||||
idempotencyKey: eventId,
|
||||
customerId: params.orgId,
|
||||
source: 'remote_download',
|
||||
sourceId: params.taskId,
|
||||
usageContext: { downloaderId: params.downloaderId },
|
||||
pricing: { unitQuantity: params.unitBytes, creditsPerUnit: params.creditsPerUnit },
|
||||
},
|
||||
)) as { accepted?: boolean; eventId?: string; error?: { code?: string } }
|
||||
if (!response.accepted || response.eventId !== eventId) throw new Error('cloud_usage_report_rejected')
|
||||
await mark(params.platform, eventId, 'reported', null)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'cloud_usage_report_failed'
|
||||
if (message === 'insufficient_credits' || message === 'overage_cap_exceeded') {
|
||||
await mark(params.platform, eventId, 'blocked', message)
|
||||
throw new RemoteDownloadBillingBlockedError()
|
||||
}
|
||||
await mark(params.platform, eventId, 'failed', message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function mark(platform: Platform, eventId: string, status: string, error: string | null) {
|
||||
await platform.db
|
||||
.update(remoteDownloadUsageReports)
|
||||
.set({ status, error, updatedAt: new Date() })
|
||||
.where(eq(remoteDownloadUsageReports.eventId, eventId))
|
||||
}
|
||||
@@ -61,6 +61,56 @@ export class S3Service {
|
||||
return url
|
||||
}
|
||||
|
||||
async createMultipartUpload(storage: Storage, key: string, contentType: string): Promise<string> {
|
||||
const client = this.createClient(storage)
|
||||
const created = await client.send(
|
||||
new CreateMultipartUploadCommand({ Bucket: storage.bucket, Key: key, ContentType: contentType }),
|
||||
)
|
||||
if (!created.UploadId) throw new Error('S3 multipart upload did not return an upload id')
|
||||
return created.UploadId
|
||||
}
|
||||
|
||||
async presignUploadPart(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
uploadId: string,
|
||||
partNumber: number,
|
||||
expiresIn = DEFAULT_EXPIRES_IN,
|
||||
): Promise<string> {
|
||||
const client = this.createClient(storage)
|
||||
return getSignedUrl(
|
||||
client,
|
||||
new UploadPartCommand({ Bucket: storage.bucket, Key: key, UploadId: uploadId, PartNumber: partNumber }),
|
||||
{ expiresIn },
|
||||
)
|
||||
}
|
||||
|
||||
async completeMultipartUpload(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
uploadId: string,
|
||||
parts: Array<{ etag: string; partNumber: number }>,
|
||||
): Promise<void> {
|
||||
const client = this.createClient(storage)
|
||||
await client.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: storage.bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: parts
|
||||
.map((part) => ({ ETag: part.etag, PartNumber: part.partNumber }))
|
||||
.sort((a, b) => a.PartNumber - b.PartNumber),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async abortMultipartUpload(storage: Storage, key: string, uploadId: string): Promise<void> {
|
||||
const client = this.createClient(storage)
|
||||
await client.send(new AbortMultipartUploadCommand({ Bucket: storage.bucket, Key: key, UploadId: uploadId }))
|
||||
}
|
||||
|
||||
async presignDownload(
|
||||
storage: Storage,
|
||||
key: string,
|
||||
|
||||
@@ -92,6 +92,23 @@ const AUTH_SCHEMA_SQL = `
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS invitation_organizationId_idx ON invitation(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS invitation_email_idx ON invitation(email);
|
||||
CREATE TABLE IF NOT EXISTS deviceCode (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_code TEXT NOT NULL,
|
||||
user_code TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
client_id TEXT,
|
||||
scope TEXT,
|
||||
status TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_polled_at INTEGER,
|
||||
polling_interval INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS deviceCode_device_code_idx ON deviceCode(device_code);
|
||||
CREATE INDEX IF NOT EXISTS deviceCode_user_code_idx ON deviceCode(user_code);
|
||||
CREATE INDEX IF NOT EXISTS deviceCode_status_idx ON deviceCode(status);
|
||||
`
|
||||
|
||||
const APP_SCHEMA_SQL = `
|
||||
@@ -370,6 +387,104 @@ const APP_SCHEMA_SQL = `
|
||||
CREATE INDEX IF NOT EXISTS background_jobs_org_created_idx ON background_jobs(org_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS background_jobs_org_status_idx ON background_jobs(org_id, status);
|
||||
CREATE INDEX IF NOT EXISTS background_jobs_org_type_idx ON background_jobs(org_id, type);
|
||||
CREATE TABLE IF NOT EXISTS downloaders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_jti TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
version TEXT NOT NULL DEFAULT 'unknown',
|
||||
hostname TEXT NOT NULL DEFAULT 'unknown',
|
||||
platform TEXT NOT NULL DEFAULT 'unknown',
|
||||
arch TEXT NOT NULL DEFAULT 'unknown',
|
||||
engine TEXT NOT NULL DEFAULT 'builtin',
|
||||
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
|
||||
current_tasks INTEGER NOT NULL DEFAULT 0,
|
||||
download_bps INTEGER NOT NULL DEFAULT 0,
|
||||
upload_bps INTEGER NOT NULL DEFAULT 0,
|
||||
free_disk_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
remote_download_credit_billing_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
remote_download_credit_unit_bytes INTEGER NOT NULL DEFAULT 104857600,
|
||||
remote_download_credit_per_unit INTEGER NOT NULL DEFAULT 1,
|
||||
last_heartbeat_at INTEGER,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS downloaders_token_jti_unique ON downloaders(token_jti);
|
||||
CREATE INDEX IF NOT EXISTS downloaders_status_idx ON downloaders(status);
|
||||
CREATE INDEX IF NOT EXISTS downloaders_enabled_idx ON downloaders(enabled);
|
||||
CREATE INDEX IF NOT EXISTS downloaders_created_idx ON downloaders(created_at);
|
||||
CREATE TABLE IF NOT EXISTS download_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
created_by_user_id TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_uri TEXT NOT NULL,
|
||||
name TEXT,
|
||||
target_folder TEXT NOT NULL DEFAULT '',
|
||||
assigned_downloader_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
downloaded_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
authorized_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
billed_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
billed_credits INTEGER NOT NULL DEFAULT 0,
|
||||
billing_status TEXT NOT NULL DEFAULT 'none',
|
||||
download_bps INTEGER NOT NULL DEFAULT 0,
|
||||
upload_bps INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
result_object_id TEXT,
|
||||
upload_token_hash TEXT,
|
||||
upload_token_jti TEXT,
|
||||
upload_token_expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
assigned_at INTEGER,
|
||||
started_at INTEGER,
|
||||
finished_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS download_tasks_org_created_idx ON download_tasks(org_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS download_tasks_org_status_idx ON download_tasks(org_id, status);
|
||||
CREATE INDEX IF NOT EXISTS download_tasks_downloader_idx ON download_tasks(assigned_downloader_id, status);
|
||||
CREATE TABLE IF NOT EXISTS object_upload_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
object_id TEXT NOT NULL,
|
||||
storage_id TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
upload_id TEXT NOT NULL,
|
||||
part_size INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS object_upload_sessions_object_idx ON object_upload_sessions(org_id, object_id);
|
||||
CREATE INDEX IF NOT EXISTS object_upload_sessions_expires_idx ON object_upload_sessions(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS remote_download_usage_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
downloader_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
unit_index INTEGER NOT NULL,
|
||||
unit_bytes INTEGER NOT NULL,
|
||||
credits_per_unit INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS remote_download_usage_reports_event_id_unique
|
||||
ON remote_download_usage_reports(event_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS remote_download_usage_task_unit_uniq
|
||||
ON remote_download_usage_reports(task_id, unit_index);
|
||||
CREATE INDEX IF NOT EXISTS remote_download_usage_org_idx ON remote_download_usage_reports(org_id);
|
||||
CREATE INDEX IF NOT EXISTS remote_download_usage_status_idx ON remote_download_usage_reports(status);
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const downloaderStatusSchema = z.enum(['online', 'offline', 'disabled'])
|
||||
export const downloaderEngineSchema = z.enum(['builtin', 'aria2', 'qbittorrent'])
|
||||
export const downloadTaskStatusSchema = z.enum([
|
||||
'queued',
|
||||
'assigned',
|
||||
'running',
|
||||
'billing_paused',
|
||||
'uploading',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
])
|
||||
export const downloadSourceTypeSchema = z.enum(['http', 'magnet', 'torrent_url'])
|
||||
|
||||
export const downloaderHeartbeatSchema = z.object({
|
||||
version: z.string().min(1).max(80),
|
||||
hostname: z.string().min(1).max(160),
|
||||
platform: z.string().min(1).max(80),
|
||||
arch: z.string().min(1).max(40),
|
||||
engine: downloaderEngineSchema,
|
||||
capabilities: z.array(z.string().min(1).max(80)).max(32),
|
||||
maxConcurrentTasks: z.number().int().min(1).max(100),
|
||||
currentTasks: z.number().int().min(0).max(100),
|
||||
downloadBps: z.number().int().min(0).default(0),
|
||||
uploadBps: z.number().int().min(0).default(0),
|
||||
freeDiskBytes: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
export const updateDownloaderSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
remoteDownloadCreditBillingEnabled: z.boolean().optional(),
|
||||
remoteDownloadCreditUnitBytes: z.number().int().positive().optional(),
|
||||
remoteDownloadCreditPerUnit: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
export const createDownloaderSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
heartbeat: downloaderHeartbeatSchema,
|
||||
})
|
||||
|
||||
const downloadUriSchema = z.string().min(1).max(4096)
|
||||
|
||||
export const createDownloadTaskSchema = z.object({
|
||||
source: z.object({
|
||||
type: downloadSourceTypeSchema,
|
||||
uri: downloadUriSchema,
|
||||
}),
|
||||
targetFolder: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
})
|
||||
|
||||
export const updateDownloadTaskSchema = z.object({
|
||||
status: downloadTaskStatusSchema.optional(),
|
||||
downloadedBytes: z.number().int().min(0).optional(),
|
||||
totalBytes: z.number().int().min(0).nullable().optional(),
|
||||
downloadBps: z.number().int().min(0).optional(),
|
||||
uploadBps: z.number().int().min(0).optional(),
|
||||
errorMessage: z.string().max(1000).nullable().optional(),
|
||||
resultObjectId: z.string().min(1).nullable().optional(),
|
||||
})
|
||||
|
||||
export const listDownloadTasksQuerySchema = z.object({
|
||||
status: downloadTaskStatusSchema.optional(),
|
||||
assignedTo: z.enum(['me']).optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const objectUploadActionSchema = z.enum(['complete', 'abort'])
|
||||
|
||||
export const createObjectUploadSessionSchema = z.object({
|
||||
partSize: z
|
||||
.number()
|
||||
.int()
|
||||
.min(5 * 1024 * 1024)
|
||||
.max(512 * 1024 * 1024)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const presignObjectUploadPartsSchema = z.object({
|
||||
partNumbers: z.array(z.number().int().min(1).max(10_000)).min(1).max(100),
|
||||
})
|
||||
|
||||
export const patchObjectUploadSessionSchema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('complete'),
|
||||
parts: z
|
||||
.array(
|
||||
z.object({
|
||||
partNumber: z.number().int().min(1).max(10_000),
|
||||
etag: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('abort'),
|
||||
}),
|
||||
])
|
||||
|
||||
export type DownloaderHeartbeatInput = z.infer<typeof downloaderHeartbeatSchema>
|
||||
export type UpdateDownloaderInput = z.infer<typeof updateDownloaderSchema>
|
||||
export type CreateDownloaderInput = z.infer<typeof createDownloaderSchema>
|
||||
export type CreateDownloadTaskInput = z.infer<typeof createDownloadTaskSchema>
|
||||
export type UpdateDownloadTaskInput = z.infer<typeof updateDownloadTaskSchema>
|
||||
export type ListDownloadTasksQuery = z.infer<typeof listDownloadTasksQuerySchema>
|
||||
export type CreateObjectUploadSessionInput = z.infer<typeof createObjectUploadSessionSchema>
|
||||
export type PresignObjectUploadPartsInput = z.infer<typeof presignObjectUploadPartsSchema>
|
||||
export type PatchObjectUploadSessionInput = z.infer<typeof patchObjectUploadSessionSchema>
|
||||
@@ -71,6 +71,32 @@ export {
|
||||
redeemGiftCardInputSchema,
|
||||
redeemGiftCardResponseSchema,
|
||||
} from './cloud-store'
|
||||
export type {
|
||||
CreateDownloaderInput,
|
||||
CreateDownloadTaskInput,
|
||||
CreateObjectUploadSessionInput,
|
||||
DownloaderHeartbeatInput,
|
||||
ListDownloadTasksQuery,
|
||||
PatchObjectUploadSessionInput,
|
||||
PresignObjectUploadPartsInput,
|
||||
UpdateDownloaderInput,
|
||||
UpdateDownloadTaskInput,
|
||||
} from './downloads'
|
||||
export {
|
||||
createDownloaderSchema,
|
||||
createDownloadTaskSchema,
|
||||
createObjectUploadSessionSchema,
|
||||
downloaderEngineSchema,
|
||||
downloaderHeartbeatSchema,
|
||||
downloaderStatusSchema,
|
||||
downloadSourceTypeSchema,
|
||||
downloadTaskStatusSchema,
|
||||
listDownloadTasksQuerySchema,
|
||||
patchObjectUploadSessionSchema,
|
||||
presignObjectUploadPartsSchema,
|
||||
updateDownloaderSchema,
|
||||
updateDownloadTaskSchema,
|
||||
} from './downloads'
|
||||
export type { ListNotificationsQuery } from './notification'
|
||||
export { listNotificationsQuerySchema } from './notification'
|
||||
export type { CreateShareInput, CreateShareRequest, ShareKind } from './share'
|
||||
|
||||
@@ -202,6 +202,84 @@ export interface PaginatedResponse<T> {
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type DownloaderStatus = 'online' | 'offline' | 'disabled'
|
||||
export type DownloaderEngine = 'builtin' | 'aria2' | 'qbittorrent'
|
||||
|
||||
export interface Downloader {
|
||||
id: string
|
||||
name: string
|
||||
status: DownloaderStatus
|
||||
enabled: boolean
|
||||
version: string
|
||||
hostname: string
|
||||
platform: string
|
||||
arch: string
|
||||
engine: DownloaderEngine
|
||||
capabilities: string[]
|
||||
maxConcurrentTasks: number
|
||||
currentTasks: number
|
||||
downloadBps: number
|
||||
uploadBps: number
|
||||
freeDiskBytes: number
|
||||
remoteDownloadCreditBillingEnabled: boolean
|
||||
remoteDownloadCreditUnitBytes: number
|
||||
remoteDownloadCreditPerUnit: number
|
||||
lastHeartbeatAt: string | null
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type DownloadSourceType = 'http' | 'magnet' | 'torrent_url'
|
||||
export type DownloadTaskStatus =
|
||||
| 'queued'
|
||||
| 'assigned'
|
||||
| 'running'
|
||||
| 'billing_paused'
|
||||
| 'uploading'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled'
|
||||
|
||||
export interface DownloadTask {
|
||||
id: string
|
||||
orgId: string
|
||||
createdByUserId: string
|
||||
sourceType: DownloadSourceType
|
||||
sourceUri: string
|
||||
name: string | null
|
||||
targetFolder: string
|
||||
assignedDownloaderId: string | null
|
||||
status: DownloadTaskStatus
|
||||
downloadedBytes: number
|
||||
totalBytes: number | null
|
||||
authorizedBytes: number
|
||||
billedBytes: number
|
||||
billedCredits: number
|
||||
billingStatus: string
|
||||
downloadBps: number
|
||||
uploadBps: number
|
||||
errorMessage: string | null
|
||||
resultObjectId: string | null
|
||||
uploadToken?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
assignedAt: string | null
|
||||
startedAt: string | null
|
||||
finishedAt: string | null
|
||||
}
|
||||
|
||||
export interface ObjectUploadSession {
|
||||
id: string
|
||||
objectId: string
|
||||
uploadId: string
|
||||
partSize: number
|
||||
status: 'active' | 'completed' | 'aborted'
|
||||
expiresAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type BackgroundJobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'canceled'
|
||||
export type KnownBackgroundJobType = 'archive_compress' | 'archive_extract'
|
||||
export type BackgroundJobType = KnownBackgroundJobType | (string & {})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Database,
|
||||
HardDriveDownload,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
@@ -31,6 +32,7 @@ const adminNavItems = [
|
||||
{ titleKey: 'admin.nav.overview', url: '/admin', icon: LayoutDashboard },
|
||||
{ titleKey: 'admin.nav.users', url: '/admin/users', icon: Users },
|
||||
{ titleKey: 'admin.nav.storages', url: '/admin/storages', icon: Database },
|
||||
{ titleKey: 'admin.nav.downloaders', url: '/admin/downloaders', icon: HardDriveDownload },
|
||||
{ titleKey: 'admin.nav.auth', url: '/admin/settings/oauth', icon: KeyRound },
|
||||
{ titleKey: 'admin.nav.email', url: '/admin/settings/email', icon: Mail },
|
||||
{ titleKey: 'admin.nav.settings', url: '/admin/settings', icon: Settings },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Download,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Image,
|
||||
@@ -89,6 +90,7 @@ export function AppSidebar() {
|
||||
const activeFileType = (type: string) => isFiles && fileType === type
|
||||
const activeRecycleBin = pathname === '/trash'
|
||||
const activeShares = pathname.startsWith('/shares')
|
||||
const activeDownloads = pathname.startsWith('/downloads')
|
||||
const activeTasks = pathname.startsWith('/tasks')
|
||||
const activeImageHost = pathname === '/image-host'
|
||||
|
||||
@@ -166,6 +168,14 @@ export function AppSidebar() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={activeDownloads}>
|
||||
<Link to="/downloads">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{t('nav.downloads')}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={activeTasks}>
|
||||
<Link to="/tasks">
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"nav.music": "Music",
|
||||
"nav.documents": "Documents",
|
||||
"nav.trash": "Trash",
|
||||
"nav.downloads": "Remote Download",
|
||||
"nav.tasks": "Tasks",
|
||||
"nav.imageHost": "Image Host",
|
||||
"nav.storage": "Storage",
|
||||
@@ -113,6 +114,38 @@
|
||||
"tasks.colFinished": "Finished",
|
||||
"tasks.colResult": "Result",
|
||||
"tasks.result.outputBytes": "{{bytes}} bytes output",
|
||||
"downloads.title": "Remote Download",
|
||||
"downloads.sourceType": "Source",
|
||||
"downloads.sourceTypes.http": "HTTP URL",
|
||||
"downloads.sourceTypes.magnet": "Magnet",
|
||||
"downloads.sourceTypes.torrentUrl": "Torrent URL",
|
||||
"downloads.uri": "Download link",
|
||||
"downloads.uriPlaceholder": "https://example.com/file.zip or magnet:...",
|
||||
"downloads.targetFolder": "Target folder",
|
||||
"downloads.targetFolderRoot": "Root",
|
||||
"downloads.name": "File name",
|
||||
"downloads.namePlaceholder": "Optional",
|
||||
"downloads.create": "Create",
|
||||
"downloads.createTitle": "New remote download",
|
||||
"downloads.start": "Start download",
|
||||
"downloads.createSuccess": "Download task created",
|
||||
"downloads.cancelSuccess": "Download task canceled",
|
||||
"downloads.cancel": "Cancel",
|
||||
"downloads.empty": "No remote download tasks",
|
||||
"downloads.unknown": "Unknown",
|
||||
"downloads.billingPaused": "Paused until credits recover",
|
||||
"downloads.table.source": "Source",
|
||||
"downloads.table.status": "Status",
|
||||
"downloads.table.progress": "Progress",
|
||||
"downloads.table.speed": "Speed",
|
||||
"downloads.status.queued": "Queued",
|
||||
"downloads.status.assigned": "Assigned",
|
||||
"downloads.status.running": "Running",
|
||||
"downloads.status.billing_paused": "Billing paused",
|
||||
"downloads.status.uploading": "Uploading",
|
||||
"downloads.status.completed": "Completed",
|
||||
"downloads.status.failed": "Failed",
|
||||
"downloads.status.canceled": "Canceled",
|
||||
"uploadPanel.toggle": "Upload progress",
|
||||
"uploadPanel.title": "Uploads",
|
||||
"uploadPanel.empty": "No uploads yet",
|
||||
@@ -164,6 +197,7 @@
|
||||
"admin.nav.management": "Management",
|
||||
"admin.nav.overview": "Overview",
|
||||
"admin.nav.storages": "Storages",
|
||||
"admin.nav.downloaders": "Downloaders",
|
||||
"admin.nav.announcement": "Announcement",
|
||||
"admin.nav.audit": "Audit Logs",
|
||||
"admin.nav.users": "Users",
|
||||
@@ -177,6 +211,23 @@
|
||||
"admin.overview.noStorages": "No storage backends configured yet.",
|
||||
"admin.overview.noStorageCapacity": "No capacity configured",
|
||||
"admin.overview.metrics.users": "Users",
|
||||
"admin.downloaders.title": "Downloaders",
|
||||
"admin.downloaders.subtitle": "Monitor remote download workers.",
|
||||
"admin.downloaders.empty": "No downloaders registered",
|
||||
"admin.downloaders.table.name": "Name",
|
||||
"admin.downloaders.table.status": "Status",
|
||||
"admin.downloaders.table.engine": "Engine",
|
||||
"admin.downloaders.table.tasks": "Tasks",
|
||||
"admin.downloaders.table.speed": "Down / Up",
|
||||
"admin.downloaders.table.billing": "Credits",
|
||||
"admin.downloaders.table.enabled": "Enabled",
|
||||
"admin.downloaders.status.online": "Online",
|
||||
"admin.downloaders.status.offline": "Offline",
|
||||
"admin.downloaders.status.disabled": "Disabled",
|
||||
"admin.downloaders.delete": "Delete",
|
||||
"admin.downloaders.deleteTitle": "Delete downloader",
|
||||
"admin.downloaders.deleteConfirm": "Delete downloader {{name}}? Active assigned tasks will be returned to the queue.",
|
||||
"admin.downloaders.deleteSuccess": "Downloader deleted.",
|
||||
"admin.overview.metrics.usersHint": "Total registered accounts",
|
||||
"admin.overview.metrics.storage": "Storage",
|
||||
"admin.overview.metrics.storageHint": "Active / configured backends",
|
||||
@@ -649,6 +700,7 @@
|
||||
"common.configured": "Configured",
|
||||
"common.notConfigured": "Not configured",
|
||||
"common.confirm": "Confirm",
|
||||
"common.copy": "Copy",
|
||||
"common.close": "Close",
|
||||
"common.loading": "Loading...",
|
||||
"common.error": "Error",
|
||||
@@ -1394,5 +1446,17 @@
|
||||
"storage.noPackages": "No subscription plans are available right now.",
|
||||
"storage.trafficQuota": "{{size}} download traffic",
|
||||
"storage.activeEntitlement": "Active entitlement",
|
||||
"storage.noHistory": "No storage orders yet."
|
||||
"storage.noHistory": "No storage orders yet.",
|
||||
"device.title": "Authorize downloader",
|
||||
"device.subtitle": "Confirm that this device can register a ZPan remote downloader.",
|
||||
"device.code": "Device code",
|
||||
"device.invalid": "This device code is invalid or expired.",
|
||||
"device.signInRequired": "Sign in before authorizing this downloader.",
|
||||
"device.approve": "Authorize",
|
||||
"device.deny": "Deny",
|
||||
"device.approved": "Downloader authorized.",
|
||||
"device.denied": "Downloader denied.",
|
||||
"device.failed": "Device authorization failed.",
|
||||
"device.approvedMessage": "Authorization complete. You can return to the downloader CLI.",
|
||||
"device.deniedMessage": "Authorization denied. You can close this page."
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"nav.music": "音乐",
|
||||
"nav.documents": "文档",
|
||||
"nav.trash": "回收站",
|
||||
"nav.downloads": "远程下载",
|
||||
"nav.tasks": "任务",
|
||||
"nav.imageHost": "图床",
|
||||
"nav.storage": "存储",
|
||||
@@ -113,6 +114,38 @@
|
||||
"tasks.colFinished": "完成时间",
|
||||
"tasks.colResult": "结果",
|
||||
"tasks.result.outputBytes": "输出 {{bytes}} 字节",
|
||||
"downloads.title": "远程下载",
|
||||
"downloads.sourceType": "来源",
|
||||
"downloads.sourceTypes.http": "HTTP 链接",
|
||||
"downloads.sourceTypes.magnet": "磁力链接",
|
||||
"downloads.sourceTypes.torrentUrl": "Torrent 链接",
|
||||
"downloads.uri": "下载链接",
|
||||
"downloads.uriPlaceholder": "https://example.com/file.zip 或 magnet:...",
|
||||
"downloads.targetFolder": "目标文件夹",
|
||||
"downloads.targetFolderRoot": "根目录",
|
||||
"downloads.name": "文件名",
|
||||
"downloads.namePlaceholder": "可选",
|
||||
"downloads.create": "创建",
|
||||
"downloads.createTitle": "新建远程下载",
|
||||
"downloads.start": "开始下载",
|
||||
"downloads.createSuccess": "下载任务已创建",
|
||||
"downloads.cancelSuccess": "下载任务已取消",
|
||||
"downloads.cancel": "取消",
|
||||
"downloads.empty": "暂无远程下载任务",
|
||||
"downloads.unknown": "未知",
|
||||
"downloads.billingPaused": "Credits 恢复后继续",
|
||||
"downloads.table.source": "来源",
|
||||
"downloads.table.status": "状态",
|
||||
"downloads.table.progress": "进度",
|
||||
"downloads.table.speed": "速度",
|
||||
"downloads.status.queued": "排队中",
|
||||
"downloads.status.assigned": "已分配",
|
||||
"downloads.status.running": "运行中",
|
||||
"downloads.status.billing_paused": "计费暂停",
|
||||
"downloads.status.uploading": "上传中",
|
||||
"downloads.status.completed": "已完成",
|
||||
"downloads.status.failed": "失败",
|
||||
"downloads.status.canceled": "已取消",
|
||||
"uploadPanel.toggle": "上传进度",
|
||||
"uploadPanel.title": "上传",
|
||||
"uploadPanel.empty": "暂无上传任务",
|
||||
@@ -164,6 +197,7 @@
|
||||
"admin.nav.management": "管理",
|
||||
"admin.nav.overview": "概览",
|
||||
"admin.nav.storages": "存储",
|
||||
"admin.nav.downloaders": "下载器",
|
||||
"admin.nav.announcement": "站点公告",
|
||||
"admin.nav.audit": "审计日志",
|
||||
"admin.nav.users": "用户",
|
||||
@@ -177,6 +211,23 @@
|
||||
"admin.overview.noStorages": "还没有配置存储后端。",
|
||||
"admin.overview.noStorageCapacity": "未配置容量",
|
||||
"admin.overview.metrics.users": "用户",
|
||||
"admin.downloaders.title": "下载器",
|
||||
"admin.downloaders.subtitle": "监控远程下载节点。",
|
||||
"admin.downloaders.empty": "暂无下载器",
|
||||
"admin.downloaders.table.name": "名称",
|
||||
"admin.downloaders.table.status": "状态",
|
||||
"admin.downloaders.table.engine": "引擎",
|
||||
"admin.downloaders.table.tasks": "任务",
|
||||
"admin.downloaders.table.speed": "下载 / 上传",
|
||||
"admin.downloaders.table.billing": "Credits",
|
||||
"admin.downloaders.table.enabled": "启用",
|
||||
"admin.downloaders.status.online": "在线",
|
||||
"admin.downloaders.status.offline": "离线",
|
||||
"admin.downloaders.status.disabled": "已禁用",
|
||||
"admin.downloaders.delete": "删除",
|
||||
"admin.downloaders.deleteTitle": "删除下载器",
|
||||
"admin.downloaders.deleteConfirm": "确定删除下载器 {{name}} 吗?未完成的已分配任务会回到队列。",
|
||||
"admin.downloaders.deleteSuccess": "下载器已删除。",
|
||||
"admin.overview.metrics.usersHint": "已注册账号总数",
|
||||
"admin.overview.metrics.storage": "存储",
|
||||
"admin.overview.metrics.storageHint": "启用 / 已配置后端",
|
||||
@@ -649,6 +700,7 @@
|
||||
"common.configured": "已配置",
|
||||
"common.notConfigured": "未配置",
|
||||
"common.confirm": "确认",
|
||||
"common.copy": "复制",
|
||||
"common.close": "关闭",
|
||||
"common.loading": "加载中...",
|
||||
"common.error": "错误",
|
||||
@@ -1394,5 +1446,17 @@
|
||||
"storage.noPackages": "当前暂无可用订阅套餐。",
|
||||
"storage.trafficQuota": "{{size}} 下载流量",
|
||||
"storage.activeEntitlement": "有效权益",
|
||||
"storage.noHistory": "暂无存储订单。"
|
||||
"storage.noHistory": "暂无存储订单。",
|
||||
"device.title": "授权下载器",
|
||||
"device.subtitle": "确认允许此设备注册为 ZPan 远程下载器。",
|
||||
"device.code": "设备码",
|
||||
"device.invalid": "设备码无效或已过期。",
|
||||
"device.signInRequired": "请先登录,再授权此下载器。",
|
||||
"device.approve": "授权",
|
||||
"device.deny": "拒绝",
|
||||
"device.approved": "下载器已授权。",
|
||||
"device.denied": "已拒绝下载器授权。",
|
||||
"device.failed": "设备授权失败。",
|
||||
"device.approvedMessage": "授权已完成,可以回到下载器 CLI。",
|
||||
"device.deniedMessage": "已拒绝授权,可以关闭此页面。"
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
createCloudCheckout,
|
||||
createCloudGiftCards,
|
||||
createCloudProduct,
|
||||
createDownloadTask,
|
||||
createIhostApiKey,
|
||||
createIhostImagePresign,
|
||||
createObject,
|
||||
createObjectUploadSession,
|
||||
createShare,
|
||||
createSiteInvitation,
|
||||
createStorage,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
deleteAvatar,
|
||||
deleteCloudGiftCard,
|
||||
deleteCloudProduct,
|
||||
deleteDownloader,
|
||||
deleteIhostConfig,
|
||||
deleteIhostImage,
|
||||
deleteObject,
|
||||
@@ -76,6 +79,8 @@ import {
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudStoreTargets,
|
||||
listDownloaders,
|
||||
listDownloadTasks,
|
||||
listIhostApiKeys,
|
||||
listIhostImages,
|
||||
listNotifications,
|
||||
@@ -91,7 +96,9 @@ import {
|
||||
listWebDavAppPasswords,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
patchObjectUploadSession,
|
||||
pollPairing,
|
||||
presignObjectUploadParts,
|
||||
redeemCloudGiftCard,
|
||||
refreshLicense,
|
||||
resendSiteInvitation,
|
||||
@@ -104,12 +111,15 @@ import {
|
||||
saveBranding,
|
||||
saveEmailConfig,
|
||||
saveShareToDrive,
|
||||
sendDownloaderHeartbeat,
|
||||
setSystemOption,
|
||||
testEmail,
|
||||
trashObject,
|
||||
updateAnnouncement,
|
||||
updateCloudProduct,
|
||||
updateCloudStoreSettings,
|
||||
updateDownloader,
|
||||
updateDownloadTask,
|
||||
updateIhostConfig,
|
||||
updateObject,
|
||||
updateStorage,
|
||||
@@ -931,6 +941,172 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('object upload sessions api', () => {
|
||||
it('creates an upload session for an object', async () => {
|
||||
const payload = { id: 'upload-1', object: { id: 'obj-1' } }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await createObjectUploadSession('obj-1', {
|
||||
partSize: 5 * 1024 * 1024,
|
||||
})
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/objects/obj-1/uploads')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(JSON.stringify({ partSize: 5 * 1024 * 1024 }))
|
||||
})
|
||||
|
||||
it('presigns upload session parts', async () => {
|
||||
const payload = { parts: [{ partNumber: 1, uploadUrl: 'https://s3/part-1' }] }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await presignObjectUploadParts('obj-1', 'upload-1', { partNumbers: [1] })
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/objects/obj-1/uploads/upload-1/parts')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(JSON.stringify({ partNumbers: [1] }))
|
||||
})
|
||||
|
||||
it('patches an upload session', async () => {
|
||||
const payload = { id: 'upload-1', status: 'completed', object: { id: 'obj-1' } }
|
||||
const body = { action: 'complete' as const, parts: [{ partNumber: 1, etag: 'etag-1' }] }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await patchObjectUploadSession('obj-1', 'upload-1', body)
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/objects/obj-1/uploads/upload-1')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(init.body).toBe(JSON.stringify(body))
|
||||
})
|
||||
|
||||
it('throws ApiError on upload session failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'upload denied' }, false, 403))
|
||||
|
||||
await expect(createObjectUploadSession('obj-1', { partSize: 5 * 1024 * 1024 })).rejects.toThrow('upload denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('remote download api', () => {
|
||||
it('lists download tasks with filters', async () => {
|
||||
const payload = { items: [], total: 0, page: 2, pageSize: 10 }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await listDownloadTasks({ status: 'running', assignedTo: 'me', page: 2, pageSize: 10 })
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/download-tasks?')
|
||||
expect(url).toContain('status=running')
|
||||
expect(url).toContain('assignedTo=me')
|
||||
expect(url).toContain('page=2')
|
||||
expect(url).toContain('pageSize=10')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('creates a download task', async () => {
|
||||
const payload = { id: 'task-1', status: 'queued' }
|
||||
const body = { source: { type: 'http' as const, uri: 'https://example.com/file.zip' }, targetFolder: 'root' }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await createDownloadTask(body)
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/download-tasks')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(JSON.stringify(body))
|
||||
})
|
||||
|
||||
it('updates a download task', async () => {
|
||||
const payload = { id: 'task-1', status: 'running' }
|
||||
const body = { status: 'running' as const, downloadedBytes: 1024 }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await updateDownloadTask('task-1', body)
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/download-tasks/task-1')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(init.body).toBe(JSON.stringify(body))
|
||||
})
|
||||
|
||||
it('lists admin downloaders', async () => {
|
||||
const payload = { items: [{ id: 'downloader-1', name: 'vps-1' }], total: 1 }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await listDownloaders()
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/admin/downloaders')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('updates an admin downloader', async () => {
|
||||
const payload = { id: 'downloader-1', enabled: false }
|
||||
const body = { enabled: false }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await updateDownloader('downloader-1', body)
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/admin/downloaders/downloader-1')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(init.body).toBe(JSON.stringify(body))
|
||||
})
|
||||
|
||||
it('deletes an admin downloader', async () => {
|
||||
const payload = { id: 'downloader-1', deleted: true }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await deleteDownloader('downloader-1')
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/admin/downloaders/downloader-1')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('sends downloader heartbeat', async () => {
|
||||
const payload = { id: 'downloader-1', status: 'online' }
|
||||
const body = {
|
||||
version: '0.1.0',
|
||||
hostname: 'vps-1',
|
||||
platform: 'linux',
|
||||
arch: 'amd64',
|
||||
engine: 'aria2' as const,
|
||||
capabilities: ['http'],
|
||||
maxConcurrentTasks: 2,
|
||||
currentTasks: 1,
|
||||
downloadBps: 2048,
|
||||
uploadBps: 512,
|
||||
freeDiskBytes: 1024,
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await sendDownloaderHeartbeat(body)
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/downloader/heartbeat')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(JSON.stringify(body))
|
||||
})
|
||||
|
||||
it('throws ApiError on download task failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'credits exhausted' }, false, 402))
|
||||
|
||||
await expect(updateDownloadTask('task-1', { downloadedBytes: 2048 })).rejects.toThrow('credits exhausted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('background jobs api', () => {
|
||||
const job = {
|
||||
id: 'job-1',
|
||||
|
||||
@@ -8,11 +8,18 @@ import type {
|
||||
CloudProductPatchInput,
|
||||
ConflictStrategy,
|
||||
CreateBackgroundJobRequest,
|
||||
CreateDownloadTaskInput,
|
||||
CreateGiftCardInput,
|
||||
CreateObjectUploadSessionInput,
|
||||
CreateShareRequest,
|
||||
CreateStorageInput,
|
||||
DownloaderHeartbeatInput,
|
||||
GiftCardStatus,
|
||||
PatchObjectUploadSessionInput,
|
||||
PresignObjectUploadPartsInput,
|
||||
RedeemGiftCardResponse,
|
||||
UpdateDownloaderInput,
|
||||
UpdateDownloadTaskInput,
|
||||
UpdateStorageInput,
|
||||
} from '@shared/schemas'
|
||||
import type {
|
||||
@@ -33,9 +40,12 @@ import type {
|
||||
CloudProduct,
|
||||
CloudStoreSettings,
|
||||
CloudStoreTarget,
|
||||
Downloader,
|
||||
DownloadTask,
|
||||
IhostConfigResponse,
|
||||
ImageHosting,
|
||||
Notification,
|
||||
ObjectUploadSession,
|
||||
OrgQuota,
|
||||
OrgQuotaEntitlement,
|
||||
PaginatedResponse,
|
||||
@@ -50,6 +60,7 @@ import {
|
||||
adminAuditApi,
|
||||
adminAuthProviders,
|
||||
adminCloudStoreApi,
|
||||
adminDownloadersApi,
|
||||
adminQuotas,
|
||||
adminSiteInvitations,
|
||||
announcementsApi,
|
||||
@@ -58,6 +69,8 @@ import {
|
||||
backgroundJobsApi,
|
||||
brandingAdminApi,
|
||||
cloudStoreApi,
|
||||
downloaderSelfApi,
|
||||
downloadTasksApi,
|
||||
emailConfig,
|
||||
ihostApi,
|
||||
ihostConfigApi,
|
||||
@@ -228,6 +241,73 @@ export function emptyTrash() {
|
||||
return unwrap<{ purged: number }>(trash.index.$delete())
|
||||
}
|
||||
|
||||
export function createObjectUploadSession(id: string, data: CreateObjectUploadSessionInput) {
|
||||
return unwrap<ObjectUploadSession & { object: StorageObject }>(
|
||||
objects[':id'].uploads.$post({ param: { id }, json: data }),
|
||||
)
|
||||
}
|
||||
|
||||
export function presignObjectUploadParts(id: string, uploadSessionId: string, data: PresignObjectUploadPartsInput) {
|
||||
return unwrap<{ parts: Array<{ partNumber: number; uploadUrl: string }> }>(
|
||||
objects[':id'].uploads[':uploadSessionId'].parts.$post({
|
||||
param: { id, uploadSessionId },
|
||||
json: data,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function patchObjectUploadSession(id: string, uploadSessionId: string, data: PatchObjectUploadSessionInput) {
|
||||
return unwrap<ObjectUploadSession & { object?: StorageObject }>(
|
||||
objects[':id'].uploads[':uploadSessionId'].$patch({
|
||||
param: { id, uploadSessionId },
|
||||
json: data,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Remote Download API
|
||||
|
||||
export interface ListDownloadTasksOptions {
|
||||
status?: string
|
||||
assignedTo?: 'me'
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function listDownloadTasks(opts: ListDownloadTasksOptions = {}) {
|
||||
const query: Record<string, string> = {
|
||||
page: String(opts.page ?? 1),
|
||||
pageSize: String(opts.pageSize ?? 50),
|
||||
}
|
||||
if (opts.status) query.status = opts.status
|
||||
if (opts.assignedTo) query.assignedTo = opts.assignedTo
|
||||
return unwrap<PaginatedResponse<DownloadTask>>(downloadTasksApi.index.$get({ query }))
|
||||
}
|
||||
|
||||
export function createDownloadTask(data: CreateDownloadTaskInput) {
|
||||
return unwrap<DownloadTask>(downloadTasksApi.index.$post({ json: data }))
|
||||
}
|
||||
|
||||
export function updateDownloadTask(id: string, data: UpdateDownloadTaskInput) {
|
||||
return unwrap<DownloadTask>(downloadTasksApi[':id'].$patch({ param: { id }, json: data }))
|
||||
}
|
||||
|
||||
export function listDownloaders() {
|
||||
return unwrap<PaginatedResponse<Downloader>>(adminDownloadersApi.index.$get())
|
||||
}
|
||||
|
||||
export function updateDownloader(id: string, data: UpdateDownloaderInput) {
|
||||
return unwrap<Downloader>(adminDownloadersApi[':id'].$patch({ param: { id }, json: data }))
|
||||
}
|
||||
|
||||
export function deleteDownloader(id: string) {
|
||||
return unwrap<{ id: string; deleted: true }>(adminDownloadersApi[':id'].$delete({ param: { id } }))
|
||||
}
|
||||
|
||||
export function sendDownloaderHeartbeat(data: DownloaderHeartbeatInput) {
|
||||
return unwrap<Downloader>(downloaderSelfApi.heartbeat.$post({ json: data }))
|
||||
}
|
||||
|
||||
// Background Jobs API
|
||||
|
||||
export interface ListBackgroundJobsOptions {
|
||||
|
||||
+35
-2
@@ -1,10 +1,10 @@
|
||||
import { apiKeyClient } from '@better-auth/api-key/client'
|
||||
import { organizationClient, usernameClient } from 'better-auth/client/plugins'
|
||||
import { deviceAuthorizationClient, organizationClient, usernameClient } from 'better-auth/client/plugins'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || '',
|
||||
plugins: [usernameClient(), organizationClient(), apiKeyClient()],
|
||||
plugins: [usernameClient(), organizationClient(), apiKeyClient(), deviceAuthorizationClient()],
|
||||
})
|
||||
|
||||
export const { signIn, signUp, signOut, useSession } = authClient
|
||||
@@ -22,3 +22,36 @@ export const {
|
||||
useListOrganizations,
|
||||
useActiveOrganization,
|
||||
} = authClient
|
||||
|
||||
type DeviceStatus = 'pending' | 'approved' | 'denied'
|
||||
type AuthFetchResult<T> = { data?: T | null; error?: { message?: string } | null }
|
||||
|
||||
function unwrapAuthFetch<T>(result: AuthFetchResult<T>): T {
|
||||
if (result.error) throw new Error(result.error.message ?? 'Auth request failed')
|
||||
if (!result.data) throw new Error('Auth request failed')
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function verifyDeviceCode(userCode: string) {
|
||||
const result = await authClient.$fetch<{ user_code: string; status: DeviceStatus }>('/device', {
|
||||
method: 'GET',
|
||||
query: { user_code: userCode },
|
||||
})
|
||||
return unwrapAuthFetch(result)
|
||||
}
|
||||
|
||||
export async function approveDeviceCode(userCode: string) {
|
||||
const result = await authClient.$fetch<{ success: boolean }>('/device/approve', {
|
||||
method: 'POST',
|
||||
body: { userCode },
|
||||
})
|
||||
return unwrapAuthFetch(result)
|
||||
}
|
||||
|
||||
export async function denyDeviceCode(userCode: string) {
|
||||
const result = await authClient.$fetch<{ success: boolean }>('/device/deny', {
|
||||
method: 'POST',
|
||||
body: { userCode },
|
||||
})
|
||||
return unwrapAuthFetch(result)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import type {
|
||||
BackgroundJobsRoute,
|
||||
BrandingAdminRoute,
|
||||
CloudStoreRoute,
|
||||
DownloaderSelfRoute,
|
||||
DownloadersRoute,
|
||||
DownloadTasksRoute,
|
||||
EmailConfigRoute,
|
||||
IhostConfigRoute,
|
||||
IhostRoute,
|
||||
@@ -37,8 +40,11 @@ import { hc } from 'hono/client'
|
||||
const opts = { init: { credentials: 'include' as RequestCredentials } }
|
||||
|
||||
export const objects = hc<ObjectsRoute>('/api/objects', opts)
|
||||
export const downloadTasksApi = hc<DownloadTasksRoute>('/api/download-tasks', opts)
|
||||
export const downloaderSelfApi = hc<DownloaderSelfRoute>('/api/downloader', opts)
|
||||
export const trash = hc<TrashRoute>('/api/trash', opts)
|
||||
export const storages = hc<StoragesRoute>('/api/admin/storages', opts)
|
||||
export const adminDownloadersApi = hc<DownloadersRoute>('/api/admin/downloaders', opts)
|
||||
export const users = hc<UsersRoute>('/api/admin/users', opts)
|
||||
export const adminQuotas = hc<AdminQuotasRoute>('/api/admin/quotas', opts)
|
||||
export const adminCloudStoreApi = hc<AdminCloudStoreRoute>('/api/admin/store', opts)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as DeviceRouteImport } from './routes/device'
|
||||
import { Route as SRouteRouteImport } from './routes/s/route'
|
||||
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
@@ -29,6 +30,7 @@ import { Route as AuthenticatedSharesIndexRouteImport } from './routes/_authenti
|
||||
import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
|
||||
import { Route as AuthenticatedImageHostIndexRouteImport } from './routes/_authenticated/image-host/index'
|
||||
import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index'
|
||||
import { Route as AuthenticatedDownloadsIndexRouteImport } from './routes/_authenticated/downloads/index'
|
||||
import { Route as AuthenticatedAdminIndexRouteImport } from './routes/_authenticated/admin/index'
|
||||
import { Route as AuthenticatedTeamsInviteRouteImport } from './routes/_authenticated/teams/invite'
|
||||
import { Route as AuthenticatedSettingsWebdavRouteImport } from './routes/_authenticated/settings/webdav'
|
||||
@@ -37,6 +39,7 @@ import { Route as AuthenticatedSettingsPasswordRouteImport } from './routes/_aut
|
||||
import { Route as AuthenticatedSettingsIhostRouteImport } from './routes/_authenticated/settings/ihost'
|
||||
import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance'
|
||||
import { Route as AuthenticatedAdminLicensingRouteImport } from './routes/_authenticated/admin/licensing'
|
||||
import { Route as AuthenticatedAdminDownloadersRouteImport } from './routes/_authenticated/admin/downloaders'
|
||||
import { Route as AuthenticatedAdminCloudStoreRouteImport } from './routes/_authenticated/admin/cloud-store'
|
||||
import { Route as AuthenticatedAdminAuditRouteImport } from './routes/_authenticated/admin/audit'
|
||||
import { Route as AuthenticatedAdminAnnouncementRouteImport } from './routes/_authenticated/admin/announcement'
|
||||
@@ -52,6 +55,11 @@ import { Route as AuthenticatedAdminUsersUserIdRouteImport } from './routes/_aut
|
||||
import { Route as AuthenticatedAdminSettingsOauthRouteImport } from './routes/_authenticated/admin/settings/oauth'
|
||||
import { Route as AuthenticatedAdminSettingsEmailRouteImport } from './routes/_authenticated/admin/settings/email'
|
||||
|
||||
const DeviceRoute = DeviceRouteImport.update({
|
||||
id: '/device',
|
||||
path: '/device',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SRouteRoute = SRouteRouteImport.update({
|
||||
id: '/s',
|
||||
path: '/s',
|
||||
@@ -156,6 +164,12 @@ const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({
|
||||
path: '/files/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedDownloadsIndexRoute =
|
||||
AuthenticatedDownloadsIndexRouteImport.update({
|
||||
id: '/downloads/',
|
||||
path: '/downloads/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminIndexRoute = AuthenticatedAdminIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -203,6 +217,12 @@ const AuthenticatedAdminLicensingRoute =
|
||||
path: '/licensing',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminDownloadersRoute =
|
||||
AuthenticatedAdminDownloadersRouteImport.update({
|
||||
id: '/downloaders',
|
||||
path: '/downloaders',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminCloudStoreRoute =
|
||||
AuthenticatedAdminCloudStoreRouteImport.update({
|
||||
id: '/cloud-store',
|
||||
@@ -290,6 +310,7 @@ const AuthenticatedAdminSettingsEmailRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/s': typeof SRouteRouteWithChildren
|
||||
'/device': typeof DeviceRoute
|
||||
'/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
'/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
|
||||
'/sign-in': typeof authSignInRoute
|
||||
@@ -302,6 +323,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/announcement': typeof AuthenticatedAdminAnnouncementRoute
|
||||
'/admin/audit': typeof AuthenticatedAdminAuditRoute
|
||||
'/admin/cloud-store': typeof AuthenticatedAdminCloudStoreRoute
|
||||
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
|
||||
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
|
||||
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/settings/ihost': typeof AuthenticatedSettingsIhostRoute
|
||||
@@ -310,6 +332,7 @@ export interface FileRoutesByFullPath {
|
||||
'/settings/webdav': typeof AuthenticatedSettingsWebdavRoute
|
||||
'/teams/invite': typeof AuthenticatedTeamsInviteRoute
|
||||
'/admin/': typeof AuthenticatedAdminIndexRoute
|
||||
'/downloads/': typeof AuthenticatedDownloadsIndexRoute
|
||||
'/files/': typeof AuthenticatedFilesIndexRoute
|
||||
'/image-host/': typeof AuthenticatedImageHostIndexRoute
|
||||
'/settings/': typeof AuthenticatedSettingsIndexRoute
|
||||
@@ -332,6 +355,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/s': typeof SRouteRouteWithChildren
|
||||
'/device': typeof DeviceRoute
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/storage': typeof AuthenticatedStorageRoute
|
||||
@@ -342,6 +366,7 @@ export interface FileRoutesByTo {
|
||||
'/admin/announcement': typeof AuthenticatedAdminAnnouncementRoute
|
||||
'/admin/audit': typeof AuthenticatedAdminAuditRoute
|
||||
'/admin/cloud-store': typeof AuthenticatedAdminCloudStoreRoute
|
||||
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
|
||||
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
|
||||
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/settings/ihost': typeof AuthenticatedSettingsIhostRoute
|
||||
@@ -350,6 +375,7 @@ export interface FileRoutesByTo {
|
||||
'/settings/webdav': typeof AuthenticatedSettingsWebdavRoute
|
||||
'/teams/invite': typeof AuthenticatedTeamsInviteRoute
|
||||
'/admin': typeof AuthenticatedAdminIndexRoute
|
||||
'/downloads': typeof AuthenticatedDownloadsIndexRoute
|
||||
'/files': typeof AuthenticatedFilesIndexRoute
|
||||
'/image-host': typeof AuthenticatedImageHostIndexRoute
|
||||
'/settings': typeof AuthenticatedSettingsIndexRoute
|
||||
@@ -374,6 +400,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_authenticated': typeof AuthenticatedRouteRouteWithChildren
|
||||
'/s': typeof SRouteRouteWithChildren
|
||||
'/device': typeof DeviceRoute
|
||||
'/_authenticated/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
'/_authenticated/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
|
||||
'/(auth)/sign-in': typeof authSignInRoute
|
||||
@@ -387,6 +414,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/admin/announcement': typeof AuthenticatedAdminAnnouncementRoute
|
||||
'/_authenticated/admin/audit': typeof AuthenticatedAdminAuditRoute
|
||||
'/_authenticated/admin/cloud-store': typeof AuthenticatedAdminCloudStoreRoute
|
||||
'/_authenticated/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
|
||||
'/_authenticated/admin/licensing': typeof AuthenticatedAdminLicensingRoute
|
||||
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
|
||||
'/_authenticated/settings/ihost': typeof AuthenticatedSettingsIhostRoute
|
||||
@@ -395,6 +423,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/settings/webdav': typeof AuthenticatedSettingsWebdavRoute
|
||||
'/_authenticated/teams/invite': typeof AuthenticatedTeamsInviteRoute
|
||||
'/_authenticated/admin/': typeof AuthenticatedAdminIndexRoute
|
||||
'/_authenticated/downloads/': typeof AuthenticatedDownloadsIndexRoute
|
||||
'/_authenticated/files/': typeof AuthenticatedFilesIndexRoute
|
||||
'/_authenticated/image-host/': typeof AuthenticatedImageHostIndexRoute
|
||||
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
|
||||
@@ -420,6 +449,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/s'
|
||||
| '/device'
|
||||
| '/admin'
|
||||
| '/settings'
|
||||
| '/sign-in'
|
||||
@@ -432,6 +462,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/announcement'
|
||||
| '/admin/audit'
|
||||
| '/admin/cloud-store'
|
||||
| '/admin/downloaders'
|
||||
| '/admin/licensing'
|
||||
| '/settings/appearance'
|
||||
| '/settings/ihost'
|
||||
@@ -440,6 +471,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/webdav'
|
||||
| '/teams/invite'
|
||||
| '/admin/'
|
||||
| '/downloads/'
|
||||
| '/files/'
|
||||
| '/image-host/'
|
||||
| '/settings/'
|
||||
@@ -462,6 +494,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/s'
|
||||
| '/device'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/storage'
|
||||
@@ -472,6 +505,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/announcement'
|
||||
| '/admin/audit'
|
||||
| '/admin/cloud-store'
|
||||
| '/admin/downloaders'
|
||||
| '/admin/licensing'
|
||||
| '/settings/appearance'
|
||||
| '/settings/ihost'
|
||||
@@ -480,6 +514,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/webdav'
|
||||
| '/teams/invite'
|
||||
| '/admin'
|
||||
| '/downloads'
|
||||
| '/files'
|
||||
| '/image-host'
|
||||
| '/settings'
|
||||
@@ -503,6 +538,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
| '/s'
|
||||
| '/device'
|
||||
| '/_authenticated/admin'
|
||||
| '/_authenticated/settings'
|
||||
| '/(auth)/sign-in'
|
||||
@@ -516,6 +552,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/admin/announcement'
|
||||
| '/_authenticated/admin/audit'
|
||||
| '/_authenticated/admin/cloud-store'
|
||||
| '/_authenticated/admin/downloaders'
|
||||
| '/_authenticated/admin/licensing'
|
||||
| '/_authenticated/settings/appearance'
|
||||
| '/_authenticated/settings/ihost'
|
||||
@@ -524,6 +561,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/settings/webdav'
|
||||
| '/_authenticated/teams/invite'
|
||||
| '/_authenticated/admin/'
|
||||
| '/_authenticated/downloads/'
|
||||
| '/_authenticated/files/'
|
||||
| '/_authenticated/image-host/'
|
||||
| '/_authenticated/settings/'
|
||||
@@ -548,6 +586,7 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
|
||||
SRouteRoute: typeof SRouteRouteWithChildren
|
||||
DeviceRoute: typeof DeviceRoute
|
||||
authSignInRoute: typeof authSignInRoute
|
||||
authSignUpRoute: typeof authSignUpRoute
|
||||
StoreCheckoutRoute: typeof StoreCheckoutRoute
|
||||
@@ -556,6 +595,13 @@ export interface RootRouteChildren {
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/device': {
|
||||
id: '/device'
|
||||
path: '/device'
|
||||
fullPath: '/device'
|
||||
preLoaderRoute: typeof DeviceRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/s': {
|
||||
id: '/s'
|
||||
path: '/s'
|
||||
@@ -696,6 +742,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedFilesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/downloads/': {
|
||||
id: '/_authenticated/downloads/'
|
||||
path: '/downloads'
|
||||
fullPath: '/downloads/'
|
||||
preLoaderRoute: typeof AuthenticatedDownloadsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/': {
|
||||
id: '/_authenticated/admin/'
|
||||
path: '/'
|
||||
@@ -752,6 +805,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAdminLicensingRouteImport
|
||||
parentRoute: typeof AuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/downloaders': {
|
||||
id: '/_authenticated/admin/downloaders'
|
||||
path: '/downloaders'
|
||||
fullPath: '/admin/downloaders'
|
||||
preLoaderRoute: typeof AuthenticatedAdminDownloadersRouteImport
|
||||
parentRoute: typeof AuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/cloud-store': {
|
||||
id: '/_authenticated/admin/cloud-store'
|
||||
path: '/cloud-store'
|
||||
@@ -857,6 +917,7 @@ interface AuthenticatedAdminRouteRouteChildren {
|
||||
AuthenticatedAdminAnnouncementRoute: typeof AuthenticatedAdminAnnouncementRoute
|
||||
AuthenticatedAdminAuditRoute: typeof AuthenticatedAdminAuditRoute
|
||||
AuthenticatedAdminCloudStoreRoute: typeof AuthenticatedAdminCloudStoreRoute
|
||||
AuthenticatedAdminDownloadersRoute: typeof AuthenticatedAdminDownloadersRoute
|
||||
AuthenticatedAdminLicensingRoute: typeof AuthenticatedAdminLicensingRoute
|
||||
AuthenticatedAdminIndexRoute: typeof AuthenticatedAdminIndexRoute
|
||||
AuthenticatedAdminSettingsEmailRoute: typeof AuthenticatedAdminSettingsEmailRoute
|
||||
@@ -872,6 +933,7 @@ const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren
|
||||
AuthenticatedAdminAnnouncementRoute: AuthenticatedAdminAnnouncementRoute,
|
||||
AuthenticatedAdminAuditRoute: AuthenticatedAdminAuditRoute,
|
||||
AuthenticatedAdminCloudStoreRoute: AuthenticatedAdminCloudStoreRoute,
|
||||
AuthenticatedAdminDownloadersRoute: AuthenticatedAdminDownloadersRoute,
|
||||
AuthenticatedAdminLicensingRoute: AuthenticatedAdminLicensingRoute,
|
||||
AuthenticatedAdminIndexRoute: AuthenticatedAdminIndexRoute,
|
||||
AuthenticatedAdminSettingsEmailRoute: AuthenticatedAdminSettingsEmailRoute,
|
||||
@@ -940,6 +1002,7 @@ interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedTeamsTeamIdRouteRoute: typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren
|
||||
AuthenticatedTeamsInviteRoute: typeof AuthenticatedTeamsInviteRoute
|
||||
AuthenticatedDownloadsIndexRoute: typeof AuthenticatedDownloadsIndexRoute
|
||||
AuthenticatedFilesIndexRoute: typeof AuthenticatedFilesIndexRoute
|
||||
AuthenticatedImageHostIndexRoute: typeof AuthenticatedImageHostIndexRoute
|
||||
AuthenticatedSharesIndexRoute: typeof AuthenticatedSharesIndexRoute
|
||||
@@ -958,6 +1021,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedTeamsTeamIdRouteRoute:
|
||||
AuthenticatedTeamsTeamIdRouteRouteWithChildren,
|
||||
AuthenticatedTeamsInviteRoute: AuthenticatedTeamsInviteRoute,
|
||||
AuthenticatedDownloadsIndexRoute: AuthenticatedDownloadsIndexRoute,
|
||||
AuthenticatedFilesIndexRoute: AuthenticatedFilesIndexRoute,
|
||||
AuthenticatedImageHostIndexRoute: AuthenticatedImageHostIndexRoute,
|
||||
AuthenticatedSharesIndexRoute: AuthenticatedSharesIndexRoute,
|
||||
@@ -985,6 +1049,7 @@ const SRouteRouteWithChildren =
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
|
||||
SRouteRoute: SRouteRouteWithChildren,
|
||||
DeviceRoute: DeviceRoute,
|
||||
authSignInRoute: authSignInRoute,
|
||||
authSignUpRoute: authSignUpRoute,
|
||||
StoreCheckoutRoute: StoreCheckoutRoute,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Downloader } from '@shared/types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Activity, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { AdminPageHeader } from '@/components/admin/admin-page-header'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { deleteDownloader, listDownloaders, updateDownloader } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/admin/downloaders')({
|
||||
component: AdminDownloadersPage,
|
||||
})
|
||||
|
||||
const QUERY_KEY = ['admin', 'downloaders']
|
||||
|
||||
function AdminDownloadersPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteTarget, setDeleteTarget] = useState<Downloader | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: listDownloaders,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateDownloader(id, { enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUERY_KEY }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteDownloader(id),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null)
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
|
||||
toast.success(t('admin.downloaders.deleteSuccess'))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const downloaders = query.data?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-4">
|
||||
<AdminPageHeader title={t('admin.downloaders.title')} description={t('admin.downloaders.subtitle')} />
|
||||
|
||||
<section className="rounded-md border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.downloaders.table.name')}</TableHead>
|
||||
<TableHead>{t('admin.downloaders.table.status')}</TableHead>
|
||||
<TableHead>{t('admin.downloaders.table.engine')}</TableHead>
|
||||
<TableHead>{t('admin.downloaders.table.tasks')}</TableHead>
|
||||
<TableHead>{t('admin.downloaders.table.speed')}</TableHead>
|
||||
<TableHead>{t('admin.downloaders.table.billing')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{downloaders.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-28 text-center text-muted-foreground">
|
||||
{query.isLoading ? t('common.loading') : t('admin.downloaders.empty')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{downloaders.map((downloader) => (
|
||||
<DownloaderRow
|
||||
key={downloader.id}
|
||||
downloader={downloader}
|
||||
onToggle={(enabled) => toggleMutation.mutate({ id: downloader.id, enabled })}
|
||||
onDelete={() => setDeleteTarget(downloader)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
<DeleteDownloaderDialog
|
||||
downloader={deleteTarget}
|
||||
open={deleteTarget !== null}
|
||||
pending={deleteMutation.isPending}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloaderRow({
|
||||
downloader,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
downloader: Downloader
|
||||
onToggle: (enabled: boolean) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="font-medium">{downloader.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{downloader.hostname} · {downloader.platform}/{downloader.arch}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
downloader.status === 'online' ? 'default' : downloader.status === 'disabled' ? 'outline' : 'secondary'
|
||||
}
|
||||
>
|
||||
{t(`admin.downloaders.status.${downloader.status}`)}
|
||||
</Badge>
|
||||
{downloader.lastHeartbeatAt && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Activity className="size-3" />
|
||||
{new Date(downloader.lastHeartbeatAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{downloader.engine}</TableCell>
|
||||
<TableCell>
|
||||
{downloader.currentTasks} / {downloader.maxConcurrentTasks}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatBytes(downloader.downloadBps)}/s · {formatBytes(downloader.uploadBps)}/s
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{downloader.remoteDownloadCreditBillingEnabled
|
||||
? `${downloader.remoteDownloadCreditPerUnit} / ${formatBytes(downloader.remoteDownloadCreditUnitBytes)}`
|
||||
: t('common.disabled')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Switch checked={downloader.enabled} onCheckedChange={onToggle} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onDelete}
|
||||
aria-label={t('admin.downloaders.delete')}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteDownloaderDialog({
|
||||
downloader,
|
||||
open,
|
||||
pending,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
downloader: Downloader | null
|
||||
open: boolean
|
||||
pending: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.downloaders.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('admin.downloaders.deleteConfirm', { name: downloader?.name ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={onConfirm} disabled={pending}>
|
||||
{t('admin.downloaders.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { DownloadTask, DownloadTaskStatus, StorageObject } from '@shared/types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileDown,
|
||||
Folder,
|
||||
FolderInput,
|
||||
Home,
|
||||
LinkIcon,
|
||||
Magnet,
|
||||
PauseCircle,
|
||||
Plus,
|
||||
RotateCw,
|
||||
} from 'lucide-react'
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useFilesQuery } from '@/components/files/hooks/use-files-query'
|
||||
import { PageHeader } from '@/components/layout/page-header'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { createDownloadTask, listDownloadTasks, updateDownloadTask } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/downloads/')({
|
||||
component: DownloadsPage,
|
||||
})
|
||||
|
||||
const QUERY_KEY = ['download-tasks']
|
||||
const ACTIVE_STATUSES = new Set<DownloadTaskStatus>(['queued', 'assigned', 'running', 'billing_paused', 'uploading'])
|
||||
|
||||
function DownloadsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [sourceType, setSourceType] = useState<'http' | 'magnet' | 'torrent_url'>('http')
|
||||
const [uri, setUri] = useState('')
|
||||
const [targetFolder, setTargetFolder] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => listDownloadTasks({ page: 1, pageSize: 50 }),
|
||||
refetchInterval: 2000,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createDownloadTask,
|
||||
onSuccess: () => {
|
||||
setUri('')
|
||||
setName('')
|
||||
setTargetFolder('')
|
||||
setCreateOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
|
||||
toast.success(t('downloads.createSuccess'))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: (id: string) => updateDownloadTask(id, { status: 'canceled' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY })
|
||||
toast.success(t('downloads.cancelSuccess'))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
createMutation.mutate({
|
||||
source: { type: sourceType, uri: uri.trim() },
|
||||
targetFolder: targetFolder.trim(),
|
||||
name: name.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const tasks = tasksQuery.data?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
items={[{ label: t('downloads.title'), icon: <Download className="size-4 text-muted-foreground" /> }]}
|
||||
actions={
|
||||
<Button type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
{t('downloads.create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('downloads.createTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-source-type">{t('downloads.sourceType')}</Label>
|
||||
<ToggleGroup
|
||||
id="download-source-type"
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={sourceType}
|
||||
onValueChange={(value) => value && setSourceType(value as typeof sourceType)}
|
||||
className="grid w-full grid-cols-3"
|
||||
>
|
||||
<ToggleGroupItem value="http" className="h-14 flex-col gap-1 px-2">
|
||||
<LinkIcon className="size-4" />
|
||||
<span className="text-xs">{t('downloads.sourceTypes.http')}</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="magnet" className="h-14 flex-col gap-1 px-2">
|
||||
<Magnet className="size-4" />
|
||||
<span className="text-xs">{t('downloads.sourceTypes.magnet')}</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="torrent_url" className="h-14 flex-col gap-1 px-2">
|
||||
<FileDown className="size-4" />
|
||||
<span className="text-xs">{t('downloads.sourceTypes.torrentUrl')}</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-uri">{t('downloads.uri')}</Label>
|
||||
<Textarea
|
||||
id="download-uri"
|
||||
value={uri}
|
||||
onChange={(event) => setUri(event.target.value)}
|
||||
placeholder={t('downloads.uriPlaceholder')}
|
||||
className="min-h-28 resize-y font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 rounded-md border bg-muted/30 p-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-target" className="flex items-center gap-2">
|
||||
<FolderInput className="size-4 text-muted-foreground" />
|
||||
{t('downloads.targetFolder')}
|
||||
</Label>
|
||||
<FolderPicker value={targetFolder} onChange={setTargetFolder} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-name" className="flex items-center gap-2">
|
||||
<FileDown className="size-4 text-muted-foreground" />
|
||||
{t('downloads.name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="download-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={t('downloads.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || !uri.trim()}>
|
||||
<Plus className="size-4" />
|
||||
{t('downloads.start')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<section className="rounded-md border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('downloads.table.source')}</TableHead>
|
||||
<TableHead>{t('downloads.table.status')}</TableHead>
|
||||
<TableHead>{t('downloads.table.progress')}</TableHead>
|
||||
<TableHead>{t('downloads.table.speed')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-28 text-center text-muted-foreground">
|
||||
{tasksQuery.isLoading ? t('common.loading') : t('downloads.empty')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} onCancel={(id) => pauseMutation.mutate(id)} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildPath(parent: string, name: string): string {
|
||||
return parent ? `${parent}/${name}` : name
|
||||
}
|
||||
|
||||
function FolderPicker({ value, onChange }: { value: string; onChange: (path: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [browsingPath, setBrowsingPath] = useState('')
|
||||
const query = useFilesQuery(browsingPath, 'folder')
|
||||
const folders = (query.data?.items ?? []).filter((item) => item.dirtype !== DirType.FILE)
|
||||
const breadcrumb = browsingPath ? browsingPath.split('/') : []
|
||||
const displayValue = value || t('downloads.targetFolderRoot')
|
||||
|
||||
function navigateToIndex(index: number) {
|
||||
setBrowsingPath(index < 0 ? '' : breadcrumb.slice(0, index + 1).join('/'))
|
||||
}
|
||||
|
||||
function navigateInto(folder: StorageObject) {
|
||||
setBrowsingPath(buildPath(browsingPath, folder.name))
|
||||
}
|
||||
|
||||
function selectPath(path: string) {
|
||||
onChange(path)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="download-target"
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full justify-between px-3 font-normal"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Folder className="size-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate">{displayValue}</span>
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 rotate-90 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="z-[60] w-96 max-w-[calc(100vw-2rem)] p-0">
|
||||
<div className="border-b px-3 py-2">
|
||||
<nav className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
|
||||
<button type="button" className="shrink-0 hover:text-foreground" onClick={() => navigateToIndex(-1)}>
|
||||
<Home className="size-4" />
|
||||
</button>
|
||||
{breadcrumb.map((name, index) => (
|
||||
<span key={breadcrumb.slice(0, index + 1).join('/')} className="flex min-w-0 items-center gap-1">
|
||||
<ChevronRight className="size-3 shrink-0" />
|
||||
<button type="button" className="truncate hover:text-foreground" onClick={() => navigateToIndex(index)}>
|
||||
{name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-muted ${
|
||||
value === '' ? 'bg-primary/10 text-primary' : ''
|
||||
}`}
|
||||
onClick={() => selectPath('')}
|
||||
>
|
||||
<Home className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('downloads.targetFolderRoot')}</span>
|
||||
{value === '' && <Check className="size-4 shrink-0" />}
|
||||
</button>
|
||||
|
||||
{query.isLoading && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">{t('common.loading')}</div>
|
||||
)}
|
||||
{!query.isLoading && folders.length === 0 && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">{t('files.noFolders')}</div>
|
||||
)}
|
||||
{folders.map((folder) => {
|
||||
const folderPath = buildPath(browsingPath, folder.name)
|
||||
const selected = value === folderPath
|
||||
return (
|
||||
<div key={folder.id} className="flex items-center rounded-sm hover:bg-muted">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm ${
|
||||
selected ? 'text-primary' : ''
|
||||
}`}
|
||||
onClick={() => selectPath(folderPath)}
|
||||
>
|
||||
<Folder className="size-4 shrink-0 text-blue-500" />
|
||||
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
|
||||
{selected && <Check className="size-4 shrink-0" />}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-1 size-8 shrink-0"
|
||||
onClick={() => navigateInto(folder)}
|
||||
>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskRow({ task, onCancel }: { task: DownloadTask; onCancel: (id: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const total = task.totalBytes ?? task.downloadedBytes
|
||||
const progress = total > 0 ? Math.min(100, Math.round((task.downloadedBytes / total) * 100)) : 0
|
||||
const active = ACTIVE_STATUSES.has(task.status)
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="max-w-[32rem]">
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{task.name || task.sourceUri}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{task.sourceUri}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={task.status} />
|
||||
{task.status === 'billing_paused' && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t('downloads.billingPaused')}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-44">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<span className="w-10 text-right text-xs text-muted-foreground">{progress}%</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatBytes(task.downloadedBytes)} /{' '}
|
||||
{task.totalBytes ? formatBytes(task.totalBytes) : t('downloads.unknown')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatBytes(task.downloadBps)}/s</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{active ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => onCancel(task.id)}>
|
||||
<PauseCircle className="size-4" />
|
||||
{t('downloads.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<RotateCw className="ml-auto size-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: DownloadTaskStatus }) {
|
||||
const { t } = useTranslation()
|
||||
const variant =
|
||||
status === 'completed' ? 'default' : status === 'failed' || status === 'canceled' ? 'destructive' : 'secondary'
|
||||
return <Badge variant={variant}>{t(`downloads.status.${status}`)}</Badge>
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { Check, Loader2, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { approveDeviceCode, denyDeviceCode, useSession, verifyDeviceCode } from '@/lib/auth-client'
|
||||
|
||||
export const Route = createFileRoute('/device')({
|
||||
component: DeviceAuthorizationPage,
|
||||
})
|
||||
|
||||
type DeviceStatus = 'loading' | 'pending' | 'approved' | 'denied' | 'invalid'
|
||||
|
||||
function DeviceAuthorizationPage() {
|
||||
const { t } = useTranslation()
|
||||
const session = useSession()
|
||||
const [status, setStatus] = useState<DeviceStatus>('loading')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const userCode = new URLSearchParams(window.location.search).get('user_code') ?? ''
|
||||
const signInRedirect = `/sign-in?redirect=${encodeURIComponent(`/device?user_code=${encodeURIComponent(userCode)}`)}`
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false
|
||||
async function load() {
|
||||
if (!userCode) {
|
||||
setStatus('invalid')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await verifyDeviceCode(userCode)
|
||||
if (!canceled) setStatus(result.status)
|
||||
} catch {
|
||||
if (!canceled) setStatus('invalid')
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [userCode])
|
||||
|
||||
async function approve() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await approveDeviceCode(userCode)
|
||||
setStatus('approved')
|
||||
toast.success(t('device.approved'))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('device.failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function deny() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await denyDeviceCode(userCode)
|
||||
setStatus('denied')
|
||||
toast.success(t('device.denied'))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('device.failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/30 p-6">
|
||||
<section className="w-full max-w-md rounded-md border bg-background p-6 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">{t('device.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('device.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="my-6 rounded-md border bg-muted/40 p-4 text-center">
|
||||
<div className="text-xs font-medium text-muted-foreground">{t('device.code')}</div>
|
||||
<div className="mt-1 font-mono text-2xl font-semibold tracking-normal">{userCode || '-'}</div>
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'invalid' && <p className="text-sm text-destructive">{t('device.invalid')}</p>}
|
||||
|
||||
{status === 'pending' && !session.data?.user && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t('device.signInRequired')}</p>
|
||||
<Button asChild className="w-full">
|
||||
<Link to={signInRedirect}>{t('auth.signIn')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'pending' && session.data?.user && (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" className="flex-1" onClick={approve} disabled={loading}>
|
||||
<Check className="size-4" />
|
||||
{t('device.approve')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={deny} disabled={loading}>
|
||||
<X className="size-4" />
|
||||
{t('device.deny')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'approved' && <p className="text-sm text-muted-foreground">{t('device.approvedMessage')}</p>}
|
||||
{status === 'denied' && <p className="text-sm text-muted-foreground">{t('device.deniedMessage')}</p>}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user