feat(downloads): add remote download workers

This commit is contained in:
saltbo
2026-06-03 02:21:50 -04:00
parent cf12806902
commit 53076d7873
53 changed files with 15539 additions and 14 deletions
+27
View File
@@ -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
+248
View File
@@ -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, &notFound) && !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"}
}
}
+38
View File
@@ -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
)
+92
View File
@@ -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=
+332
View File
@@ -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))
}
+87
View File
@@ -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, &notFound) && !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")
}
+446
View File
@@ -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")
}
+130
View File
@@ -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)
}
+65
View File
@@ -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
+567
View File
@@ -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
}
+118
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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",
+46
View File
@@ -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
+7
View File
@@ -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`)
+11
View File
@@ -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
View File
@@ -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',
+28
View File
@@ -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),
+122
View File
@@ -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',
{
+39
View File
@@ -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) {
+26
View File
@@ -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()
})
+25
View File
@@ -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}',
]),
)
})
})
+165
View File
@@ -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)
})
})
+191
View File
@@ -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
}
}
+170
View File
@@ -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
View File
@@ -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
}
}
+162
View File
@@ -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
}
+529
View File
@@ -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 []
}
}
+150
View File
@@ -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(),
}
}
+96
View File
@@ -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))
}
+50
View File
@@ -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,
+115
View File
@@ -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,
+112
View File
@@ -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>
+26
View File
@@ -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'
+78
View File
@@ -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 & {})
+2
View File
@@ -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 },
+10
View File
@@ -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">
+65 -1
View File
@@ -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."
}
+65 -1
View File
@@ -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": "已拒绝授权,可以关闭此页面。"
}
+176
View File
@@ -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',
+80
View File
@@ -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
View File
@@ -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)
}
+6
View File
@@ -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)
+65
View File
@@ -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]}`
}
+118
View File
@@ -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>
)
}