Initial DDC release
This commit is contained in:
commit
434f6441da
57 files changed
+4606
No files matched your search
Executable
+10
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.env.example
|
||||
.git
|
||||
.gitignore
|
||||
PROJECT.md
|
||||
UPDATE.md
|
||||
README.md
|
||||
deploy
|
||||
data
|
||||
docker-compose.yml
|
||||
Executable
+17
@@ -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
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
PROJECT.md
|
||||
UPDATE.md
|
||||
/ddc
|
||||
/data/
|
||||
Executable
+12
@@ -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"]
|
||||
@@ -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.
|
||||
Executable
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+23
@@ -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
|
||||
Executable
+18
@@ -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
|
||||
}
|
||||
Executable
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+75
@@ -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[:])
|
||||
}
|
||||
Executable
+73
@@ -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()
|
||||
}
|
||||
Executable
+78
@@ -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
|
||||
}
|
||||
Executable
+56
@@ -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
|
||||
}
|
||||
Executable
+38
@@ -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)
|
||||
}
|
||||
Executable
+48
@@ -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
|
||||
}
|
||||
Executable
+50
@@ -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
|
||||
}
|
||||
Executable
+61
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+106
@@ -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
|
||||
}
|
||||
Executable
+44
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+152
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+65
@@ -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
|
||||
}
|
||||
Executable
+101
@@ -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
|
||||
}
|
||||
Executable
+99
@@ -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 == '\\'
|
||||
}
|
||||
Executable
+52
@@ -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)
|
||||
}
|
||||
Executable
+85
@@ -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
|
||||
}
|
||||
Executable
+54
@@ -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
|
||||
}
|
||||
Executable
+55
@@ -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
|
||||
}
|
||||
Executable
+98
@@ -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
|
||||
}
|
||||
Executable
+126
@@ -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,
|
||||
}
|
||||
}
|
||||
Executable
+208
@@ -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)
|
||||
}
|
||||
Executable
+125
@@ -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)
|
||||
}
|
||||
Executable
+51
@@ -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
|
||||
}
|
||||
@@ -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--
|
||||
}
|
||||
Executable
+102
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+83
@@ -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
|
||||
}
|
||||
Executable
+99
@@ -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)
|
||||
}
|
||||
Executable
+73
@@ -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)
|
||||
}
|
||||
Executable
+47
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+73
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Executable
+17
@@ -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
|
||||
}
|
||||
Executable
+162
@@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#16181d">
|
||||
<title>DDC</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<section id="login-view" class="login-view" hidden>
|
||||
<form id="login-form" class="card login-card">
|
||||
<div class="login-brand">
|
||||
<span class="brand-mark">DDC</span>
|
||||
<p class="muted">Direct Download Client</p>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input name="username" autocomplete="username" autocapitalize="off" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit" class="primary block">Sign in</button>
|
||||
<p id="login-error" class="error" role="alert"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="app-view" class="app" hidden>
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<span class="brand-mark">DDC</span>
|
||||
<span id="disk-status" class="chip"></span>
|
||||
<div class="topbar-actions">
|
||||
<button id="settings-button" type="button" class="ghost">Settings</button>
|
||||
<button id="logout-button" type="button" class="ghost">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<section class="card">
|
||||
<h2>New download</h2>
|
||||
<form id="download-form" class="download-form">
|
||||
<input id="download-url" type="url" inputmode="url" placeholder="https://example.com/file.zip" autocomplete="off" autocapitalize="off" required>
|
||||
<button type="submit" class="primary">Download</button>
|
||||
</form>
|
||||
<label class="checkbox">
|
||||
<input id="auto-extract" type="checkbox" checked>
|
||||
<span>Extract archives automatically</span>
|
||||
</label>
|
||||
<p id="download-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Downloads</h2>
|
||||
<ul id="job-list" class="job-list"></ul>
|
||||
<p id="job-empty" class="empty-state">No downloads yet. Paste a link above to start.</p>
|
||||
</section>
|
||||
|
||||
<section id="job-detail" class="card" hidden>
|
||||
<div class="detail-header">
|
||||
<h2 id="detail-title"></h2>
|
||||
<button id="close-detail-button" type="button" class="ghost small">Close</button>
|
||||
</div>
|
||||
<div id="file-tree" class="file-tree"></div>
|
||||
<p id="selection-summary" class="muted small-text"></p>
|
||||
<div class="move-controls">
|
||||
<label class="field">
|
||||
<span>Destination</span>
|
||||
<select id="library-select"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Subfolder (optional)</span>
|
||||
<input id="subfolder-input" placeholder="Movie Name (2024)" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox">
|
||||
<input id="cleanup-checkbox" type="checkbox" checked>
|
||||
<span>Delete remaining temporary files after the move</span>
|
||||
</label>
|
||||
<button id="move-button" type="button" class="primary block-mobile" disabled>Move</button>
|
||||
<p id="action-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="settings-dialog" class="sheet">
|
||||
<form id="settings-form" class="sheet-form">
|
||||
<header class="sheet-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="settings-close" type="button" class="ghost small">Close</button>
|
||||
</header>
|
||||
|
||||
<div class="sheet-body">
|
||||
<fieldset class="settings-group">
|
||||
<legend>Downloads</legend>
|
||||
<div class="settings-grid">
|
||||
<label class="field">
|
||||
<span>Parallel downloads</span>
|
||||
<input id="setting-concurrent" type="number" inputmode="numeric" min="1" max="10" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Keep free on disk (GB)</span>
|
||||
<input id="setting-min-free" type="number" inputmode="numeric" min="0" required>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Allowed ports</span>
|
||||
<input id="setting-ports" placeholder="80, 443 or * for any" autocomplete="off">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Defaults</legend>
|
||||
<label class="checkbox">
|
||||
<input id="setting-auto-extract" type="checkbox">
|
||||
<span>Extract archives automatically</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="setting-cleanup" type="checkbox">
|
||||
<span>Delete temporary files after a move</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Appearance</legend>
|
||||
<label class="field">
|
||||
<span>Theme (this browser)</span>
|
||||
<select id="setting-theme">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Server</legend>
|
||||
<dl class="info-list">
|
||||
<dt>Download folder</dt>
|
||||
<dd id="info-download-dir" class="mono"></dd>
|
||||
<dt>Destination roots</dt>
|
||||
<dd id="info-roots"></dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
|
||||
<p id="settings-error" class="error" role="alert"></p>
|
||||
</div>
|
||||
|
||||
<footer class="sheet-footer">
|
||||
<button id="settings-cancel" type="button" class="ghost">Cancel</button>
|
||||
<button type="submit" class="primary">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+61
@@ -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)),
|
||||
};
|
||||
Executable
+293
@@ -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();
|
||||
Executable
+13
@@ -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;
|
||||
}
|
||||
Executable
+27
@@ -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);
|
||||
}
|
||||
Executable
+95
@@ -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;
|
||||
}
|
||||
Executable
+17
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
Executable
+50
@@ -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;
|
||||
}
|
||||
Executable
+726
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user