From 434f6441da63f506beba64734094d80e330d93a1 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sun, 25 Oct 2026 20:24:50 -0400 Subject: [PATCH] Initial DDC release --- .dockerignore | 10 + .env.example | 17 + .gitignore | 5 + Dockerfile | 12 + README.md | 105 ++++ deploy/nginx/ddc.conf | 28 ++ docker-compose.yml | 23 + go.mod | 3 + internal/auth/credentials.go | 18 + internal/auth/limiter.go | 76 +++ internal/auth/sessions.go | 75 +++ internal/config/config.go | 73 +++ internal/config/env.go | 78 +++ internal/config/libraries.go | 56 +++ internal/diskspace/diskspace.go | 38 ++ internal/extract/extract.go | 48 ++ internal/extract/formats.go | 50 ++ internal/extract/tar.go | 61 +++ internal/extract/writer.go | 106 ++++ internal/extract/zip.go | 44 ++ internal/fetch/downloader.go | 152 ++++++ internal/fetch/filename.go | 65 +++ internal/files/move.go | 101 ++++ internal/files/paths.go | 99 ++++ internal/files/size.go | 52 ++ internal/files/tree.go | 85 ++++ internal/guard/addresses.go | 54 ++ internal/guard/client.go | 55 ++ internal/guard/policy.go | 98 ++++ internal/jobs/job.go | 126 +++++ internal/jobs/manager.go | 208 ++++++++ internal/jobs/move.go | 125 +++++ internal/jobs/restore.go | 51 ++ internal/jobs/slots.go | 45 ++ internal/jobs/workflows.go | 102 ++++ internal/libraries/libraries.go | 57 +++ internal/preferences/preferences.go | 41 ++ internal/preferences/store.go | 87 ++++ internal/server/auth_handlers.go | 83 +++ internal/server/job_handlers.go | 99 ++++ internal/server/middleware.go | 73 +++ internal/server/respond.go | 47 ++ internal/server/server.go | 73 +++ internal/server/settings_handlers.go | 38 ++ main.go | 89 ++++ web/embed.go | 17 + web/static/index.html | 162 ++++++ web/static/js/api.js | 61 +++ web/static/js/app.js | 293 +++++++++++ web/static/js/dom.js | 13 + web/static/js/format.js | 27 + web/static/js/jobs.js | 95 ++++ web/static/js/login.js | 17 + web/static/js/settings.js | 104 ++++ web/static/js/theme.js | 10 + web/static/js/tree.js | 50 ++ web/static/style.css | 726 +++++++++++++++++++++++++++ 57 files changed, 4606 insertions(+) create mode 100755 .dockerignore create mode 100755 .env.example create mode 100755 .gitignore create mode 100755 Dockerfile create mode 100755 README.md create mode 100755 deploy/nginx/ddc.conf create mode 100755 docker-compose.yml create mode 100755 go.mod create mode 100755 internal/auth/credentials.go create mode 100755 internal/auth/limiter.go create mode 100755 internal/auth/sessions.go create mode 100755 internal/config/config.go create mode 100755 internal/config/env.go create mode 100755 internal/config/libraries.go create mode 100755 internal/diskspace/diskspace.go create mode 100755 internal/extract/extract.go create mode 100755 internal/extract/formats.go create mode 100755 internal/extract/tar.go create mode 100755 internal/extract/writer.go create mode 100755 internal/extract/zip.go create mode 100755 internal/fetch/downloader.go create mode 100755 internal/fetch/filename.go create mode 100755 internal/files/move.go create mode 100755 internal/files/paths.go create mode 100755 internal/files/size.go create mode 100755 internal/files/tree.go create mode 100755 internal/guard/addresses.go create mode 100755 internal/guard/client.go create mode 100755 internal/guard/policy.go create mode 100755 internal/jobs/job.go create mode 100755 internal/jobs/manager.go create mode 100755 internal/jobs/move.go create mode 100755 internal/jobs/restore.go create mode 100644 internal/jobs/slots.go create mode 100755 internal/jobs/workflows.go create mode 100644 internal/libraries/libraries.go create mode 100644 internal/preferences/preferences.go create mode 100644 internal/preferences/store.go create mode 100755 internal/server/auth_handlers.go create mode 100755 internal/server/job_handlers.go create mode 100755 internal/server/middleware.go create mode 100755 internal/server/respond.go create mode 100755 internal/server/server.go create mode 100644 internal/server/settings_handlers.go create mode 100755 main.go create mode 100755 web/embed.go create mode 100755 web/static/index.html create mode 100755 web/static/js/api.js create mode 100755 web/static/js/app.js create mode 100755 web/static/js/dom.js create mode 100755 web/static/js/format.js create mode 100755 web/static/js/jobs.js create mode 100755 web/static/js/login.js create mode 100644 web/static/js/settings.js create mode 100644 web/static/js/theme.js create mode 100755 web/static/js/tree.js create mode 100755 web/static/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..6b66f74 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.env +.env.example +.git +.gitignore +PROJECT.md +UPDATE.md +README.md +deploy +data +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..3a3a384 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +PUID=1000 +PGID=1000 + +AUTH_USERNAME=admin +AUTH_PASSWORD=change-this-to-a-long-random-password +COOKIE_SECURE=true +SESSION_TTL_HOURS=12 +TRUST_PROXY_HEADERS=true + +LISTEN_ADDR=:8080 +DOWNLOAD_DIR=/data/downloads + +DOWNLOAD_HOST_DIR=./data/downloads + +MIN_FREE_GB=10 +MAX_CONCURRENT_DOWNLOADS=2 +ALLOWED_PORTS=80,443 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..855ecae --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +PROJECT.md +UPDATE.md +/ddc +/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..a8fcf28 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +COPY internal ./internal +COPY web ./web +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ddc . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/ddc /ddc +EXPOSE 8080 +ENTRYPOINT ["/ddc"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..6dd6330 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# DDC (Direct Download Client) + +Self-hosted direct download client that fetches files onto the server, extracts archives, and moves the results into media folders. + +## Stack + +- Backend: Go standard library only (no third-party dependencies), single static binary +- Frontend: plain HTML, CSS, and JavaScript modules embedded in the binary +- Container: distroless static image, non-root, read-only root filesystem + +## Project Layout + +| Path | Purpose | +| --- | --- | +| `main.go` | Startup and graceful shutdown | +| `internal/config` | Environment configuration and library root parsing | +| `internal/preferences` | Settings editable from the web page, saved to `.settings.json` | +| `internal/libraries` | Discovers and validates first level library folders | +| `internal/auth` | Sessions, credential check, login rate limiting | +| `internal/guard` | URL validation and SSRF-safe HTTP client | +| `internal/fetch` | Streaming download with progress, size and stall limits | +| `internal/extract` | Zip and tar extraction with Zip Slip and bomb protection | +| `internal/files` | Safe path resolution, file tree, cross-device moves | +| `internal/diskspace` | Free space checks | +| `internal/jobs` | Download job lifecycle and workflows | +| `internal/server` | HTTP routes, middleware, handlers | +| `web/static` | Single page frontend | +| `deploy/nginx` | Reverse proxy example | + +## Destinations + +Every first level folder inside these roots is a move destination: + +- `/media/blob/TeaNet/Plex` +- `/media/blob/ChaiNet/Plex` + +## Security + +- Session login with HttpOnly, Secure, SameSite=Strict cookie +- Login lockout after 5 failed attempts per client for 15 minutes +- Same-origin check and JSON-only bodies on state changing requests +- SSRF protection: http/https only, allowed port list, every resolved IP checked at connect time (blocks loopback, private, link-local, CGNAT, metadata, ULA, NAT64, multicast), redirects revalidated, proxies ignored +- Zip Slip protection: absolute paths and `..` entries rejected, symlinks, hardlinks and devices skipped +- Extraction limited by free disk space and entry count +- Move targets restricted to first level folders of the destination roots (hidden folders and symlinks excluded), subfolders validated component by component, existing files never overwritten +- Disk space checked before download, during download, before extraction, and before cross-device copies +- Strict Content-Security-Policy, no inline scripts, all untrusted names rendered as text + +## Supported Archives + +`.zip`, `.tar`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tbz2`, `.tbz` + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `AUTH_USERNAME` | required | Login username | +| `AUTH_PASSWORD` | required | Login password, minimum 12 characters | +| `COOKIE_SECURE` | `true` | Set `false` only for plain HTTP testing | +| `SESSION_TTL_HOURS` | `12` | Session lifetime | +| `TRUST_PROXY_HEADERS` | `false` | Use `X-Real-IP` for login rate limiting (enable behind nginx) | +| `LISTEN_ADDR` | `:8080` | Listen address inside the container | +| `DOWNLOAD_DIR` | `/data/downloads` | Temporary download directory | +| `MIN_FREE_GB` | `10` | Initial value: free space that must always remain | +| `MAX_CONCURRENT_DOWNLOADS` | `2` | Initial value: parallel downloads, others wait in queue | +| `ALLOWED_PORTS` | `80,443` | Initial value: allowed remote ports, `*` for any | + +## Web Settings + +The Settings dialog edits parallel downloads, free space to keep, allowed ports, default extract and cleanup options, and the theme. Server settings are saved to `.settings.json` in the download folder and override the initial values above. The theme is stored per browser. + +## Local Testing + +1. Create the download folder first, so Docker does not create it as root: + ``` + mkdir -p data/downloads + ``` +2. `.env` is preset for testing: downloads go to `./data/downloads`, cookies work over plain HTTP. Both destination drives must be mounted before starting. +3. Start and open `http://127.0.0.1:8080` (user `admin`, password from `.env`): + ``` + docker compose up -d --build + ``` + +## Deployment + +1. Make sure `PUID`/`PGID` can write to the download folder and to the first level folders of both destination roots. +2. Copy and edit the environment file: + ``` + cp .env.example .env + ``` +3. Start the service: + ``` + docker compose up -d --build + ``` +4. Install the nginx site: + ``` + sudo cp deploy/nginx/ddc.conf /etc/nginx/sites-available/ddc.conf + sudo ln -s /etc/nginx/sites-available/ddc.conf /etc/nginx/sites-enabled/ + sudo nginx -t && sudo systemctl reload nginx + ``` + +## Notes + +- Moves are instant renames when the download directory and destination share a mount, otherwise files are copied and the source removed. +- Downloads left in the download directory after a restart are listed again as ready. diff --git a/deploy/nginx/ddc.conf b/deploy/nginx/ddc.conf new file mode 100755 index 0000000..b0112e3 --- /dev/null +++ b/deploy/nginx/ddc.conf @@ -0,0 +1,28 @@ +server { + listen 80; + listen [::]:80; + server_name downloads.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name downloads.example.com; + + ssl_certificate /etc/letsencrypt/live/downloads.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/downloads.example.com/privkey.pem; + + client_max_body_size 64k; + add_header Strict-Transport-Security "max-age=31536000" always; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..54e41be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + ddc: + build: . + image: ddc:latest + container_name: ddc + restart: unless-stopped + user: "${PUID:-1000}:${PGID:-1000}" + env_file: .env + ports: + - "127.0.0.1:8080:8080" + volumes: + - "${DOWNLOAD_HOST_DIR:-./data/downloads}:/data/downloads" + - "/media/blob/TeaNet/Plex:/media/blob/TeaNet/Plex" + - "/media/blob/ChaiNet/Plex:/media/blob/ChaiNet/Plex" + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + mem_limit: 512m + pids_limit: 128 diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..289206a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ddc + +go 1.24 diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go new file mode 100755 index 0000000..594028d --- /dev/null +++ b/internal/auth/credentials.go @@ -0,0 +1,18 @@ +package auth + +import ( + "crypto/sha256" + "crypto/subtle" +) + +func CredentialsMatch(expectedUsername, expectedPassword, username, password string) bool { + usernameMatches := constantTimeEqual(expectedUsername, username) + passwordMatches := constantTimeEqual(expectedPassword, password) + return usernameMatches && passwordMatches +} + +func constantTimeEqual(expected, actual string) bool { + expectedHash := sha256.Sum256([]byte(expected)) + actualHash := sha256.Sum256([]byte(actual)) + return subtle.ConstantTimeCompare(expectedHash[:], actualHash[:]) == 1 +} diff --git a/internal/auth/limiter.go b/internal/auth/limiter.go new file mode 100755 index 0000000..d9f54fc --- /dev/null +++ b/internal/auth/limiter.go @@ -0,0 +1,76 @@ +package auth + +import ( + "sync" + "time" +) + +const maxTrackedClients = 10000 + +type attemptWindow struct { + failures int + startedAt time.Time +} + +type LoginLimiter struct { + mutex sync.Mutex + maxFailures int + window time.Duration + attempts map[string]*attemptWindow +} + +func NewLoginLimiter(maxFailures int, window time.Duration) *LoginLimiter { + return &LoginLimiter{ + maxFailures: maxFailures, + window: window, + attempts: make(map[string]*attemptWindow), + } +} + +func (limiter *LoginLimiter) Allowed(client string) bool { + limiter.mutex.Lock() + defer limiter.mutex.Unlock() + attempt := limiter.current(client) + return attempt == nil || attempt.failures < limiter.maxFailures +} + +func (limiter *LoginLimiter) RecordFailure(client string) { + limiter.mutex.Lock() + defer limiter.mutex.Unlock() + + attempt := limiter.current(client) + if attempt == nil { + if len(limiter.attempts) >= maxTrackedClients { + limiter.removeExpired() + } + attempt = &attemptWindow{startedAt: time.Now()} + limiter.attempts[client] = attempt + } + attempt.failures++ +} + +func (limiter *LoginLimiter) Reset(client string) { + limiter.mutex.Lock() + defer limiter.mutex.Unlock() + delete(limiter.attempts, client) +} + +func (limiter *LoginLimiter) current(client string) *attemptWindow { + attempt, found := limiter.attempts[client] + if !found { + return nil + } + if time.Since(attempt.startedAt) > limiter.window { + delete(limiter.attempts, client) + return nil + } + return attempt +} + +func (limiter *LoginLimiter) removeExpired() { + for client, attempt := range limiter.attempts { + if time.Since(attempt.startedAt) > limiter.window { + delete(limiter.attempts, client) + } + } +} diff --git a/internal/auth/sessions.go b/internal/auth/sessions.go new file mode 100755 index 0000000..c990d07 --- /dev/null +++ b/internal/auth/sessions.go @@ -0,0 +1,75 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "sync" + "time" +) + +const tokenBytes = 32 + +type SessionStore struct { + mutex sync.Mutex + ttl time.Duration + expirations map[string]time.Time +} + +func NewSessionStore(ttl time.Duration) *SessionStore { + return &SessionStore{ttl: ttl, expirations: make(map[string]time.Time)} +} + +func (store *SessionStore) Create() (string, error) { + buffer := make([]byte, tokenBytes) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + token := base64.RawURLEncoding.EncodeToString(buffer) + + store.mutex.Lock() + defer store.mutex.Unlock() + now := time.Now() + store.removeExpired(now) + store.expirations[hashToken(token)] = now.Add(store.ttl) + return token, nil +} + +func (store *SessionStore) Valid(token string) bool { + if token == "" { + return false + } + key := hashToken(token) + + store.mutex.Lock() + defer store.mutex.Unlock() + expiresAt, found := store.expirations[key] + if !found { + return false + } + if time.Now().After(expiresAt) { + delete(store.expirations, key) + return false + } + return true +} + +func (store *SessionStore) Delete(token string) { + store.mutex.Lock() + defer store.mutex.Unlock() + delete(store.expirations, hashToken(token)) +} + +func (store *SessionStore) removeExpired(now time.Time) { + for key, expiresAt := range store.expirations { + if now.After(expiresAt) { + delete(store.expirations, key) + } + } +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100755 index 0000000..8465c84 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,73 @@ +package config + +import ( + "errors" + "fmt" + "path/filepath" + "time" + + "ddc/internal/preferences" +) + +const ( + minPasswordLength = 12 + defaultLibraryRoots = "/media/blob/TeaNet/Plex;/media/blob/ChaiNet/Plex" +) + +type Settings struct { + ListenAddr string + Username string + Password string + CookieSecure bool + SessionTTL time.Duration + TrustProxyHeaders bool + DownloadDir string + LibraryRoots []string + Defaults preferences.Preferences +} + +func Load() (Settings, error) { + reader := &envReader{} + settings := Settings{ + ListenAddr: reader.text("LISTEN_ADDR", ":8080"), + Username: reader.text("AUTH_USERNAME", ""), + Password: reader.text("AUTH_PASSWORD", ""), + CookieSecure: reader.boolean("COOKIE_SECURE", true), + SessionTTL: time.Duration(reader.integer("SESSION_TTL_HOURS", 12)) * time.Hour, + TrustProxyHeaders: reader.boolean("TRUST_PROXY_HEADERS", false), + DownloadDir: filepath.Clean(reader.text("DOWNLOAD_DIR", "/data/downloads")), + Defaults: preferences.Preferences{ + MaxConcurrentDownloads: reader.integer("MAX_CONCURRENT_DOWNLOADS", 2), + MinFreeGB: reader.integer("MIN_FREE_GB", 10), + AllowedPorts: reader.ports("ALLOWED_PORTS", "80,443"), + AutoExtract: true, + CleanupAfterMove: true, + }, + } + rawRoots := reader.text("LIBRARY_ROOTS", defaultLibraryRoots) + if reader.err != nil { + return Settings{}, reader.err + } + + roots, err := parseLibraryRoots(rawRoots) + if err != nil { + return Settings{}, err + } + settings.LibraryRoots = roots + + return settings, settings.validate() +} + +func (settings Settings) validate() error { + switch { + case settings.Username == "": + return errors.New("AUTH_USERNAME is required") + case len(settings.Password) < minPasswordLength: + return fmt.Errorf("AUTH_PASSWORD must be at least %d characters", minPasswordLength) + case !filepath.IsAbs(settings.DownloadDir): + return errors.New("DOWNLOAD_DIR must be an absolute path") + case settings.SessionTTL <= 0: + return errors.New("SESSION_TTL_HOURS must be greater than zero") + } + return settings.Defaults.Validate() +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100755 index 0000000..42aa46b --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,78 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type envReader struct { + err error +} + +func (reader *envReader) fail(name string, err error) { + if reader.err == nil { + reader.err = fmt.Errorf("%s: %w", name, err) + } +} + +func (reader *envReader) text(name, fallback string) string { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + return value +} + +func (reader *envReader) boolean(name string, fallback bool) bool { + raw := reader.text(name, "") + if raw == "" { + return fallback + } + value, err := strconv.ParseBool(raw) + if err != nil { + reader.fail(name, err) + return fallback + } + return value +} + +func (reader *envReader) integer(name string, fallback int) int { + raw := reader.text(name, "") + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil { + reader.fail(name, err) + return fallback + } + return value +} + +func (reader *envReader) ports(name, fallback string) []uint16 { + raw := reader.text(name, fallback) + if raw == "*" { + return nil + } + + var ports []uint16 + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + value, err := strconv.ParseUint(part, 10, 16) + if err != nil || value == 0 { + reader.fail(name, fmt.Errorf("invalid port %q", part)) + return nil + } + ports = append(ports, uint16(value)) + } + + if len(ports) == 0 { + reader.fail(name, fmt.Errorf("no ports listed, use * to allow any port")) + } + return ports +} diff --git a/internal/config/libraries.go b/internal/config/libraries.go new file mode 100755 index 0000000..a9e8778 --- /dev/null +++ b/internal/config/libraries.go @@ -0,0 +1,56 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func parseLibraryRoots(raw string) ([]string, error) { + var roots []string + seenRoots := make(map[string]bool) + + for _, entry := range strings.Split(raw, ";") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + root, err := resolveRoot(entry) + if err != nil { + return nil, err + } + if seenRoots[root] { + continue + } + seenRoots[root] = true + roots = append(roots, root) + } + + if len(roots) == 0 { + return nil, errors.New("LIBRARY_ROOTS must list at least one folder") + } + return roots, nil +} + +func resolveRoot(entry string) (string, error) { + cleaned := filepath.Clean(entry) + if !filepath.IsAbs(cleaned) || cleaned == "/" { + return "", fmt.Errorf("LIBRARY_ROOTS: invalid path %q", entry) + } + + resolved, err := filepath.EvalSymlinks(cleaned) + if err != nil { + return "", fmt.Errorf("LIBRARY_ROOTS: %w", err) + } + info, err := os.Stat(resolved) + if err != nil { + return "", fmt.Errorf("LIBRARY_ROOTS: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("LIBRARY_ROOTS: %q is not a directory", entry) + } + return resolved, nil +} diff --git a/internal/diskspace/diskspace.go b/internal/diskspace/diskspace.go new file mode 100755 index 0000000..94537ce --- /dev/null +++ b/internal/diskspace/diskspace.go @@ -0,0 +1,38 @@ +package diskspace + +import ( + "errors" + "fmt" + "math" + "syscall" +) + +var ErrInsufficientSpace = errors.New("not enough free disk space") + +func Available(path string) (int64, error) { + var stats syscall.Statfs_t + if err := syscall.Statfs(path, &stats); err != nil { + return 0, err + } + available := stats.Bavail * uint64(stats.Bsize) + if available > math.MaxInt64 { + return math.MaxInt64, nil + } + return int64(available), nil +} + +func EnsureFree(path string, requiredBytes, reservedBytes int64) error { + available, err := Available(path) + if err != nil { + return err + } + if available-reservedBytes < requiredBytes { + return fmt.Errorf("%w: %.1f GiB needed, %.1f GiB usable after keeping %.1f GiB free", + ErrInsufficientSpace, toGiB(requiredBytes), toGiB(available-reservedBytes), toGiB(reservedBytes)) + } + return nil +} + +func toGiB(bytes int64) float64 { + return float64(bytes) / float64(1<<30) +} diff --git a/internal/extract/extract.go b/internal/extract/extract.go new file mode 100755 index 0000000..d80dff8 --- /dev/null +++ b/internal/extract/extract.go @@ -0,0 +1,48 @@ +package extract + +import ( + "context" + "errors" + "os" + "path/filepath" +) + +var ( + ErrUnsupportedFormat = errors.New("unsupported archive format") + ErrUnsafeEntry = errors.New("archive contains an unsafe path") + ErrTooLarge = errors.New("archive exceeds the extraction size limit") + ErrTooManyEntries = errors.New("archive contains too many entries") +) + +type Limits struct { + MaxBytes int64 + MaxEntries int +} + +type Progress interface { + SetTotal(total int64) + Add(delta int64) +} + +func Archive(ctx context.Context, archivePath, destination string, limits Limits, progress Progress) error { + archiveFormat, _ := detect(filepath.Base(archivePath)) + if archiveFormat == formatNone { + return ErrUnsupportedFormat + } + if err := os.Mkdir(destination, 0o755); err != nil { + return err + } + + writer := &entryWriter{ctx: ctx, root: destination, limits: limits, progress: progress} + var err error + if archiveFormat == formatZip { + err = extractZip(archivePath, writer) + } else { + err = extractTar(archivePath, archiveFormat, writer) + } + + if err != nil { + os.RemoveAll(destination) + } + return err +} diff --git a/internal/extract/formats.go b/internal/extract/formats.go new file mode 100755 index 0000000..1dec0dc --- /dev/null +++ b/internal/extract/formats.go @@ -0,0 +1,50 @@ +package extract + +import "strings" + +type format int + +const ( + formatNone format = iota + formatZip + formatTar + formatTarGzip + formatTarBzip2 +) + +var knownSuffixes = []struct { + suffix string + format format +}{ + {".tar.gz", formatTarGzip}, + {".tgz", formatTarGzip}, + {".tar.bz2", formatTarBzip2}, + {".tbz2", formatTarBzip2}, + {".tbz", formatTarBzip2}, + {".tar", formatTar}, + {".zip", formatZip}, +} + +func detect(name string) (format, string) { + lowerName := strings.ToLower(name) + for _, known := range knownSuffixes { + if strings.HasSuffix(lowerName, known.suffix) { + return known.format, known.suffix + } + } + return formatNone, "" +} + +func IsArchive(name string) bool { + archiveFormat, _ := detect(name) + return archiveFormat != formatNone +} + +func DirectoryName(archiveName string) string { + _, suffix := detect(archiveName) + base := strings.Trim(archiveName[:len(archiveName)-len(suffix)], ". ") + if base == "" { + return "extracted" + } + return base +} diff --git a/internal/extract/tar.go b/internal/extract/tar.go new file mode 100755 index 0000000..bc4a0c5 --- /dev/null +++ b/internal/extract/tar.go @@ -0,0 +1,61 @@ +package extract + +import ( + "archive/tar" + "compress/bzip2" + "compress/gzip" + "errors" + "io" + "os" +) + +func extractTar(archivePath string, archiveFormat format, writer *entryWriter) error { + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + if archiveFormat == formatTar { + if info, err := file.Stat(); err == nil { + writer.progress.SetTotal(info.Size()) + } + } + + source, err := decompress(file, archiveFormat) + if err != nil { + return err + } + + reader := tar.NewReader(source) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + + switch header.Typeflag { + case tar.TypeDir: + err = writer.writeDirectory(header.Name) + case tar.TypeReg: + err = writer.writeFile(header.Name, reader) + } + if err != nil { + return err + } + } +} + +func decompress(file *os.File, archiveFormat format) (io.Reader, error) { + switch archiveFormat { + case formatTarGzip: + return gzip.NewReader(file) + case formatTarBzip2: + return bzip2.NewReader(file), nil + default: + return file, nil + } +} diff --git a/internal/extract/writer.go b/internal/extract/writer.go new file mode 100755 index 0000000..49919f5 --- /dev/null +++ b/internal/extract/writer.go @@ -0,0 +1,106 @@ +package extract + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type entryWriter struct { + ctx context.Context + root string + limits Limits + progress Progress + written int64 + entries int +} + +func (writer *entryWriter) writeDirectory(name string) error { + target, err := writer.prepare(name) + if err != nil || target == "" { + return err + } + return os.MkdirAll(target, 0o755) +} + +func (writer *entryWriter) writeFile(name string, content io.Reader) error { + target, err := writer.prepare(name) + if err != nil || target == "" { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + + remaining := writer.limits.MaxBytes - writer.written + source := &progressReader{ctx: writer.ctx, reader: io.LimitReader(content, remaining+1), progress: writer.progress} + copied, copyErr := io.Copy(file, source) + closeErr := file.Close() + writer.written += copied + + if copyErr != nil { + return copyErr + } + if writer.written > writer.limits.MaxBytes { + return ErrTooLarge + } + return closeErr +} + +func (writer *entryWriter) prepare(name string) (string, error) { + writer.entries++ + if writer.entries > writer.limits.MaxEntries { + return "", ErrTooManyEntries + } + if err := writer.ctx.Err(); err != nil { + return "", err + } + return writer.resolve(name) +} + +func (writer *entryWriter) resolve(name string) (string, error) { + normalized := strings.ReplaceAll(name, "\\", "/") + if strings.HasPrefix(normalized, "/") { + return "", fmt.Errorf("%w: %s", ErrUnsafeEntry, name) + } + + cleaned := filepath.Clean(filepath.FromSlash(normalized)) + if cleaned == "." { + return "", nil + } + if !filepath.IsLocal(cleaned) { + return "", fmt.Errorf("%w: %s", ErrUnsafeEntry, name) + } + + target := filepath.Join(writer.root, cleaned) + relative, err := filepath.Rel(writer.root, target) + if err != nil || !filepath.IsLocal(relative) { + return "", fmt.Errorf("%w: %s", ErrUnsafeEntry, name) + } + return target, nil +} + +type progressReader struct { + ctx context.Context + reader io.Reader + progress Progress +} + +func (reader *progressReader) Read(buffer []byte) (int, error) { + if err := reader.ctx.Err(); err != nil { + return 0, err + } + count, err := reader.reader.Read(buffer) + if count > 0 { + reader.progress.Add(int64(count)) + } + return count, err +} diff --git a/internal/extract/zip.go b/internal/extract/zip.go new file mode 100755 index 0000000..47bd367 --- /dev/null +++ b/internal/extract/zip.go @@ -0,0 +1,44 @@ +package extract + +import "archive/zip" + +func extractZip(archivePath string, writer *entryWriter) error { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer reader.Close() + + var declaredBytes uint64 + for _, file := range reader.File { + declaredBytes += file.UncompressedSize64 + } + if declaredBytes > uint64(writer.limits.MaxBytes) { + return ErrTooLarge + } + writer.progress.SetTotal(int64(declaredBytes)) + + for _, file := range reader.File { + if err := extractZipEntry(file, writer); err != nil { + return err + } + } + return nil +} + +func extractZipEntry(file *zip.File, writer *entryWriter) error { + mode := file.Mode() + switch { + case mode.IsDir(): + return writer.writeDirectory(file.Name) + case mode.IsRegular(): + content, err := file.Open() + if err != nil { + return err + } + defer content.Close() + return writer.writeFile(file.Name, content) + default: + return nil + } +} diff --git a/internal/fetch/downloader.go b/internal/fetch/downloader.go new file mode 100755 index 0000000..b068e1b --- /dev/null +++ b/internal/fetch/downloader.go @@ -0,0 +1,152 @@ +package fetch + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "ddc/internal/diskspace" + "ddc/internal/files" + "ddc/internal/guard" +) + +const ( + userAgent = "ddc/1.0" + bufferSize = 1 << 20 + stallTimeout = 2 * time.Minute + spaceCheckInterval = int64(256) << 20 +) + +var ( + ErrIncomplete = errors.New("download ended before the full file was received") + ErrStalled = errors.New("download stalled, no data received for too long") +) + +type Progress interface { + SetTotal(total int64) + Add(delta int64) + SetFileName(name string) +} + +type Downloader struct { + policy *guard.Policy + client *http.Client +} + +func NewDownloader(policy *guard.Policy) *Downloader { + return &Downloader{policy: policy, client: policy.NewHTTPClient()} +} + +func (downloader *Downloader) Download(ctx context.Context, rawURL, directory string, reservedBytes int64, progress Progress) (string, error) { + target, err := downloader.policy.ValidateURL(rawURL) + if err != nil { + return "", err + } + + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + response, err := downloader.open(ctx, target.String()) + if err != nil { + return "", err + } + defer response.Body.Close() + + fileName := FileNameFromResponse(response) + progress.SetFileName(fileName) + if err := diskspace.EnsureFree(directory, max(response.ContentLength, 0), reservedBytes); err != nil { + return "", err + } + progress.SetTotal(max(response.ContentLength, 0)) + + partPath := filepath.Join(directory, files.PartFileName) + written, err := writePart(cancel, partPath, response.Body, progress, directory, reservedBytes) + if err != nil { + os.Remove(partPath) + if errors.Is(context.Cause(ctx), ErrStalled) { + return "", ErrStalled + } + return "", err + } + if response.ContentLength > 0 && written != response.ContentLength { + os.Remove(partPath) + return "", ErrIncomplete + } + + finalPath := files.UniquePath(filepath.Join(directory, fileName)) + if err := os.Rename(partPath, finalPath); err != nil { + os.Remove(partPath) + return "", err + } + return finalPath, nil +} + +func (downloader *Downloader) open(ctx context.Context, target string) (*http.Response, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", userAgent) + request.Header.Set("Accept-Encoding", "identity") + + response, err := downloader.client.Do(request) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + return nil, fmt.Errorf("server responded with %s", response.Status) + } + return response, nil +} + +func writePart(cancel context.CancelCauseFunc, partPath string, body io.Reader, progress Progress, directory string, reservedBytes int64) (int64, error) { + file, err := os.OpenFile(partPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return 0, err + } + written, copyErr := copyBody(cancel, file, body, progress, directory, reservedBytes) + closeErr := file.Close() + if copyErr != nil { + return written, copyErr + } + return written, closeErr +} + +func copyBody(cancel context.CancelCauseFunc, destination io.Writer, body io.Reader, progress Progress, directory string, reservedBytes int64) (int64, error) { + stallTimer := time.AfterFunc(stallTimeout, func() { cancel(ErrStalled) }) + defer stallTimer.Stop() + + buffer := make([]byte, bufferSize) + var written, sinceSpaceCheck int64 + for { + count, readErr := body.Read(buffer) + if count > 0 { + stallTimer.Reset(stallTimeout) + if _, err := destination.Write(buffer[:count]); err != nil { + return written, err + } + written += int64(count) + sinceSpaceCheck += int64(count) + progress.Add(int64(count)) + + if sinceSpaceCheck >= spaceCheckInterval { + sinceSpaceCheck = 0 + if err := diskspace.EnsureFree(directory, 0, reservedBytes); err != nil { + return written, err + } + } + } + if errors.Is(readErr, io.EOF) { + return written, nil + } + if readErr != nil { + return written, readErr + } + } +} diff --git a/internal/fetch/filename.go b/internal/fetch/filename.go new file mode 100755 index 0000000..0201543 --- /dev/null +++ b/internal/fetch/filename.go @@ -0,0 +1,65 @@ +package fetch + +import ( + "mime" + "net/http" + "path" + "path/filepath" + "strings" + "unicode/utf8" +) + +const ( + maxFileNameBytes = 200 + maxExtensionBytes = 16 + fallbackFileName = "download" +) + +func FileNameFromResponse(response *http.Response) string { + if disposition := response.Header.Get("Content-Disposition"); disposition != "" { + if _, parameters, err := mime.ParseMediaType(disposition); err == nil { + if name := SanitizeFileName(parameters["filename"]); name != "" { + return name + } + } + } + if response.Request != nil && response.Request.URL != nil { + if name := SanitizeFileName(path.Base(response.Request.URL.Path)); name != "" { + return name + } + } + return fallbackFileName +} + +func SanitizeFileName(raw string) string { + name := path.Base(strings.ReplaceAll(raw, "\\", "/")) + name = strings.Map(dropUnsafeRune, name) + name = strings.TrimSpace(strings.TrimLeft(name, ". ")) + if name == "" { + return "" + } + return truncate(name) +} + +func dropUnsafeRune(character rune) rune { + if character < 0x20 || character == 0x7f || character == '/' || character == utf8.RuneError { + return -1 + } + return character +} + +func truncate(name string) string { + if len(name) <= maxFileNameBytes { + return name + } + extension := filepath.Ext(name) + if len(extension) > maxExtensionBytes { + extension = "" + } + base := strings.TrimSuffix(name, extension) + for len(base)+len(extension) > maxFileNameBytes { + _, size := utf8.DecodeLastRuneInString(base) + base = base[:len(base)-size] + } + return base + extension +} diff --git a/internal/files/move.go b/internal/files/move.go new file mode 100755 index 0000000..e110212 --- /dev/null +++ b/internal/files/move.go @@ -0,0 +1,101 @@ +package files + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "syscall" + + "ddc/internal/diskspace" +) + +var ErrDestinationExists = errors.New("an item with this name already exists in the destination") + +func MoveInto(source, destinationDir string, reservedBytes int64, progress func(int64)) error { + name := filepath.Base(source) + target := filepath.Join(destinationDir, name) + if exists(target) { + return fmt.Errorf("%w: %s", ErrDestinationExists, name) + } + + size, err := TreeSize(source) + if err != nil { + return err + } + + renameErr := os.Rename(source, target) + if renameErr == nil { + progress(size) + return nil + } + if !errors.Is(renameErr, syscall.EXDEV) { + return renameErr + } + + if err := diskspace.EnsureFree(destinationDir, size, reservedBytes); err != nil { + return err + } + if err := copyTree(source, target, progress); err != nil { + os.RemoveAll(target) + return err + } + return os.RemoveAll(source) +} + +func copyTree(source, target string, progress func(int64)) error { + return filepath.WalkDir(source, func(current string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, current) + if err != nil { + return err + } + destination := filepath.Join(target, relative) + + switch { + case entry.IsDir(): + return os.MkdirAll(destination, 0o755) + case entry.Type().IsRegular(): + return copyFile(current, destination, progress) + default: + return nil + } + }) +} + +func copyFile(source, destination string, progress func(int64)) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + + _, copyErr := io.Copy(output, &progressReader{reader: input, progress: progress}) + closeErr := output.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +type progressReader struct { + reader io.Reader + progress func(int64) +} + +func (reader *progressReader) Read(buffer []byte) (int, error) { + count, err := reader.reader.Read(buffer) + if count > 0 { + reader.progress(int64(count)) + } + return count, err +} diff --git a/internal/files/paths.go b/internal/files/paths.go new file mode 100755 index 0000000..2be8fee --- /dev/null +++ b/internal/files/paths.go @@ -0,0 +1,99 @@ +package files + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const ( + PartFileName = ".download.part" + maxSubfolderDepth = 4 + maxNameBytes = 255 +) + +var ErrUnsafePath = errors.New("path is not allowed") + +func ResolveInside(root, relative string) (string, error) { + cleaned := filepath.Clean(filepath.FromSlash(relative)) + if relative == "" || cleaned == "." || !filepath.IsLocal(cleaned) { + return "", ErrUnsafePath + } + + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + + candidate := filepath.Join(resolvedRoot, cleaned) + info, err := os.Lstat(candidate) + if err != nil { + return "", err + } + if info.Mode()&fs.ModeSymlink != 0 { + return "", ErrUnsafePath + } + + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return "", err + } + if !IsWithin(resolvedRoot, resolved) { + return "", ErrUnsafePath + } + return resolved, nil +} + +func IsWithin(root, target string) bool { + relative, err := filepath.Rel(root, target) + return err == nil && relative != "." && filepath.IsLocal(relative) +} + +func SanitizeSubfolder(raw string) (string, error) { + trimmed := strings.Trim(strings.TrimSpace(raw), "/") + if trimmed == "" { + return "", nil + } + + parts := strings.Split(trimmed, "/") + if len(parts) > maxSubfolderDepth { + return "", ErrUnsafePath + } + for index, part := range parts { + part = strings.TrimSpace(part) + if part == "" || part == "." || part == ".." || len(part) > maxNameBytes || strings.ContainsFunc(part, isUnsafeRune) { + return "", ErrUnsafePath + } + parts[index] = part + } + return filepath.Join(parts...), nil +} + +func EnsureSubdirectory(root, subfolder string) (string, error) { + current := root + if subfolder == "" { + return current, nil + } + + for _, part := range strings.Split(subfolder, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + switch { + case errors.Is(err, fs.ErrNotExist): + if err := os.Mkdir(current, 0o755); err != nil && !errors.Is(err, fs.ErrExist) { + return "", err + } + case err != nil: + return "", err + case !info.IsDir(): + return "", ErrUnsafePath + } + } + return current, nil +} + +func isUnsafeRune(character rune) bool { + return character < 0x20 || character == 0x7f || character == '\\' +} diff --git a/internal/files/size.go b/internal/files/size.go new file mode 100755 index 0000000..22eff62 --- /dev/null +++ b/internal/files/size.go @@ -0,0 +1,52 @@ +package files + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +func TreeSize(root string) (int64, error) { + var total int64 + err := filepath.WalkDir(root, func(_ string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.Type().IsRegular() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + total += info.Size() + return nil + }) + return total, err +} + +func UniquePath(candidate string) string { + if !exists(candidate) { + return candidate + } + + directory, name := filepath.Split(candidate) + extension := filepath.Ext(name) + base := strings.TrimSuffix(name, extension) + for index := 1; index < 1000; index++ { + next := filepath.Join(directory, fmt.Sprintf("%s (%d)%s", base, index, extension)) + if !exists(next) { + return next + } + } + return filepath.Join(directory, fmt.Sprintf("%s-%d%s", base, time.Now().UnixNano(), extension)) +} + +func exists(path string) bool { + _, err := os.Lstat(path) + return !errors.Is(err, fs.ErrNotExist) +} diff --git a/internal/files/tree.go b/internal/files/tree.go new file mode 100755 index 0000000..6fb753d --- /dev/null +++ b/internal/files/tree.go @@ -0,0 +1,85 @@ +package files + +import ( + "os" + "path" + "path/filepath" + "sort" + "strings" + + "ddc/internal/extract" +) + +const ( + maxTreeEntries = 20000 + maxTreeDepth = 32 +) + +type Node struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"isDir"` + IsArchive bool `json:"isArchive"` + Size int64 `json:"size"` + Children []Node `json:"children,omitempty"` +} + +func BuildTree(root string) ([]Node, error) { + entryCount := 0 + return readLevel(root, "", 0, &entryCount) +} + +func readLevel(root, relative string, depth int, entryCount *int) ([]Node, error) { + entries, err := os.ReadDir(filepath.Join(root, relative)) + if err != nil { + return nil, err + } + + nodes := make([]Node, 0, len(entries)) + for _, entry := range entries { + if *entryCount >= maxTreeEntries { + break + } + entryType := entry.Type() + if entry.Name() == PartFileName || (!entryType.IsDir() && !entryType.IsRegular()) { + continue + } + *entryCount++ + + node := Node{ + Name: entry.Name(), + Path: path.Join(filepath.ToSlash(relative), entry.Name()), + IsDir: entry.IsDir(), + } + + if node.IsDir { + if depth < maxTreeDepth { + children, err := readLevel(root, filepath.Join(relative, entry.Name()), depth+1, entryCount) + if err != nil { + return nil, err + } + node.Children = children + for _, child := range children { + node.Size += child.Size + } + } + } else { + info, err := entry.Info() + if err != nil { + continue + } + node.Size = info.Size() + node.IsArchive = extract.IsArchive(entry.Name()) + } + + nodes = append(nodes, node) + } + + sort.SliceStable(nodes, func(first, second int) bool { + if nodes[first].IsDir != nodes[second].IsDir { + return nodes[first].IsDir + } + return strings.ToLower(nodes[first].Name) < strings.ToLower(nodes[second].Name) + }) + return nodes, nil +} diff --git a/internal/guard/addresses.go b/internal/guard/addresses.go new file mode 100755 index 0000000..6a56e22 --- /dev/null +++ b/internal/guard/addresses.go @@ -0,0 +1,54 @@ +package guard + +import "net/netip" + +var blockedPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("224.0.0.0/4"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("::/128"), + netip.MustParsePrefix("::1/128"), + netip.MustParsePrefix("::ffff:0:0/96"), + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001::/32"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("fec0::/10"), + netip.MustParsePrefix("ff00::/8"), +} + +func IsPublicAddress(address netip.Addr) bool { + address = address.Unmap().WithZone("") + if !address.IsValid() || + !address.IsGlobalUnicast() || + address.IsPrivate() || + address.IsLoopback() || + address.IsLinkLocalUnicast() || + address.IsMulticast() || + address.IsUnspecified() { + return false + } + + for _, prefix := range blockedPrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} diff --git a/internal/guard/client.go b/internal/guard/client.go new file mode 100755 index 0000000..982553a --- /dev/null +++ b/internal/guard/client.go @@ -0,0 +1,55 @@ +package guard + +import ( + "net" + "net/http" + "net/netip" + "syscall" + "time" +) + +func (policy *Policy) NewHTTPClient() *http.Client { + dialer := &net.Dialer{ + Timeout: 15 * time.Second, + KeepAlive: 30 * time.Second, + Control: policy.controlConnection, + } + + transport := &http.Transport{ + Proxy: nil, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 15 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + ExpectContinueTimeout: time.Second, + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + } + + return &http.Client{ + Transport: transport, + CheckRedirect: policy.checkRedirect, + } +} + +func (policy *Policy) controlConnection(_ string, address string, _ syscall.RawConn) error { + addressPort, err := netip.ParseAddrPort(address) + if err != nil { + return ErrBlockedAddress + } + if !IsPublicAddress(addressPort.Addr()) { + return ErrBlockedAddress + } + if !policy.portAllowed(addressPort.Port()) { + return ErrBlockedPort + } + return nil +} + +func (policy *Policy) checkRedirect(request *http.Request, previous []*http.Request) error { + if len(previous) >= maxRedirects { + return ErrTooManyRedirects + } + _, err := policy.ValidateURL(request.URL.String()) + return err +} diff --git a/internal/guard/policy.go b/internal/guard/policy.go new file mode 100755 index 0000000..fe66762 --- /dev/null +++ b/internal/guard/policy.go @@ -0,0 +1,98 @@ +package guard + +import ( + "errors" + "fmt" + "net/netip" + "net/url" + "strconv" + "strings" + "sync" +) + +const ( + maxURLLength = 4096 + maxRedirects = 5 +) + +var ( + ErrURLTooLong = errors.New("URL is too long") + ErrUnsupportedScheme = errors.New("only http and https URLs are allowed") + ErrMissingHost = errors.New("URL must include a host") + ErrBlockedAddress = errors.New("destination address is not allowed") + ErrBlockedPort = errors.New("destination port is not allowed") + ErrTooManyRedirects = errors.New("too many redirects") +) + +type Policy struct { + mutex sync.RWMutex + allowedPorts map[uint16]bool +} + +func NewPolicy(allowedPorts []uint16) *Policy { + policy := &Policy{} + policy.SetAllowedPorts(allowedPorts) + return policy +} + +func (policy *Policy) SetAllowedPorts(allowedPorts []uint16) { + ports := make(map[uint16]bool, len(allowedPorts)) + for _, port := range allowedPorts { + ports[port] = true + } + policy.mutex.Lock() + defer policy.mutex.Unlock() + policy.allowedPorts = ports +} + +func (policy *Policy) ValidateURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if len(raw) > maxURLLength { + return nil, ErrURLTooLong + } + + parsed, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, ErrUnsupportedScheme + } + + host := parsed.Hostname() + if host == "" { + return nil, ErrMissingHost + } + if address, err := netip.ParseAddr(host); err == nil && !IsPublicAddress(address) { + return nil, ErrBlockedAddress + } + + port, err := portOf(parsed) + if err != nil { + return nil, err + } + if !policy.portAllowed(port) { + return nil, ErrBlockedPort + } + return parsed, nil +} + +func (policy *Policy) portAllowed(port uint16) bool { + policy.mutex.RLock() + defer policy.mutex.RUnlock() + return len(policy.allowedPorts) == 0 || policy.allowedPorts[port] +} + +func portOf(parsed *url.URL) (uint16, error) { + if raw := parsed.Port(); raw != "" { + value, err := strconv.ParseUint(raw, 10, 16) + if err != nil { + return 0, fmt.Errorf("invalid port: %w", err) + } + return uint16(value), nil + } + if parsed.Scheme == "https" { + return 443, nil + } + return 80, nil +} diff --git a/internal/jobs/job.go b/internal/jobs/job.go new file mode 100755 index 0000000..565e76e --- /dev/null +++ b/internal/jobs/job.go @@ -0,0 +1,126 @@ +package jobs + +import ( + "context" + "sync" + "sync/atomic" + "time" +) + +type Status string + +const ( + StatusQueued Status = "queued" + StatusDownloading Status = "downloading" + StatusExtracting Status = "extracting" + StatusReady Status = "ready" + StatusMoving Status = "moving" + StatusCompleted Status = "completed" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" +) + +type Snapshot struct { + ID string `json:"id"` + SourceURL string `json:"sourceUrl"` + FileName string `json:"fileName"` + Status Status `json:"status"` + Message string `json:"message"` + BytesDone int64 `json:"bytesDone"` + BytesTotal int64 `json:"bytesTotal"` + CreatedAt time.Time `json:"createdAt"` +} + +type Job struct { + id string + sourceURL string + displayURL string + directory string + autoExtract bool + createdAt time.Time + jobContext context.Context + cancel context.CancelFunc + workers sync.WaitGroup + + mutex sync.Mutex + status Status + message string + fileName string + deleted bool + + bytesDone atomic.Int64 + bytesTotal atomic.Int64 +} + +func (job *Job) SetTotal(total int64) { + job.bytesTotal.Store(total) +} + +func (job *Job) Add(delta int64) { + job.bytesDone.Add(delta) +} + +func (job *Job) SetFileName(name string) { + job.mutex.Lock() + defer job.mutex.Unlock() + job.fileName = name +} + +func (job *Job) resetProgress() { + job.bytesDone.Store(0) + job.bytesTotal.Store(0) +} + +func (job *Job) setStatus(status Status, message string) { + job.mutex.Lock() + defer job.mutex.Unlock() + if job.deleted { + return + } + job.status = status + job.message = message +} + +func (job *Job) currentStatus() Status { + job.mutex.Lock() + defer job.mutex.Unlock() + return job.status +} + +func (job *Job) begin(from, to Status) bool { + job.mutex.Lock() + defer job.mutex.Unlock() + if job.deleted || job.status != from { + return false + } + job.status = to + job.message = "" + job.workers.Add(1) + return true +} + +func (job *Job) markDeleted() bool { + job.mutex.Lock() + defer job.mutex.Unlock() + if job.status == StatusMoving { + return false + } + job.deleted = true + job.status = StatusCancelled + return true +} + +func (job *Job) snapshot() Snapshot { + job.mutex.Lock() + defer job.mutex.Unlock() + return Snapshot{ + ID: job.id, + SourceURL: job.displayURL, + FileName: job.fileName, + Status: job.status, + Message: job.message, + BytesDone: job.bytesDone.Load(), + BytesTotal: job.bytesTotal.Load(), + CreatedAt: job.createdAt, + } +} diff --git a/internal/jobs/manager.go b/internal/jobs/manager.go new file mode 100755 index 0000000..4a42894 --- /dev/null +++ b/internal/jobs/manager.go @@ -0,0 +1,208 @@ +package jobs + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "sync" + "time" + + "ddc/internal/config" + "ddc/internal/diskspace" + "ddc/internal/fetch" + "ddc/internal/files" + "ddc/internal/guard" + "ddc/internal/libraries" + "ddc/internal/preferences" +) + +var ( + ErrJobNotFound = errors.New("download not found") + ErrJobBusy = errors.New("download is busy, try again when it is ready") + ErrInvalidRequest = errors.New("invalid request") +) + +type Manager struct { + settings config.Settings + preferences *preferences.Store + policy *guard.Policy + downloader *fetch.Downloader + rootContext context.Context + cancelAll context.CancelFunc + slots slotLimiter + mutex sync.RWMutex + jobs map[string]*Job +} + +func NewManager(settings config.Settings, store *preferences.Store) *Manager { + policy := guard.NewPolicy(store.Get().AllowedPorts) + rootContext, cancelAll := context.WithCancel(context.Background()) + return &Manager{ + settings: settings, + preferences: store, + policy: policy, + downloader: fetch.NewDownloader(policy), + rootContext: rootContext, + cancelAll: cancelAll, + jobs: make(map[string]*Job), + } +} + +func (manager *Manager) Destinations() []libraries.Destination { + return libraries.Discover(manager.settings.LibraryRoots) +} + +func (manager *Manager) Preferences() preferences.Preferences { + return manager.preferences.Get() +} + +func (manager *Manager) UpdatePreferences(next preferences.Preferences) error { + if err := manager.preferences.Update(next); err != nil { + return err + } + manager.policy.SetAllowedPorts(next.AllowedPorts) + return nil +} + +func (manager *Manager) concurrencyLimit() int { + return manager.preferences.Get().MaxConcurrentDownloads +} + +func (manager *Manager) reservedBytes() int64 { + return manager.preferences.Get().MinFreeBytes() +} + +func (manager *Manager) AvailableBytes() (int64, error) { + return diskspace.Available(manager.settings.DownloadDir) +} + +func (manager *Manager) Create(rawURL string, autoExtract bool) (Snapshot, error) { + target, err := manager.policy.ValidateURL(rawURL) + if err != nil { + return Snapshot{}, invalid(err) + } + if err := diskspace.EnsureFree(manager.settings.DownloadDir, 0, manager.reservedBytes()); err != nil { + return Snapshot{}, err + } + + id, err := newJobID() + if err != nil { + return Snapshot{}, err + } + directory := filepath.Join(manager.settings.DownloadDir, id) + if err := os.Mkdir(directory, 0o755); err != nil { + return Snapshot{}, err + } + + job := manager.newJob(id, directory, time.Now()) + job.sourceURL = target.String() + job.displayURL = target.Redacted() + job.autoExtract = autoExtract + job.status = StatusQueued + job.fileName = initialFileName(target.Path, target.Host) + job.workers.Add(1) + + manager.store(job) + go manager.download(job) + return job.snapshot(), nil +} + +func (manager *Manager) List() []Snapshot { + manager.mutex.RLock() + snapshots := make([]Snapshot, 0, len(manager.jobs)) + for _, job := range manager.jobs { + snapshots = append(snapshots, job.snapshot()) + } + manager.mutex.RUnlock() + + sort.Slice(snapshots, func(first, second int) bool { + return snapshots[first].CreatedAt.After(snapshots[second].CreatedAt) + }) + return snapshots +} + +func (manager *Manager) Files(id string) ([]files.Node, error) { + job, err := manager.lookup(id) + if err != nil { + return nil, err + } + if job.currentStatus() != StatusReady { + return nil, ErrJobBusy + } + return files.BuildTree(job.directory) +} + +func (manager *Manager) Delete(id string) error { + job, err := manager.lookup(id) + if err != nil { + return err + } + if !job.markDeleted() { + return ErrJobBusy + } + + job.cancel() + job.workers.Wait() + + manager.mutex.Lock() + delete(manager.jobs, id) + manager.mutex.Unlock() + + return os.RemoveAll(job.directory) +} + +func (manager *Manager) CancelAll() { + manager.cancelAll() +} + +func (manager *Manager) newJob(id, directory string, createdAt time.Time) *Job { + jobContext, cancel := context.WithCancel(manager.rootContext) + return &Job{ + id: id, + directory: directory, + createdAt: createdAt, + jobContext: jobContext, + cancel: cancel, + } +} + +func (manager *Manager) store(job *Job) { + manager.mutex.Lock() + defer manager.mutex.Unlock() + manager.jobs[job.id] = job +} + +func (manager *Manager) lookup(id string) (*Job, error) { + manager.mutex.RLock() + defer manager.mutex.RUnlock() + job, found := manager.jobs[id] + if !found { + return nil, ErrJobNotFound + } + return job, nil +} + +func newJobID() (string, error) { + buffer := make([]byte, 16) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} + +func initialFileName(urlPath, host string) string { + if name := fetch.SanitizeFileName(path.Base(urlPath)); name != "" { + return name + } + return host +} + +func invalid(err error) error { + return fmt.Errorf("%w: %w", ErrInvalidRequest, err) +} diff --git a/internal/jobs/move.go b/internal/jobs/move.go new file mode 100755 index 0000000..4b2557b --- /dev/null +++ b/internal/jobs/move.go @@ -0,0 +1,125 @@ +package jobs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "ddc/internal/files" + "ddc/internal/libraries" +) + +type MoveRequest struct { + Paths []string `json:"paths"` + Destination string `json:"destination"` + Subfolder string `json:"subfolder"` + Cleanup bool `json:"cleanup"` +} + +func (manager *Manager) Move(id string, request MoveRequest) error { + job, err := manager.lookup(id) + if err != nil { + return err + } + if job.currentStatus() != StatusReady { + return ErrJobBusy + } + if len(request.Paths) == 0 { + return invalid(errors.New("select at least one file or folder")) + } + + library, err := libraries.Resolve(manager.settings.LibraryRoots, request.Destination) + if err != nil { + return invalid(err) + } + subfolder, err := files.SanitizeSubfolder(request.Subfolder) + if err != nil { + return invalid(err) + } + sources, err := resolveSources(job.directory, request.Paths) + if err != nil { + return err + } + destination, err := files.EnsureSubdirectory(library.Path, subfolder) + if err != nil { + return invalid(err) + } + if err := checkTargets(sources, destination); err != nil { + return err + } + + if !job.begin(StatusReady, StatusMoving) { + return ErrJobBusy + } + go func() { + defer job.workers.Done() + manager.moveFiles(job, library, sources, destination, request.Cleanup) + }() + return nil +} + +func resolveSources(root string, relativePaths []string) ([]string, error) { + sources := make([]string, 0, len(relativePaths)) + seenNames := make(map[string]bool) + + for _, relative := range relativePaths { + source, err := files.ResolveInside(root, relative) + if err != nil { + return nil, invalid(err) + } + name := filepath.Base(source) + if seenNames[name] { + return nil, invalid(fmt.Errorf("more than one selected item is named %q", name)) + } + seenNames[name] = true + sources = append(sources, source) + } + + for _, outer := range sources { + for _, inner := range sources { + if files.IsWithin(outer, inner) { + return nil, invalid(errors.New("selection contains a folder and items inside it")) + } + } + } + return sources, nil +} + +func checkTargets(sources []string, destination string) error { + for _, source := range sources { + name := filepath.Base(source) + if _, err := os.Lstat(filepath.Join(destination, name)); err == nil { + return fmt.Errorf("%w: %s", files.ErrDestinationExists, name) + } + } + return nil +} + +func (manager *Manager) moveFiles(job *Job, library libraries.Destination, sources []string, destination string, cleanup bool) { + job.resetProgress() + var totalBytes int64 + for _, source := range sources { + if size, err := files.TreeSize(source); err == nil { + totalBytes += size + } + } + job.SetTotal(totalBytes) + + for _, source := range sources { + if err := files.MoveInto(source, destination, manager.reservedBytes(), job.Add); err != nil { + job.setStatus(StatusReady, "Move failed: "+err.Error()) + return + } + } + + message := fmt.Sprintf("Moved %d item(s) to %s", len(sources), library.Path) + if !cleanup { + job.setStatus(StatusReady, message) + return + } + if err := os.RemoveAll(job.directory); err != nil { + message += ", cleanup failed: " + err.Error() + } + job.setStatus(StatusCompleted, message) +} diff --git a/internal/jobs/restore.go b/internal/jobs/restore.go new file mode 100755 index 0000000..0f4a3e6 --- /dev/null +++ b/internal/jobs/restore.go @@ -0,0 +1,51 @@ +package jobs + +import ( + "os" + "path/filepath" + "regexp" + "time" + + "ddc/internal/files" +) + +var jobIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) + +func (manager *Manager) RestoreExisting() error { + entries, err := os.ReadDir(manager.settings.DownloadDir) + if err != nil { + return err + } + + for _, entry := range entries { + if !entry.IsDir() || !jobIDPattern.MatchString(entry.Name()) { + continue + } + directory := filepath.Join(manager.settings.DownloadDir, entry.Name()) + os.Remove(filepath.Join(directory, files.PartFileName)) + + createdAt := time.Now() + if info, err := entry.Info(); err == nil { + createdAt = info.ModTime() + } + + job := manager.newJob(entry.Name(), directory, createdAt) + job.status = StatusReady + job.fileName = firstEntryName(directory, entry.Name()) + manager.store(job) + } + return nil +} + +func firstEntryName(directory, fallback string) string { + entries, err := os.ReadDir(directory) + if err != nil { + return fallback + } + for _, entry := range entries { + if entry.Name() != files.PartFileName { + return entry.Name() + } + } + return fallback +} diff --git a/internal/jobs/slots.go b/internal/jobs/slots.go new file mode 100644 index 0000000..70f5b30 --- /dev/null +++ b/internal/jobs/slots.go @@ -0,0 +1,45 @@ +package jobs + +import ( + "context" + "sync" + "time" +) + +const slotWaitInterval = 500 * time.Millisecond + +type slotLimiter struct { + mutex sync.Mutex + active int +} + +func (limiter *slotLimiter) acquire(ctx context.Context, limit func() int) bool { + ticker := time.NewTicker(slotWaitInterval) + defer ticker.Stop() + for { + if limiter.tryAcquire(limit()) { + return true + } + select { + case <-ctx.Done(): + return false + case <-ticker.C: + } + } +} + +func (limiter *slotLimiter) tryAcquire(limit int) bool { + limiter.mutex.Lock() + defer limiter.mutex.Unlock() + if limiter.active >= limit { + return false + } + limiter.active++ + return true +} + +func (limiter *slotLimiter) release() { + limiter.mutex.Lock() + defer limiter.mutex.Unlock() + limiter.active-- +} diff --git a/internal/jobs/workflows.go b/internal/jobs/workflows.go new file mode 100755 index 0000000..07b3a1c --- /dev/null +++ b/internal/jobs/workflows.go @@ -0,0 +1,102 @@ +package jobs + +import ( + "errors" + "os" + "path/filepath" + + "ddc/internal/diskspace" + "ddc/internal/extract" + "ddc/internal/files" +) + +const maxArchiveEntries = 100000 + +var errNotArchive = errors.New("selected file is not a supported archive") + +func (manager *Manager) download(job *Job) { + defer job.workers.Done() + + if !manager.slots.acquire(job.jobContext, manager.concurrencyLimit) { + return + } + defer manager.slots.release() + + job.setStatus(StatusDownloading, "") + filePath, err := manager.downloader.Download(job.jobContext, job.sourceURL, job.directory, manager.reservedBytes(), job) + if err != nil { + manager.finishWithError(job, StatusFailed, "", err) + return + } + job.SetFileName(filepath.Base(filePath)) + + if job.autoExtract && extract.IsArchive(filePath) { + job.setStatus(StatusExtracting, "") + destination, err := manager.extractArchive(job, filePath) + if err != nil { + manager.finishWithError(job, StatusReady, "Extraction failed: ", err) + return + } + job.SetFileName(filepath.Base(destination)) + } + + job.setStatus(StatusReady, "") +} + +func (manager *Manager) Extract(id, relativePath string) error { + job, err := manager.lookup(id) + if err != nil { + return err + } + + archivePath, err := files.ResolveInside(job.directory, relativePath) + if err != nil { + return invalid(err) + } + info, err := os.Stat(archivePath) + if err != nil || !info.Mode().IsRegular() || !extract.IsArchive(archivePath) { + return invalid(errNotArchive) + } + + if !job.begin(StatusReady, StatusExtracting) { + return ErrJobBusy + } + go func() { + defer job.workers.Done() + if _, err := manager.extractArchive(job, archivePath); err != nil { + manager.finishWithError(job, StatusReady, "Extraction failed: ", err) + return + } + job.setStatus(StatusReady, "") + }() + return nil +} + +func (manager *Manager) extractArchive(job *Job, archivePath string) (string, error) { + available, err := diskspace.Available(job.directory) + if err != nil { + return "", err + } + limit := available - manager.reservedBytes() + if limit <= 0 { + return "", diskspace.ErrInsufficientSpace + } + + directoryName := extract.DirectoryName(filepath.Base(archivePath)) + destination := files.UniquePath(filepath.Join(filepath.Dir(archivePath), directoryName)) + limits := extract.Limits{MaxBytes: limit, MaxEntries: maxArchiveEntries} + + job.resetProgress() + if err := extract.Archive(job.jobContext, archivePath, destination, limits, job); err != nil { + return "", err + } + return destination, os.Remove(archivePath) +} + +func (manager *Manager) finishWithError(job *Job, status Status, prefix string, err error) { + if job.jobContext.Err() != nil { + job.setStatus(StatusCancelled, "") + return + } + job.setStatus(status, prefix+err.Error()) +} diff --git a/internal/libraries/libraries.go b/internal/libraries/libraries.go new file mode 100644 index 0000000..0d90925 --- /dev/null +++ b/internal/libraries/libraries.go @@ -0,0 +1,57 @@ +package libraries + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +var ErrUnknownDestination = errors.New("destination is not a first level folder of a library root") + +type Destination struct { + Name string `json:"name"` + Root string `json:"root"` + Path string `json:"path"` +} + +func Discover(roots []string) []Destination { + destinations := []Destination{} + for _, root := range roots { + entries, err := os.ReadDir(root) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + destinations = append(destinations, Destination{ + Name: entry.Name(), + Root: root, + Path: filepath.Join(root, entry.Name()), + }) + } + } + return destinations +} + +func Resolve(roots []string, requested string) (Destination, error) { + cleaned := filepath.Clean(requested) + name := filepath.Base(cleaned) + if strings.HasPrefix(name, ".") { + return Destination{}, ErrUnknownDestination + } + + for _, root := range roots { + if filepath.Dir(cleaned) != root { + continue + } + info, err := os.Lstat(cleaned) + if err != nil || !info.IsDir() { + return Destination{}, ErrUnknownDestination + } + return Destination{Name: name, Root: root, Path: cleaned}, nil + } + return Destination{}, ErrUnknownDestination +} diff --git a/internal/preferences/preferences.go b/internal/preferences/preferences.go new file mode 100644 index 0000000..f64daf3 --- /dev/null +++ b/internal/preferences/preferences.go @@ -0,0 +1,41 @@ +package preferences + +import ( + "errors" + "fmt" +) + +const ( + gigabyte = int64(1) << 30 + maxConcurrentLimit = 10 + maxReservedGigabytes = 100000 +) + +var ErrInvalid = errors.New("invalid settings") + +type Preferences struct { + MaxConcurrentDownloads int `json:"maxConcurrentDownloads"` + MinFreeGB int `json:"minFreeGB"` + AllowedPorts []uint16 `json:"allowedPorts"` + AutoExtract bool `json:"autoExtract"` + CleanupAfterMove bool `json:"cleanupAfterMove"` +} + +func (preferences Preferences) MinFreeBytes() int64 { + return int64(preferences.MinFreeGB) * gigabyte +} + +func (preferences Preferences) Validate() error { + if preferences.MaxConcurrentDownloads < 1 || preferences.MaxConcurrentDownloads > maxConcurrentLimit { + return fmt.Errorf("%w: parallel downloads must be between 1 and %d", ErrInvalid, maxConcurrentLimit) + } + if preferences.MinFreeGB < 0 || preferences.MinFreeGB > maxReservedGigabytes { + return fmt.Errorf("%w: free space to keep must be between 0 and %d GB", ErrInvalid, maxReservedGigabytes) + } + for _, port := range preferences.AllowedPorts { + if port == 0 { + return fmt.Errorf("%w: port 0 is not allowed", ErrInvalid) + } + } + return nil +} diff --git a/internal/preferences/store.go b/internal/preferences/store.go new file mode 100644 index 0000000..66fd572 --- /dev/null +++ b/internal/preferences/store.go @@ -0,0 +1,87 @@ +package preferences + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "sync" +) + +type Store struct { + mutex sync.RWMutex + path string + current Preferences +} + +func Open(path string, defaults Preferences) (*Store, error) { + store := &Store{path: path, current: defaults} + + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return store, nil + } + if err != nil { + return nil, err + } + + loaded := defaults + if err := json.Unmarshal(data, &loaded); err != nil { + return nil, fmt.Errorf("settings file %s: %w", path, err) + } + if err := loaded.Validate(); err != nil { + return nil, fmt.Errorf("settings file %s: %w", path, err) + } + store.current = loaded + return store, nil +} + +func (store *Store) Get() Preferences { + store.mutex.RLock() + defer store.mutex.RUnlock() + current := store.current + current.AllowedPorts = slices.Clone(current.AllowedPorts) + if current.AllowedPorts == nil { + current.AllowedPorts = []uint16{} + } + return current +} + +func (store *Store) Update(next Preferences) error { + if err := next.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(next, "", " ") + if err != nil { + return err + } + + store.mutex.Lock() + defer store.mutex.Unlock() + if err := writeAtomically(store.path, data); err != nil { + return err + } + store.current = next + return nil +} + +func writeAtomically(path string, data []byte) error { + temporary, err := os.CreateTemp(filepath.Dir(path), ".settings-*.tmp") + if err != nil { + return err + } + _, writeErr := temporary.Write(data) + closeErr := temporary.Close() + if writeErr != nil || closeErr != nil { + os.Remove(temporary.Name()) + return errors.Join(writeErr, closeErr) + } + if err := os.Rename(temporary.Name(), path); err != nil { + os.Remove(temporary.Name()) + return err + } + return nil +} diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go new file mode 100755 index 0000000..a947509 --- /dev/null +++ b/internal/server/auth_handlers.go @@ -0,0 +1,83 @@ +package server + +import ( + "net" + "net/http" + "strings" + + "ddc/internal/auth" +) + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type sessionResponse struct { + Authenticated bool `json:"authenticated"` +} + +func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Request) { + client := server.clientAddress(request) + if !server.limiter.Allowed(client) { + writeError(writer, http.StatusTooManyRequests, "too many failed attempts, try again later") + return + } + + var body loginRequest + if err := decodeJSON(request, &body); err != nil { + writeError(writer, http.StatusBadRequest, "invalid request body") + return + } + if !auth.CredentialsMatch(server.settings.Username, server.settings.Password, body.Username, body.Password) { + server.limiter.RecordFailure(client) + writeError(writer, http.StatusUnauthorized, "invalid username or password") + return + } + + server.limiter.Reset(client) + token, err := server.sessions.Create() + if err != nil { + writeError(writer, http.StatusInternalServerError, "could not create session") + return + } + http.SetCookie(writer, server.sessionCookie(token, int(server.settings.SessionTTL.Seconds()))) + writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: true}) +} + +func (server *Server) handleLogout(writer http.ResponseWriter, request *http.Request) { + if cookie, err := request.Cookie(sessionCookieName); err == nil { + server.sessions.Delete(cookie.Value) + } + http.SetCookie(writer, server.sessionCookie("", -1)) + writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: false}) +} + +func (server *Server) handleSession(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: server.authenticated(request)}) +} + +func (server *Server) sessionCookie(value string, maxAge int) *http.Cookie { + return &http.Cookie{ + Name: sessionCookieName, + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: server.settings.CookieSecure, + SameSite: http.SameSiteStrictMode, + } +} + +func (server *Server) clientAddress(request *http.Request) string { + if server.settings.TrustProxyHeaders { + if realIP := strings.TrimSpace(request.Header.Get("X-Real-IP")); realIP != "" { + return realIP + } + } + host, _, err := net.SplitHostPort(request.RemoteAddr) + if err != nil { + return request.RemoteAddr + } + return host +} diff --git a/internal/server/job_handlers.go b/internal/server/job_handlers.go new file mode 100755 index 0000000..69f4c2b --- /dev/null +++ b/internal/server/job_handlers.go @@ -0,0 +1,99 @@ +package server + +import ( + "net/http" + + "ddc/internal/jobs" +) + +type createJobRequest struct { + URL string `json:"url"` + AutoExtract bool `json:"autoExtract"` +} + +type extractRequest struct { + Path string `json:"path"` +} + +type diskResponse struct { + AvailableBytes int64 `json:"availableBytes"` + ReservedBytes int64 `json:"reservedBytes"` +} + +type startedResponse struct { + Started bool `json:"started"` +} + +func (server *Server) handleLibraries(writer http.ResponseWriter, _ *http.Request) { + writeJSON(writer, http.StatusOK, server.manager.Destinations()) +} + +func (server *Server) handleDisk(writer http.ResponseWriter, _ *http.Request) { + available, err := server.manager.AvailableBytes() + if err != nil { + writeFailure(writer, err) + return + } + writeJSON(writer, http.StatusOK, diskResponse{AvailableBytes: available, ReservedBytes: server.manager.Preferences().MinFreeBytes()}) +} + +func (server *Server) handleListJobs(writer http.ResponseWriter, _ *http.Request) { + writeJSON(writer, http.StatusOK, server.manager.List()) +} + +func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.Request) { + var body createJobRequest + if err := decodeJSON(request, &body); err != nil { + writeError(writer, http.StatusBadRequest, "invalid request body") + return + } + snapshot, err := server.manager.Create(body.URL, body.AutoExtract) + if err != nil { + writeFailure(writer, err) + return + } + writeJSON(writer, http.StatusCreated, snapshot) +} + +func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.Request) { + tree, err := server.manager.Files(request.PathValue("id")) + if err != nil { + writeFailure(writer, err) + return + } + writeJSON(writer, http.StatusOK, tree) +} + +func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Request) { + var body extractRequest + if err := decodeJSON(request, &body); err != nil { + writeError(writer, http.StatusBadRequest, "invalid request body") + return + } + if err := server.manager.Extract(request.PathValue("id"), body.Path); err != nil { + writeFailure(writer, err) + return + } + writeJSON(writer, http.StatusAccepted, startedResponse{Started: true}) +} + +func (server *Server) handleMove(writer http.ResponseWriter, request *http.Request) { + var body jobs.MoveRequest + if err := decodeJSON(request, &body); err != nil { + writeError(writer, http.StatusBadRequest, "invalid request body") + return + } + if err := server.manager.Move(request.PathValue("id"), body); err != nil { + writeFailure(writer, err) + return + } + writeJSON(writer, http.StatusAccepted, startedResponse{Started: true}) +} + +func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) { + if err := server.manager.Delete(request.PathValue("id")); err != nil { + writeFailure(writer, err) + return + } + writer.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100755 index 0000000..f2ddcc0 --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,73 @@ +package server + +import ( + "net/http" + "net/url" + "strings" +) + +const ( + sessionCookieName = "ddc_session" + maxRequestBodyBytes = 64 << 10 + contentSecurityPolicy = "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" +) + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + header := writer.Header() + header.Set("Content-Security-Policy", contentSecurityPolicy) + header.Set("X-Content-Type-Options", "nosniff") + header.Set("X-Frame-Options", "DENY") + header.Set("Referrer-Policy", "no-referrer") + header.Set("Cross-Origin-Opener-Policy", "same-origin") + header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + if strings.HasPrefix(request.URL.Path, "/api/") { + header.Set("Cache-Control", "no-store") + } + next.ServeHTTP(writer, request) + }) +} + +func protectStateChanges(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodGet || request.Method == http.MethodHead { + next.ServeHTTP(writer, request) + return + } + if !isSameOrigin(request) { + writeError(writer, http.StatusForbidden, "cross-origin request rejected") + return + } + if request.Method == http.MethodPost && !strings.HasPrefix(request.Header.Get("Content-Type"), "application/json") { + writeError(writer, http.StatusUnsupportedMediaType, "content type must be application/json") + return + } + request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBodyBytes) + next.ServeHTTP(writer, request) + }) +} + +func isSameOrigin(request *http.Request) bool { + origin := request.Header.Get("Origin") + if origin == "" { + fetchSite := request.Header.Get("Sec-Fetch-Site") + return fetchSite == "" || fetchSite == "same-origin" || fetchSite == "none" + } + parsed, err := url.Parse(origin) + return err == nil && strings.EqualFold(parsed.Host, request.Host) +} + +func (server *Server) requireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if !server.authenticated(request) { + writeError(writer, http.StatusUnauthorized, "authentication required") + return + } + next.ServeHTTP(writer, request) + }) +} + +func (server *Server) authenticated(request *http.Request) bool { + cookie, err := request.Cookie(sessionCookieName) + return err == nil && server.sessions.Valid(cookie.Value) +} diff --git a/internal/server/respond.go b/internal/server/respond.go new file mode 100755 index 0000000..ddda2a3 --- /dev/null +++ b/internal/server/respond.go @@ -0,0 +1,47 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "ddc/internal/diskspace" + "ddc/internal/files" + "ddc/internal/jobs" + "ddc/internal/preferences" +) + +func writeJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(status) + json.NewEncoder(writer).Encode(payload) +} + +func writeError(writer http.ResponseWriter, status int, message string) { + writeJSON(writer, status, map[string]string{"error": message}) +} + +func writeFailure(writer http.ResponseWriter, err error) { + writeError(writer, statusFor(err), err.Error()) +} + +func decodeJSON(request *http.Request, target any) error { + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func statusFor(err error) int { + switch { + case errors.Is(err, jobs.ErrJobNotFound): + return http.StatusNotFound + case errors.Is(err, jobs.ErrJobBusy), errors.Is(err, files.ErrDestinationExists): + return http.StatusConflict + case errors.Is(err, jobs.ErrInvalidRequest), errors.Is(err, preferences.ErrInvalid): + return http.StatusBadRequest + case errors.Is(err, diskspace.ErrInsufficientSpace): + return http.StatusInsufficientStorage + default: + return http.StatusInternalServerError + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100755 index 0000000..12ffc0e --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,73 @@ +package server + +import ( + "net/http" + "strings" + "time" + + "ddc/internal/auth" + "ddc/internal/config" + "ddc/internal/jobs" + "ddc/web" +) + +const ( + maxLoginFailures = 5 + loginLockout = 15 * time.Minute +) + +type Server struct { + settings config.Settings + sessions *auth.SessionStore + limiter *auth.LoginLimiter + manager *jobs.Manager +} + +func New(settings config.Settings, sessions *auth.SessionStore, manager *jobs.Manager) http.Handler { + server := &Server{ + settings: settings, + sessions: sessions, + limiter: auth.NewLoginLimiter(maxLoginFailures, loginLockout), + manager: manager, + } + return server.routes() +} + +func (server *Server) routes() http.Handler { + api := http.NewServeMux() + api.HandleFunc("POST /api/logout", server.handleLogout) + api.HandleFunc("GET /api/libraries", server.handleLibraries) + api.HandleFunc("GET /api/disk", server.handleDisk) + api.HandleFunc("GET /api/settings", server.handleGetSettings) + api.HandleFunc("POST /api/settings", server.handleUpdateSettings) + api.HandleFunc("GET /api/jobs", server.handleListJobs) + api.HandleFunc("POST /api/jobs", server.handleCreateJob) + api.HandleFunc("GET /api/jobs/{id}/files", server.handleJobFiles) + api.HandleFunc("POST /api/jobs/{id}/extract", server.handleExtract) + api.HandleFunc("POST /api/jobs/{id}/move", server.handleMove) + api.HandleFunc("DELETE /api/jobs/{id}", server.handleDeleteJob) + + root := http.NewServeMux() + root.HandleFunc("POST /api/login", server.handleLogin) + root.HandleFunc("GET /api/session", server.handleSession) + root.HandleFunc("GET /healthz", handleHealth) + root.Handle("/api/", server.requireSession(api)) + root.Handle("/", staticHandler()) + + return securityHeaders(protectStateChanges(root)) +} + +func staticHandler() http.Handler { + fileServer := http.FileServerFS(web.Static()) + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/" && strings.HasSuffix(request.URL.Path, "/") { + http.NotFound(writer, request) + return + } + fileServer.ServeHTTP(writer, request) + }) +} + +func handleHealth(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/settings_handlers.go b/internal/server/settings_handlers.go new file mode 100644 index 0000000..1386db8 --- /dev/null +++ b/internal/server/settings_handlers.go @@ -0,0 +1,38 @@ +package server + +import ( + "net/http" + + "ddc/internal/preferences" +) + +type settingsResponse struct { + Settings preferences.Preferences `json:"settings"` + DownloadDir string `json:"downloadDir"` + LibraryRoots []string `json:"libraryRoots"` +} + +func (server *Server) handleGetSettings(writer http.ResponseWriter, _ *http.Request) { + writeJSON(writer, http.StatusOK, server.settingsSnapshot()) +} + +func (server *Server) handleUpdateSettings(writer http.ResponseWriter, request *http.Request) { + var body preferences.Preferences + if err := decodeJSON(request, &body); err != nil { + writeError(writer, http.StatusBadRequest, "invalid request body") + return + } + if err := server.manager.UpdatePreferences(body); err != nil { + writeFailure(writer, err) + return + } + writeJSON(writer, http.StatusOK, server.settingsSnapshot()) +} + +func (server *Server) settingsSnapshot() settingsResponse { + return settingsResponse{ + Settings: server.manager.Preferences(), + DownloadDir: server.settings.DownloadDir, + LibraryRoots: server.settings.LibraryRoots, + } +} diff --git a/main.go b/main.go new file mode 100755 index 0000000..865cf91 --- /dev/null +++ b/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "ddc/internal/auth" + "ddc/internal/config" + "ddc/internal/jobs" + "ddc/internal/preferences" + "ddc/internal/server" +) + +const settingsFileName = ".settings.json" + +func main() { + settings, err := config.Load() + if err != nil { + log.Fatalf("configuration error: %v", err) + } + + if err := os.MkdirAll(settings.DownloadDir, 0o755); err != nil { + log.Fatalf("download directory error: %v", err) + } + resolvedDownloadDir, err := filepath.EvalSymlinks(settings.DownloadDir) + if err != nil { + log.Fatalf("download directory error: %v", err) + } + settings.DownloadDir = resolvedDownloadDir + if err := ensureWritable(settings.DownloadDir); err != nil { + log.Fatalf("download directory %s is not writable by uid %d gid %d: %v", settings.DownloadDir, os.Getuid(), os.Getgid(), err) + } + + store, err := preferences.Open(filepath.Join(settings.DownloadDir, settingsFileName), settings.Defaults) + if err != nil { + log.Fatalf("settings error: %v", err) + } + + manager := jobs.NewManager(settings, store) + if err := manager.RestoreExisting(); err != nil { + log.Printf("could not restore existing downloads: %v", err) + } + + sessions := auth.NewSessionStore(settings.SessionTTL) + httpServer := &http.Server{ + Addr: settings.ListenAddr, + Handler: server.New(settings, sessions, manager), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + shutdownSignal, stopListening := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopListening() + + go func() { + log.Printf("listening on %s", settings.ListenAddr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + }() + + <-shutdownSignal.Done() + log.Printf("shutting down") + manager.CancelAll() + + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownContext); err != nil { + log.Printf("shutdown error: %v", err) + } +} + +func ensureWritable(directory string) error { + probe, err := os.CreateTemp(directory, ".write-test-*") + if err != nil { + return err + } + probe.Close() + return os.Remove(probe.Name()) +} diff --git a/web/embed.go b/web/embed.go new file mode 100755 index 0000000..75b6c56 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,17 @@ +package web + +import ( + "embed" + "io/fs" +) + +//go:embed static +var content embed.FS + +func Static() fs.FS { + static, err := fs.Sub(content, "static") + if err != nil { + panic(err) + } + return static +} diff --git a/web/static/index.html b/web/static/index.html new file mode 100755 index 0000000..125bd55 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,162 @@ + + + + + + + DDC + + + + + + + + + + + +
+
+

Settings

+ +
+ +
+
+ Downloads +
+ + +
+ +
+ +
+ Defaults + + +
+ +
+ Appearance + +
+ +
+ Server +
+
Download folder
+
+
Destination roots
+
+
+
+ + +
+ + +
+
+ + diff --git a/web/static/js/api.js b/web/static/js/api.js new file mode 100755 index 0000000..c967cc4 --- /dev/null +++ b/web/static/js/api.js @@ -0,0 +1,61 @@ +export class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +const unauthorizedListeners = new Set(); + +export function onUnauthorized(listener) { + unauthorizedListeners.add(listener); +} + +function parseBody(text) { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +async function request(method, path, body) { + const options = { method, credentials: "same-origin", headers: {} }; + if (body !== undefined) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(path, options); + const data = parseBody(await response.text()); + + if (!response.ok) { + if (response.status === 401 && path !== "/api/login") { + unauthorizedListeners.forEach((listener) => listener()); + } + const message = data && data.error ? data.error : `Request failed (${response.status})`; + throw new ApiError(response.status, message); + } + return data; +} + +function jobPath(id, suffix = "") { + return `/api/jobs/${encodeURIComponent(id)}${suffix}`; +} + +export const api = { + session: () => request("GET", "/api/session"), + login: (username, password) => request("POST", "/api/login", { username, password }), + logout: () => request("POST", "/api/logout", {}), + libraries: () => request("GET", "/api/libraries"), + disk: () => request("GET", "/api/disk"), + settings: () => request("GET", "/api/settings"), + saveSettings: (settings) => request("POST", "/api/settings", settings), + jobs: () => request("GET", "/api/jobs"), + createJob: (url, autoExtract) => request("POST", "/api/jobs", { url, autoExtract }), + files: (id) => request("GET", jobPath(id, "/files")), + extract: (id, path) => request("POST", jobPath(id, "/extract"), { path }), + move: (id, payload) => request("POST", jobPath(id, "/move"), payload), + remove: (id) => request("DELETE", jobPath(id)), +}; diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100755 index 0000000..30080dc --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,293 @@ +import { api, onUnauthorized } from "./api.js"; +import { element } from "./dom.js"; +import { formatBytes } from "./format.js"; +import { isActive, renderJobs, updateSpeeds } from "./jobs.js"; +import { setupLogin } from "./login.js"; +import { loadSettings, openSettings, setupSettings } from "./settings.js"; +import { renderTree, topLevelSelection } from "./tree.js"; + +const pollIntervalMs = 1500; + +const elements = { + loginView: document.getElementById("login-view"), + loginForm: document.getElementById("login-form"), + loginError: document.getElementById("login-error"), + appView: document.getElementById("app-view"), + logoutButton: document.getElementById("logout-button"), + settingsButton: document.getElementById("settings-button"), + diskStatus: document.getElementById("disk-status"), + downloadForm: document.getElementById("download-form"), + downloadUrl: document.getElementById("download-url"), + autoExtract: document.getElementById("auto-extract"), + downloadError: document.getElementById("download-error"), + jobList: document.getElementById("job-list"), + jobEmpty: document.getElementById("job-empty"), + detail: document.getElementById("job-detail"), + detailTitle: document.getElementById("detail-title"), + closeDetailButton: document.getElementById("close-detail-button"), + fileTree: document.getElementById("file-tree"), + selectionSummary: document.getElementById("selection-summary"), + librarySelect: document.getElementById("library-select"), + subfolderInput: document.getElementById("subfolder-input"), + cleanupCheckbox: document.getElementById("cleanup-checkbox"), + moveButton: document.getElementById("move-button"), + actionError: document.getElementById("action-error"), +}; + +const state = { + jobs: [], + selectedJobId: null, + treeStatus: null, + selection: new Set(), + pollTimer: null, + refreshing: false, +}; + +function showLogin() { + stopPolling(); + closeDetail(); + elements.appView.hidden = true; + elements.loginView.hidden = false; +} + +async function showApp() { + elements.loginView.hidden = true; + elements.appView.hidden = false; + applyDefaults(await loadSettings()); + await loadLibraries(); + await refresh(); + startPolling(); +} + +function applyDefaults(settings) { + elements.autoExtract.checked = settings.autoExtract; + elements.cleanupCheckbox.checked = settings.cleanupAfterMove; +} + +async function showSettings() { + try { + await openSettings(); + } catch (error) { + window.alert(error.message); + } +} + +function startPolling() { + stopPolling(); + state.pollTimer = window.setInterval(refresh, pollIntervalMs); +} + +function stopPolling() { + if (state.pollTimer !== null) window.clearInterval(state.pollTimer); + state.pollTimer = null; +} + +async function loadLibraries() { + const destinations = (await api.libraries()) || []; + const previousValue = elements.librarySelect.value; + const groups = new Map(); + + for (const destination of destinations) { + if (!groups.has(destination.root)) { + const group = document.createElement("optgroup"); + group.label = destination.root; + groups.set(destination.root, group); + } + const option = element("option", "", destination.name); + option.value = destination.path; + option.title = destination.path; + groups.get(destination.root).append(option); + } + + elements.librarySelect.replaceChildren(...groups.values()); + if (destinations.some((destination) => destination.path === previousValue)) { + elements.librarySelect.value = previousValue; + } +} + +async function refresh() { + if (state.refreshing) return; + state.refreshing = true; + try { + const [jobs, disk] = await Promise.all([api.jobs(), api.disk()]); + state.jobs = jobs || []; + updateSpeeds(state.jobs); + elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`; + renderJobList(); + await syncDetail(); + } catch (error) { + if (error.status !== 401) console.error(error); + } finally { + state.refreshing = false; + } +} + +function renderJobList() { + elements.jobEmpty.hidden = state.jobs.length > 0; + renderJobs(elements.jobList, state.jobs, state.selectedJobId, { + onBrowse: openDetail, + onDelete: deleteJob, + }); +} + +function selectedJob() { + return state.jobs.find((job) => job.id === state.selectedJobId); +} + +function openDetail(job) { + state.selectedJobId = job.id; + state.treeStatus = null; + state.selection.clear(); + elements.actionError.textContent = ""; + elements.detail.hidden = false; + renderJobList(); + syncDetail(); + loadLibraries().catch((error) => { + elements.actionError.textContent = error.message; + }); + elements.detail.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function closeDetail() { + state.selectedJobId = null; + state.treeStatus = null; + state.selection.clear(); + elements.detail.hidden = true; + elements.fileTree.replaceChildren(); +} + +async function syncDetail() { + const job = selectedJob(); + if (!job) { + if (state.selectedJobId) closeDetail(); + return; + } + + elements.detailTitle.textContent = job.fileName; + if (job.status === state.treeStatus) return; + state.treeStatus = job.status; + + if (job.status === "ready") { + await loadTree(job.id); + return; + } + + state.selection.clear(); + updateSelectionSummary(); + const text = job.status === "completed" + ? "Files were moved and temporary files were removed." + : "Files will be available when this download is ready."; + elements.fileTree.replaceChildren(element("p", "muted", text)); +} + +async function loadTree(jobId) { + try { + const nodes = (await api.files(jobId)) || []; + state.selection.clear(); + renderTree(elements.fileTree, nodes, state.selection, { + onSelectionChange: updateSelectionSummary, + onExtract: extractArchive, + }); + updateSelectionSummary(); + } catch (error) { + elements.actionError.textContent = error.message; + } +} + +function updateSelectionSummary() { + const count = topLevelSelection(state.selection).length; + const job = selectedJob(); + elements.selectionSummary.textContent = count === 0 ? "Nothing selected" : `${count} item(s) selected`; + elements.moveButton.disabled = count === 0 || !job || job.status !== "ready"; +} + +async function submitDownload(event) { + event.preventDefault(); + elements.downloadError.textContent = ""; + try { + await api.createJob(elements.downloadUrl.value.trim(), elements.autoExtract.checked); + elements.downloadUrl.value = ""; + await refresh(); + } catch (error) { + elements.downloadError.textContent = error.message; + } +} + +async function extractArchive(path) { + elements.actionError.textContent = ""; + try { + await api.extract(state.selectedJobId, path); + await refresh(); + } catch (error) { + elements.actionError.textContent = error.message; + } +} + +async function moveSelection() { + elements.actionError.textContent = ""; + const paths = topLevelSelection(state.selection); + if (paths.length === 0) return; + + try { + await api.move(state.selectedJobId, { + paths, + destination: elements.librarySelect.value, + subfolder: elements.subfolderInput.value.trim(), + cleanup: elements.cleanupCheckbox.checked, + }); + elements.subfolderInput.value = ""; + await refresh(); + } catch (error) { + elements.actionError.textContent = error.message; + } +} + +async function deleteJob(job) { + const question = isActive(job) + ? `Cancel and remove "${job.fileName}"?` + : `Remove "${job.fileName}" and its temporary files?`; + if (!window.confirm(question)) return; + + try { + await api.remove(job.id); + if (job.id === state.selectedJobId) closeDetail(); + await refresh(); + } catch (error) { + window.alert(error.message); + } +} + +async function logout() { + try { + await api.logout(); + } finally { + showLogin(); + } +} + +async function init() { + onUnauthorized(showLogin); + setupLogin(elements.loginForm, elements.loginError, showApp); + elements.downloadForm.addEventListener("submit", submitDownload); + elements.moveButton.addEventListener("click", moveSelection); + elements.closeDetailButton.addEventListener("click", () => { + closeDetail(); + renderJobList(); + }); + elements.logoutButton.addEventListener("click", logout); + elements.settingsButton.addEventListener("click", showSettings); + setupSettings((settings) => { + applyDefaults(settings); + refresh(); + }); + + try { + const session = await api.session(); + if (session && session.authenticated) await showApp(); + else showLogin(); + } catch { + showLogin(); + } +} + +init(); diff --git a/web/static/js/dom.js b/web/static/js/dom.js new file mode 100755 index 0000000..e61764a --- /dev/null +++ b/web/static/js/dom.js @@ -0,0 +1,13 @@ +export function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; +} + +export function button(label, className, onClick) { + const node = element("button", className, label); + node.type = "button"; + node.addEventListener("click", onClick); + return node; +} diff --git a/web/static/js/format.js b/web/static/js/format.js new file mode 100755 index 0000000..1928efc --- /dev/null +++ b/web/static/js/format.js @@ -0,0 +1,27 @@ +const units = ["B", "KB", "MB", "GB", "TB"]; + +export const statusLabels = { + queued: "Queued", + downloading: "Downloading", + extracting: "Extracting", + ready: "Ready", + moving: "Moving", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", +}; + +export function formatBytes(bytes) { + let value = Math.max(0, bytes || 0); + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +export function percentOf(done, total) { + if (!total || total <= 0) return null; + return Math.min(100, (done / total) * 100); +} diff --git a/web/static/js/jobs.js b/web/static/js/jobs.js new file mode 100755 index 0000000..bfbf383 --- /dev/null +++ b/web/static/js/jobs.js @@ -0,0 +1,95 @@ +import { button, element } from "./dom.js"; +import { formatBytes, percentOf, statusLabels } from "./format.js"; + +const activeStatuses = new Set(["queued", "downloading", "extracting", "moving"]); +const speedSamples = new Map(); + +export function isActive(job) { + return activeStatuses.has(job.status); +} + +export function updateSpeeds(jobs) { + const now = performance.now(); + const seenIds = new Set(); + + for (const job of jobs) { + seenIds.add(job.id); + const previous = speedSamples.get(job.id); + let speed = 0; + if (previous && job.bytesDone >= previous.bytes && now > previous.time) { + speed = ((job.bytesDone - previous.bytes) * 1000) / (now - previous.time); + } + speedSamples.set(job.id, { bytes: job.bytesDone, time: now, speed }); + } + + for (const id of speedSamples.keys()) { + if (!seenIds.has(id)) speedSamples.delete(id); + } +} + +export function renderJobs(container, jobs, selectedId, handlers) { + container.replaceChildren(...jobs.map((job) => renderJob(job, job.id === selectedId, handlers))); +} + +function renderJob(job, selected, handlers) { + const item = element("li", selected ? "job selected" : "job"); + + const header = element("div", "job-header"); + header.append( + element("span", "job-name", job.fileName || job.sourceUrl), + element("span", `badge badge-${job.status}`, statusLabels[job.status] || job.status), + ); + item.append(header); + + if (job.sourceUrl) item.append(element("p", "job-source muted", job.sourceUrl)); + if (job.status !== "completed") item.append(renderProgress(job)); + if (job.message) { + const messageClass = job.status === "failed" ? "job-message error" : "job-message muted"; + item.append(element("p", messageClass, job.message)); + } + item.append(renderActions(job, handlers)); + return item; +} + +function renderProgress(job) { + const wrapper = element("div"); + const track = element("div", "progress"); + const bar = element("div", "progress-bar"); + const percent = percentOf(job.bytesDone, job.bytesTotal); + + if (percent === null && isActive(job)) { + bar.classList.add("indeterminate"); + } else { + const width = percent === null ? (job.status === "ready" ? 100 : 0) : percent; + bar.style.width = `${width}%`; + } + + track.append(bar); + wrapper.append(track, element("div", "progress-text muted", progressText(job, percent))); + return wrapper; +} + +function progressText(job, percent) { + const parts = []; + if (job.bytesTotal > 0) { + parts.push(`${formatBytes(job.bytesDone)} of ${formatBytes(job.bytesTotal)}`); + } else if (job.bytesDone > 0) { + parts.push(formatBytes(job.bytesDone)); + } + if (percent !== null) parts.push(`${percent.toFixed(1)}%`); + + const sample = speedSamples.get(job.id); + if (isActive(job) && sample && sample.speed > 0) parts.push(`${formatBytes(sample.speed)}/s`); + return parts.join(" | "); +} + +function renderActions(job, handlers) { + const actions = element("div", "job-actions"); + if (job.status === "ready") { + actions.append(button("Browse files", "secondary small", () => handlers.onBrowse(job))); + } + if (job.status !== "moving") { + actions.append(button(isActive(job) ? "Cancel" : "Remove", "danger small", () => handlers.onDelete(job))); + } + return actions; +} diff --git a/web/static/js/login.js b/web/static/js/login.js new file mode 100755 index 0000000..998015f --- /dev/null +++ b/web/static/js/login.js @@ -0,0 +1,17 @@ +import { api } from "./api.js"; + +export function setupLogin(form, errorElement, onSuccess) { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + errorElement.textContent = ""; + const formData = new FormData(form); + + try { + await api.login(formData.get("username"), formData.get("password")); + form.reset(); + await onSuccess(); + } catch (error) { + errorElement.textContent = error.message; + } + }); +} diff --git a/web/static/js/settings.js b/web/static/js/settings.js new file mode 100644 index 0000000..7b16c99 --- /dev/null +++ b/web/static/js/settings.js @@ -0,0 +1,104 @@ +import { api } from "./api.js"; +import { element } from "./dom.js"; + +const themeStorageKey = "ddc-theme"; + +const fields = { + dialog: document.getElementById("settings-dialog"), + form: document.getElementById("settings-form"), + closeButton: document.getElementById("settings-close"), + cancelButton: document.getElementById("settings-cancel"), + error: document.getElementById("settings-error"), + concurrent: document.getElementById("setting-concurrent"), + minFree: document.getElementById("setting-min-free"), + ports: document.getElementById("setting-ports"), + autoExtract: document.getElementById("setting-auto-extract"), + cleanup: document.getElementById("setting-cleanup"), + theme: document.getElementById("setting-theme"), + downloadDir: document.getElementById("info-download-dir"), + roots: document.getElementById("info-roots"), +}; + +export function setupSettings(onSaved) { + fields.closeButton.addEventListener("click", () => fields.dialog.close()); + fields.cancelButton.addEventListener("click", () => fields.dialog.close()); + fields.form.addEventListener("submit", (event) => saveSettings(event, onSaved)); + fields.theme.addEventListener("change", () => applyTheme(fields.theme.value)); + fields.dialog.addEventListener("close", () => applyTheme(readTheme())); +} + +export async function loadSettings() { + const data = await api.settings(); + return data.settings; +} + +export async function openSettings() { + fields.error.textContent = ""; + const data = await api.settings(); + fillForm(data); + fields.dialog.showModal(); +} + +function fillForm(data) { + const settings = data.settings; + fields.concurrent.value = settings.maxConcurrentDownloads; + fields.minFree.value = settings.minFreeGB; + fields.ports.value = settings.allowedPorts.length > 0 ? settings.allowedPorts.join(", ") : "*"; + fields.autoExtract.checked = settings.autoExtract; + fields.cleanup.checked = settings.cleanupAfterMove; + fields.theme.value = readTheme(); + fields.downloadDir.textContent = data.downloadDir; + fields.roots.replaceChildren(...data.libraryRoots.map((root) => element("div", "mono", root))); +} + +async function saveSettings(event, onSaved) { + event.preventDefault(); + fields.error.textContent = ""; + try { + const payload = { + maxConcurrentDownloads: Number(fields.concurrent.value), + minFreeGB: Number(fields.minFree.value), + allowedPorts: parsePorts(fields.ports.value), + autoExtract: fields.autoExtract.checked, + cleanupAfterMove: fields.cleanup.checked, + }; + const data = await api.saveSettings(payload); + storeTheme(fields.theme.value); + fields.dialog.close(); + onSaved(data.settings); + } catch (error) { + fields.error.textContent = error.message; + } +} + +function parsePorts(text) { + const trimmed = text.trim(); + if (trimmed === "*") return []; + const ports = trimmed.split(/[\s,]+/).filter(Boolean).map(Number); + const invalid = ports.length === 0 || ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535); + if (invalid) throw new Error("Allowed ports must be numbers between 1 and 65535, or * for any port."); + return ports; +} + +function readTheme() { + try { + return localStorage.getItem(themeStorageKey) || "system"; + } catch { + return "system"; + } +} + +function storeTheme(theme) { + try { + localStorage.setItem(themeStorageKey, theme); + } catch { + return; + } finally { + applyTheme(theme); + } +} + +function applyTheme(theme) { + if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme; + else delete document.documentElement.dataset.theme; +} diff --git a/web/static/js/theme.js b/web/static/js/theme.js new file mode 100644 index 0000000..518d267 --- /dev/null +++ b/web/static/js/theme.js @@ -0,0 +1,10 @@ +(function () { + try { + var theme = localStorage.getItem("ddc-theme"); + if (theme === "light" || theme === "dark") { + document.documentElement.setAttribute("data-theme", theme); + } + } catch (error) { + return; + } +})(); diff --git a/web/static/js/tree.js b/web/static/js/tree.js new file mode 100755 index 0000000..38bd6fc --- /dev/null +++ b/web/static/js/tree.js @@ -0,0 +1,50 @@ +import { button, element } from "./dom.js"; +import { formatBytes } from "./format.js"; + +export function renderTree(container, nodes, selection, handlers) { + if (nodes.length === 0) { + container.replaceChildren(element("p", "muted", "No files found.")); + return; + } + container.replaceChildren(buildList(nodes, selection, handlers)); +} + +export function topLevelSelection(selection) { + const paths = [...selection].sort(); + return paths.filter((path) => !paths.some((other) => other !== path && path.startsWith(`${other}/`))); +} + +function buildList(nodes, selection, handlers) { + const list = element("ul", "tree-list"); + for (const node of nodes) list.append(buildNode(node, selection, handlers)); + return list; +} + +function buildNode(node, selection, handlers) { + const item = element("li", "tree-item"); + const row = element("div", "tree-row"); + const label = element("label", "tree-label"); + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selection.has(node.path); + checkbox.addEventListener("change", () => { + if (checkbox.checked) selection.add(node.path); + else selection.delete(node.path); + handlers.onSelectionChange(); + }); + + const displayName = node.isDir ? `${node.name}/` : node.name; + label.append(checkbox, element("span", node.isDir ? "tree-name folder" : "tree-name", displayName)); + row.append(label, element("span", "tree-size", formatBytes(node.size))); + + if (node.isArchive) { + row.append(button("Extract", "secondary small", () => handlers.onExtract(node.path))); + } + + item.append(row); + if (node.children && node.children.length > 0) { + item.append(buildList(node.children, selection, handlers)); + } + return item; +} diff --git a/web/static/style.css b/web/static/style.css new file mode 100755 index 0000000..4bfdf46 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,726 @@ +:root { + color-scheme: light; + --background: #f2f3f6; + --surface: #ffffff; + --surface-alt: #f7f8fa; + --surface-hover: #eef0f4; + --border: #dde0e6; + --text: #191c22; + --muted: #687080; + --accent: #3b6ef5; + --accent-hover: #2f5ddb; + --accent-soft: rgba(59, 110, 245, 0.12); + --accent-text: #ffffff; + --danger: #d03c32; + --danger-soft: rgba(208, 60, 50, 0.1); + --success: #1f9d62; + --success-soft: rgba(31, 157, 98, 0.12); + --info: #3b6ef5; + --info-soft: rgba(59, 110, 245, 0.12); + --shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 2px 8px rgba(16, 24, 40, 0.04); + --radius: 12px; + --radius-small: 8px; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --background: #0f1115; + --surface: #171a20; + --surface-alt: #1d2128; + --surface-hover: #242933; + --border: #2a2f39; + --text: #e7e9ee; + --muted: #949cab; + --accent: #5b87ff; + --accent-hover: #7298ff; + --accent-soft: rgba(91, 135, 255, 0.16); + --danger: #f06a5f; + --danger-soft: rgba(240, 106, 95, 0.14); + --success: #3fc486; + --success-soft: rgba(63, 196, 134, 0.14); + --info: #5b87ff; + --info-soft: rgba(91, 135, 255, 0.16); + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --background: #0f1115; + --surface: #171a20; + --surface-alt: #1d2128; + --surface-hover: #242933; + --border: #2a2f39; + --text: #e7e9ee; + --muted: #949cab; + --accent: #5b87ff; + --accent-hover: #7298ff; + --accent-soft: rgba(91, 135, 255, 0.16); + --danger: #f06a5f; + --danger-soft: rgba(240, 106, 95, 0.14); + --success: #3fc486; + --success-soft: rgba(63, 196, 134, 0.14); + --info: #5b87ff; + --info-soft: rgba(91, 135, 255, 0.16); + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 15px; + line-height: 1.45; + background: var(--background); + color: var(--text); + -webkit-tap-highlight-color: transparent; +} + +h2 { + font-size: 1.05rem; + font-weight: 650; + margin: 0 0 14px; + overflow-wrap: anywhere; +} + +input, +select, +button { + font: inherit; + color: inherit; +} + +input:not([type="checkbox"]), +select { + width: 100%; + min-height: 42px; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-small); + background: var(--surface-alt); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +input:not([type="checkbox"]):focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--accent); + flex-shrink: 0; +} + +button { + min-height: 40px; + padding: 8px 16px; + border: 1px solid transparent; + border-radius: var(--radius-small); + background: var(--surface-alt); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; +} + +button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--accent-soft); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + color: var(--accent-text); +} + +button.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +button.ghost, +button.secondary { + background: transparent; + border-color: var(--border); +} + +button.ghost:hover, +button.secondary:hover { + background: var(--surface-hover); +} + +button.danger { + background: transparent; + color: var(--danger); + border-color: var(--danger); +} + +button.danger:hover { + background: var(--danger-soft); +} + +button.small { + min-height: 32px; + padding: 4px 12px; + font-size: 0.85rem; +} + +button.block { + width: 100%; +} + +.muted { + color: var(--muted); +} + +.small-text { + font-size: 0.85rem; +} + +.mono { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.85rem; + overflow-wrap: anywhere; +} + +.error { + color: var(--danger); + margin: 10px 0 0; + font-size: 0.9rem; +} + +.error:empty { + display: none; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 20px; + margin-bottom: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.85rem; + font-weight: 550; + color: var(--muted); +} + +.field input, +.field select { + font-weight: 400; + color: var(--text); +} + +.checkbox { + display: flex; + align-items: center; + gap: 10px; + margin: 14px 0; + min-height: 32px; + cursor: pointer; +} + +.brand-mark { + font-weight: 800; + font-size: 1.15rem; + letter-spacing: 0.06em; + color: var(--accent); +} + +.chip { + font-size: 0.8rem; + padding: 3px 10px; + border-radius: 999px; + background: var(--surface-alt); + border: 1px solid var(--border); + color: var(--muted); + white-space: nowrap; +} + +.chip:empty { + display: none; +} + +.login-view { + min-height: 100vh; + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.login-card { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 28px; +} + +.login-brand { + text-align: center; +} + +.login-brand .brand-mark { + font-size: 1.8rem; +} + +.login-brand p { + margin: 4px 0 0; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + background: color-mix(in srgb, var(--surface) 88%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); + padding-top: env(safe-area-inset-top); +} + +.topbar-inner { + max-width: 980px; + margin: 0 auto; + padding: 10px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-actions { + margin-left: auto; + display: flex; + gap: 8px; +} + +.content { + max-width: 980px; + margin: 0 auto; + padding: 16px 16px calc(24px + env(safe-area-inset-bottom)); +} + +.download-form { + display: flex; + gap: 10px; +} + +.empty-state { + text-align: center; + color: var(--muted); + padding: 24px 8px; + margin: 0; + border: 1px dashed var(--border); + border-radius: var(--radius-small); +} + +.job-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.job { + border: 1px solid var(--border); + border-radius: var(--radius-small); + padding: 14px; + background: var(--surface-alt); + transition: border-color 0.15s ease; +} + +.job.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.job-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; +} + +.job-name { + font-weight: 600; + overflow-wrap: anywhere; +} + +.job-source { + font-size: 0.8rem; + margin: 2px 0 10px; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.job-message { + font-size: 0.88rem; + margin: 10px 0 0; +} + +.job-actions { + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; +} + +.badge { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 3px 9px; + border-radius: 999px; + white-space: nowrap; + flex-shrink: 0; +} + +.badge-ready, +.badge-completed { + color: var(--success); + background: var(--success-soft); +} + +.badge-failed, +.badge-cancelled { + color: var(--danger); + background: var(--danger-soft); +} + +.badge-downloading, +.badge-extracting, +.badge-moving, +.badge-queued { + color: var(--info); + background: var(--info-soft); +} + +.progress { + height: 8px; + background: var(--border); + border-radius: 999px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + width: 0; + background: var(--accent); + border-radius: 999px; + transition: width 0.4s ease; +} + +.progress-bar.indeterminate { + width: 30% !important; + animation: slide 1.2s ease-in-out infinite; +} + +@keyframes slide { + from { + transform: translateX(-100%); + } + to { + transform: translateX(340%); + } +} + +.progress-text { + font-size: 0.82rem; + margin-top: 6px; + font-variant-numeric: tabular-nums; +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.file-tree { + max-height: 440px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-small); + padding: 6px; + background: var(--surface-alt); + -webkit-overflow-scrolling: touch; +} + +.file-tree > .muted { + padding: 12px; + margin: 0; +} + +.tree-list { + list-style: none; + margin: 0; + padding-left: 0; +} + +.tree-list .tree-list { + padding-left: 20px; + border-left: 1px solid var(--border); + margin-left: 14px; +} + +.tree-row { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 8px; + min-height: 40px; + border-radius: 6px; +} + +.tree-row:hover { + background: var(--surface-hover); +} + +.tree-label { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + cursor: pointer; +} + +.tree-name { + overflow-wrap: anywhere; +} + +.tree-name.folder { + font-weight: 600; +} + +.tree-size { + font-size: 0.8rem; + color: var(--muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.move-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 14px; +} + +.sheet { + width: min(560px, calc(100% - 32px)); + max-height: calc(100dvh - 48px); + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); +} + +.sheet::backdrop { + background: rgba(10, 12, 16, 0.55); +} + +.sheet-form { + display: flex; + flex-direction: column; + max-height: calc(100dvh - 48px); +} + +.sheet-header, +.sheet-footer { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 20px; +} + +.sheet-header { + justify-content: space-between; + border-bottom: 1px solid var(--border); +} + +.sheet-header h2 { + margin: 0; +} + +.sheet-footer { + justify-content: flex-end; + border-top: 1px solid var(--border); +} + +.sheet-body { + padding: 4px 20px 16px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.settings-group { + border: none; + padding: 0; + margin: 16px 0 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.settings-group legend { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + padding: 0; + margin-bottom: 10px; +} + +.settings-group .checkbox { + margin: 0; +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.info-list { + margin: 0; + display: grid; + gap: 4px; +} + +.info-list dt { + font-size: 0.85rem; + color: var(--muted); + margin-top: 6px; +} + +.info-list dd { + margin: 0; +} + +@media (max-width: 640px) { + body { + font-size: 16px; + } + + .card { + padding: 16px; + border-radius: 10px; + } + + .content { + padding: 12px 12px calc(20px + env(safe-area-inset-bottom)); + } + + .topbar-inner { + padding: 8px 12px; + gap: 8px; + } + + .topbar-actions button { + min-height: 36px; + padding: 6px 10px; + font-size: 0.85rem; + } + + .download-form { + flex-direction: column; + } + + .download-form button, + button.block-mobile { + width: 100%; + min-height: 46px; + } + + .move-controls, + .settings-grid { + grid-template-columns: 1fr; + } + + .job-actions button { + flex: 1; + min-height: 40px; + } + + .file-tree { + max-height: 55vh; + } + + .tree-row { + min-height: 46px; + } + + .tree-list .tree-list { + padding-left: 12px; + margin-left: 8px; + } + + .sheet { + width: 100%; + max-width: none; + max-height: 92dvh; + margin: auto 0 0; + border-radius: 16px 16px 0 0; + border-bottom: none; + } + + .sheet-form { + max-height: 92dvh; + } + + .sheet-footer { + padding-bottom: calc(14px + env(safe-area-inset-bottom)); + } + + .sheet-footer button { + flex: 1; + min-height: 46px; + } +} + +@media (max-width: 380px) { + .chip { + display: none; + } +}