Initial DDC release

This commit is contained in:
SirBlob committed 2026-10-25 20:24:50 -04:00
commit 434f6441da
57 files changed
+4606

No files matched your search

Executable
+10
View File
@@ -0,0 +1,10 @@
.env
.env.example
.git
.gitignore
PROJECT.md
UPDATE.md
README.md
deploy
data
docker-compose.yml
Executable
+17
View File
@@ -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
View File
@@ -0,0 +1,5 @@
.env
PROJECT.md
UPDATE.md
/ddc
/data/
Executable
+12
View File
@@ -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"]
Executable
+105
View File
@@ -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.
+28
View File
@@ -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;
}
}
+23
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module ddc
go 1.24
+18
View File
@@ -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
}
+76
View File
@@ -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)
}
}
}
+75
View File
@@ -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[:])
}
+73
View File
@@ -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()
}
+78
View File
@@ -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
}
+56
View File
@@ -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
}
+38
View File
@@ -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)
}
+48
View File
@@ -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
}
+50
View File
@@ -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
}
+61
View File
@@ -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
}
}
+106
View File
@@ -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
}
+44
View File
@@ -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
}
}
+152
View File
@@ -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
}
}
}
+65
View File
@@ -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
}
+101
View File
@@ -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
}
+99
View File
@@ -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 == '\\'
}
+52
View File
@@ -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)
}
+85
View File
@@ -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
}
+54
View File
@@ -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
}
+55
View File
@@ -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
}
+98
View File
@@ -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
}
+126
View File
@@ -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,
}
}
+208
View File
@@ -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)
}
+125
View File
@@ -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)
}
+51
View File
@@ -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
}
+45
View File
@@ -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--
}
+102
View File
@@ -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())
}
+57
View File
@@ -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
}
+41
View File
@@ -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
}
+87
View File
@@ -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
}
+83
View File
@@ -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
}
+99
View File
@@ -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)
}
+73
View File
@@ -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)
}
+47
View File
@@ -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
}
}
+73
View File
@@ -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)
}
+38
View File
@@ -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,
}
}
Executable
+89
View File
@@ -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
View File
@@ -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
}
+162
View File
@@ -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>
+61
View File
@@ -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)),
};
+293
View File
@@ -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();
+13
View File
@@ -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;
}
+27
View File
@@ -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);
}
+95
View File
@@ -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;
}
+17
View File
@@ -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;
}
});
}
+104
View File
@@ -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;
}
+10
View File
@@ -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;
}
})();
+50
View File
@@ -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;
}
+726
View File
@@ -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;
}
}