Add structured logging and better error handling

This commit is contained in:
SirBlob committed 2026-11-01 13:50:26 -05:00
1 parent db4e3c9611
commit 68c7dd76f2
31 files changed
+640 -109

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ SESSION_TTL_HOURS=12
TRUST_PROXY_HEADERS=true TRUST_PROXY_HEADERS=true
LISTEN_ADDR=:8080 LISTEN_ADDR=:8080
LOG_LEVEL=info
LOG_FORMAT=text
DOWNLOAD_DIR=/data/downloads DOWNLOAD_DIR=/data/downloads
DOWNLOAD_HOST_DIR=./data/downloads DOWNLOAD_HOST_DIR=./data/downloads
+7
View File
@@ -64,7 +64,14 @@ Every first level folder inside these roots is a move destination:
| `DOWNLOAD_DIR` | `/data/downloads` | Temporary download directory | | `DOWNLOAD_DIR` | `/data/downloads` | Temporary download directory |
| `MIN_FREE_GB` | `10` | Initial value: free space that must always remain | | `MIN_FREE_GB` | `10` | Initial value: free space that must always remain |
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Initial value: parallel downloads, others wait in queue | | `MAX_CONCURRENT_DOWNLOADS` | `2` | Initial value: parallel downloads, others wait in queue |
| `CONNECTIONS_PER_DOWNLOAD` | `4` | Initial value: parallel connections for one download when the server allows ranges |
| `ALLOWED_PORTS` | `80,443` | Initial value: allowed remote ports, `*` for any | | `ALLOWED_PORTS` | `80,443` | Initial value: allowed remote ports, `*` for any |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. `debug` also logs every polling request |
| `LOG_FORMAT` | `text` | `text` or `json` |
## Logging
Logs go to stdout (`docker compose logs -f`). Every request gets an `X-Request-ID`. When the server hits an internal error, the page shows a reference ID and the log line for that request carries the same `request_id`, so the two can be matched. Download, extraction and move steps are logged with a short `download` ID.
## Web Settings ## Web Settings
+4
View File
@@ -23,6 +23,8 @@ type Settings struct {
TrustProxyHeaders bool TrustProxyHeaders bool
DownloadDir string DownloadDir string
LibraryRoots []string LibraryRoots []string
LogLevel string
LogFormat string
Defaults preferences.Preferences Defaults preferences.Preferences
} }
@@ -36,6 +38,8 @@ func Load() (Settings, error) {
SessionTTL: time.Duration(reader.integer("SESSION_TTL_HOURS", 12)) * time.Hour, SessionTTL: time.Duration(reader.integer("SESSION_TTL_HOURS", 12)) * time.Hour,
TrustProxyHeaders: reader.boolean("TRUST_PROXY_HEADERS", false), TrustProxyHeaders: reader.boolean("TRUST_PROXY_HEADERS", false),
DownloadDir: filepath.Clean(reader.text("DOWNLOAD_DIR", "/data/downloads")), DownloadDir: filepath.Clean(reader.text("DOWNLOAD_DIR", "/data/downloads")),
LogLevel: reader.text("LOG_LEVEL", "info"),
LogFormat: reader.text("LOG_FORMAT", "text"),
Defaults: preferences.Preferences{ Defaults: preferences.Preferences{
MaxConcurrentDownloads: reader.integer("MAX_CONCURRENT_DOWNLOADS", 2), MaxConcurrentDownloads: reader.integer("MAX_CONCURRENT_DOWNLOADS", 2),
ConnectionsPerDownload: reader.integer("CONNECTIONS_PER_DOWNLOAD", 4), ConnectionsPerDownload: reader.integer("CONNECTIONS_PER_DOWNLOAD", 4),
+88
View File
@@ -0,0 +1,88 @@
package explain
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"io/fs"
"net"
"syscall"
"ddc/internal/guard"
)
var keptAsIs = []error{
guard.ErrBlockedAddress,
guard.ErrBlockedPort,
guard.ErrTooManyRedirects,
guard.ErrUnsupportedScheme,
}
func Error(err error) string {
if err == nil {
return ""
}
for _, known := range keptAsIs {
if errors.Is(err, known) {
return capitalize(known.Error())
}
}
if message := networkMessage(err); message != "" {
return message
}
if message := fileSystemMessage(err); message != "" {
return message
}
return capitalize(err.Error())
}
func networkMessage(err error) string {
var dnsError *net.DNSError
var certificateError *tls.CertificateVerificationError
var unknownAuthority x509.UnknownAuthorityError
var hostnameError x509.HostnameError
var timeoutError net.Error
switch {
case errors.As(err, &dnsError):
return fmt.Sprintf("Could not find the server %q. Check the link.", dnsError.Name)
case errors.As(err, &certificateError), errors.As(err, &unknownAuthority), errors.As(err, &hostnameError):
return "The server's security certificate is not valid."
case errors.Is(err, syscall.ECONNREFUSED):
return "The server refused the connection."
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.ErrUnexpectedEOF):
return "The server closed the connection before the file was complete. Use Retry to continue."
case errors.Is(err, syscall.ENETUNREACH), errors.Is(err, syscall.EHOSTUNREACH):
return "The server could not be reached from this network."
case errors.As(err, &timeoutError) && timeoutError.Timeout():
return "The server took too long to respond."
default:
return ""
}
}
func fileSystemMessage(err error) string {
switch {
case errors.Is(err, syscall.ENOSPC):
return "The disk is full."
case errors.Is(err, fs.ErrPermission):
return "The server does not have permission to write there. Check the folder owner and permissions."
case errors.Is(err, syscall.EROFS):
return "The destination is on a read-only file system."
default:
return ""
}
}
func capitalize(message string) string {
if message == "" {
return message
}
first := message[0]
if first >= 'a' && first <= 'z' {
return string(first-'a'+'A') + message[1:]
}
return message
}
+29 -5
View File
@@ -3,6 +3,7 @@ package fetch
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -34,6 +35,14 @@ type Request struct {
State *State State *State
Progress Progress Progress Progress
Checkpoint func(State) Checkpoint func(State)
Logger *slog.Logger
}
func (request Request) logger() *slog.Logger {
if request.Logger != nil {
return request.Logger
}
return slog.Default()
} }
type Downloader struct { type Downloader struct {
@@ -90,11 +99,19 @@ func (downloader *Downloader) Download(ctx context.Context, request Request) (st
} }
func (downloader *Downloader) prepare(ctx context.Context, source *remoteFile, request Request, partPath string) (State, *http.Response, error) { func (downloader *Downloader) prepare(ctx context.Context, source *remoteFile, request Request, partPath string) (State, *http.Response, error) {
if request.State != nil && request.State.valid() && fileExists(partPath) { if request.State != nil {
state := *request.State if request.State.valid() && fileExists(partPath) {
source.validator = state.Validator state := *request.State
source.totalBytes = state.TotalBytes source.validator = state.Validator
return state, nil, nil source.totalBytes = state.TotalBytes
request.logger().Info("resuming from saved progress",
"bytes_done", state.DoneBytes(),
"bytes_total", state.TotalBytes,
"parts", len(state.Segments),
)
return state, nil, nil
}
request.logger().Warn("saved progress is unusable, starting over", "part_file_exists", fileExists(partPath))
} }
probe, err := downloader.open(ctx, source, 0, 0) probe, err := downloader.open(ctx, source, 0, 0)
@@ -113,6 +130,13 @@ func (downloader *Downloader) prepare(ctx context.Context, source *remoteFile, r
TotalBytes: source.totalBytes, TotalBytes: source.totalBytes,
Segments: planSegments(source.totalBytes, connections), Segments: planSegments(source.totalBytes, connections),
} }
request.logger().Info("connected to server",
"file", state.FileName,
"bytes_total", state.TotalBytes,
"supports_ranges", probe.Header.Get("Accept-Ranges") == "bytes",
"has_validator", state.Validator != "",
"parts", len(state.Segments),
)
return state, probe, nil return state, probe, nil
} }
+3 -1
View File
@@ -123,6 +123,8 @@ func (session *session) saveCheckpoint() {
return return
} }
state := session.snapshot() state := session.snapshot()
session.file.Sync() if err := session.file.Sync(); err != nil {
session.request.logger().Warn("could not flush partial file to disk", "error", err)
}
session.request.Checkpoint(state) session.request.Checkpoint(state)
} }
+17 -1
View File
@@ -68,11 +68,26 @@ func (worker *segmentWorker) record(count int64) error {
func (worker *segmentWorker) reconnect(ctx context.Context, cause error) (*http.Response, error) { func (worker *segmentWorker) reconnect(ctx context.Context, cause error) (*http.Response, error) {
progress := worker.session.request.Progress progress := worker.session.request.Progress
logger := worker.session.request.logger().With("part_start", worker.segment.start)
for { for {
worker.failedAttempts++ worker.failedAttempts++
if ctx.Err() != nil || !isRetryable(cause) || worker.failedAttempts > maxRetriesWithoutProgress { if ctx.Err() != nil {
return nil, cause return nil, cause
} }
if !isRetryable(cause) {
logger.Debug("error cannot be retried", "error", cause)
return nil, cause
}
if worker.failedAttempts > maxRetriesWithoutProgress {
logger.Error("giving up after repeated connection failures", "attempts", maxRetriesWithoutProgress, "error", cause)
return nil, fmt.Errorf("gave up after %d reconnect attempts: %w", maxRetriesWithoutProgress, cause)
}
logger.Warn("connection lost, reconnecting",
"attempt", worker.failedAttempts,
"position", worker.segment.position(),
"error", cause,
)
progress.SetNotice(fmt.Sprintf("Connection lost, reconnecting (attempt %d of %d)", worker.failedAttempts, maxRetriesWithoutProgress)) progress.SetNotice(fmt.Sprintf("Connection lost, reconnecting (attempt %d of %d)", worker.failedAttempts, maxRetriesWithoutProgress))
if err := waitBeforeRetry(ctx, worker.failedAttempts); err != nil { if err := waitBeforeRetry(ctx, worker.failedAttempts); err != nil {
return nil, err return nil, err
@@ -80,6 +95,7 @@ func (worker *segmentWorker) reconnect(ctx context.Context, cause error) (*http.
response, err := worker.open(ctx) response, err := worker.open(ctx)
if err == nil { if err == nil {
logger.Info("reconnected", "position", worker.segment.position())
progress.SetNotice("") progress.SetNotice("")
return response, nil return response, nil
} }
+25 -5
View File
@@ -4,7 +4,9 @@ import (
"context" "context"
"errors" "errors"
"path/filepath" "path/filepath"
"time"
"ddc/internal/explain"
"ddc/internal/extract" "ddc/internal/extract"
"ddc/internal/fetch" "ddc/internal/fetch"
) )
@@ -17,6 +19,7 @@ func (manager *Manager) Pause(id string) error {
if !job.pause() { if !job.pause() {
return ErrJobBusy return ErrJobBusy
} }
job.logger().Info("pause requested")
return nil return nil
} }
@@ -28,6 +31,7 @@ func (manager *Manager) Resume(id string) error {
if !job.begin(StatusQueued, StatusPaused, StatusFailed) { if !job.begin(StatusQueued, StatusPaused, StatusFailed) {
return ErrJobBusy return ErrJobBusy
} }
job.logger().Info("download resumed", "has_saved_progress", job.currentDownloadState() != nil)
job.saveRecord() job.saveRecord()
manager.launchDownload(job) manager.launchDownload(job)
return nil return nil
@@ -44,17 +48,17 @@ func (manager *Manager) Reorder(id, direction string) error {
if job.currentStatus() != StatusQueued || !manager.slots.reorder(id, direction) { if job.currentStatus() != StatusQueued || !manager.slots.reorder(id, direction) {
return ErrJobBusy return ErrJobBusy
} }
job.logger().Debug("queue position changed", "direction", direction, "position", manager.slots.position(id))
return nil return nil
} }
func (manager *Manager) launchDownload(job *Job) { func (manager *Manager) launchDownload(job *Job) {
runContext, cancelRun := job.startRun() runContext, cancelRun := job.startRun()
manager.slots.enqueue(job.id) manager.slots.enqueue(job.id)
go func() { manager.runInBackground(job, "download", func() {
defer job.workers.Done()
defer cancelRun(nil) defer cancelRun(nil)
manager.download(job, runContext) manager.download(job, runContext)
}() })
} }
func (manager *Manager) download(job *Job, runContext context.Context) { func (manager *Manager) download(job *Job, runContext context.Context) {
@@ -65,18 +69,25 @@ func (manager *Manager) download(job *Job, runContext context.Context) {
defer manager.slots.release() defer manager.slots.release()
job.setStatus(StatusDownloading, "") job.setStatus(StatusDownloading, "")
started := time.Now()
job.logger().Info("download started", "url", job.displayURL)
filePath, err := manager.downloader.Download(runContext, manager.fetchRequest(job)) filePath, err := manager.downloader.Download(runContext, manager.fetchRequest(job))
if err != nil { if err != nil {
manager.handleDownloadError(job, runContext, err) manager.handleDownloadError(job, runContext, err)
return return
} }
job.markDownloaded(filepath.Base(filePath)) job.markDownloaded(filepath.Base(filePath))
job.logger().Info("download finished",
"file", filepath.Base(filePath),
"bytes", job.bytesDone.Load(),
"duration", time.Since(started).Round(time.Second).String(),
)
if job.autoExtract && extract.IsArchive(filePath) { if job.autoExtract && extract.IsArchive(filePath) {
job.setStatus(StatusExtracting, "") job.setStatus(StatusExtracting, "")
destination, err := manager.extractArchive(job, filePath) destination, err := manager.extractArchive(job, filePath)
if err != nil { if err != nil {
manager.finishWithError(job, StatusReady, "Extraction failed: ", err) manager.failExtraction(job, err)
job.saveRecord() job.saveRecord()
return return
} }
@@ -98,6 +109,7 @@ func (manager *Manager) fetchRequest(job *Job) fetch.Request {
Connections: current.ConnectionsPerDownload, Connections: current.ConnectionsPerDownload,
State: job.currentDownloadState(), State: job.currentDownloadState(),
Progress: job, Progress: job,
Logger: job.logger(),
Checkpoint: func(state fetch.State) { Checkpoint: func(state fetch.State) {
job.setDownloadState(&state) job.setDownloadState(&state)
job.saveRecord() job.saveRecord()
@@ -109,14 +121,22 @@ func (manager *Manager) handleDownloadError(job *Job, runContext context.Context
switch { switch {
case errors.Is(context.Cause(runContext), errPaused): case errors.Is(context.Cause(runContext), errPaused):
job.setStatus(StatusPaused, "") job.setStatus(StatusPaused, "")
job.logger().Info("download paused", "bytes_done", job.bytesDone.Load())
case job.jobContext.Err() != nil: case job.jobContext.Err() != nil:
job.setStatus(StatusCancelled, "") job.setStatus(StatusCancelled, "")
job.logger().Info("download stopped", "bytes_done", job.bytesDone.Load())
return return
default: default:
if errors.Is(err, fetch.ErrSourceChanged) || errors.Is(err, fetch.ErrResumeUnsupported) { if errors.Is(err, fetch.ErrSourceChanged) || errors.Is(err, fetch.ErrResumeUnsupported) {
job.setDownloadState(nil) job.setDownloadState(nil)
} }
job.setStatus(StatusFailed, err.Error()) job.setStatus(StatusFailed, explain.Error(err))
job.logger().Error("download failed",
"url", job.displayURL,
"bytes_done", job.bytesDone.Load(),
"bytes_total", job.bytesTotal.Load(),
"error", err,
)
} }
job.saveRecord() job.saveRecord()
} }
+19 -7
View File
@@ -4,8 +4,10 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"time"
"ddc/internal/diskspace" "ddc/internal/diskspace"
"ddc/internal/explain"
"ddc/internal/extract" "ddc/internal/extract"
"ddc/internal/files" "ddc/internal/files"
) )
@@ -32,14 +34,13 @@ func (manager *Manager) Extract(id, relativePath string) error {
if !job.begin(StatusExtracting, StatusReady) { if !job.begin(StatusExtracting, StatusReady) {
return ErrJobBusy return ErrJobBusy
} }
go func() { manager.runInBackground(job, "extraction", func() {
defer job.workers.Done()
if _, err := manager.extractArchive(job, archivePath); err != nil { if _, err := manager.extractArchive(job, archivePath); err != nil {
manager.finishWithError(job, StatusReady, "Extraction failed: ", err) manager.failExtraction(job, err)
return return
} }
job.setStatus(StatusReady, "") job.setStatus(StatusReady, "")
}() })
return nil return nil
} }
@@ -58,16 +59,27 @@ func (manager *Manager) extractArchive(job *Job, archivePath string) (string, er
limits := extract.Limits{MaxBytes: limit, MaxEntries: maxArchiveEntries} limits := extract.Limits{MaxBytes: limit, MaxEntries: maxArchiveEntries}
job.resetProgress() job.resetProgress()
started := time.Now()
job.logger().Info("extraction started", "archive", filepath.Base(archivePath))
if err := extract.Archive(job.jobContext, archivePath, destination, limits, job); err != nil { if err := extract.Archive(job.jobContext, archivePath, destination, limits, job); err != nil {
return "", err return "", err
} }
return destination, os.Remove(archivePath) job.logger().Info("extraction finished",
"archive", filepath.Base(archivePath),
"folder", filepath.Base(destination),
"duration", time.Since(started).Round(time.Second).String(),
)
if err := os.Remove(archivePath); err != nil {
job.logger().Warn("could not remove archive after extraction", "archive", archivePath, "error", err)
}
return destination, nil
} }
func (manager *Manager) finishWithError(job *Job, status Status, prefix string, err error) { func (manager *Manager) failExtraction(job *Job, err error) {
if job.jobContext.Err() != nil { if job.jobContext.Err() != nil {
job.setStatus(StatusCancelled, "") job.setStatus(StatusCancelled, "")
return return
} }
job.setStatus(status, prefix+err.Error()) job.logger().Error("extraction failed", "error", err)
job.setStatus(StatusReady, "Extraction failed: "+explain.Error(err))
} }
+1 -2
View File
@@ -1,7 +1,6 @@
package jobs package jobs
import ( import (
"log"
"time" "time"
"ddc/internal/history" "ddc/internal/history"
@@ -28,6 +27,6 @@ func (manager *Manager) recordHistory(job *Job, destination string, movedNames [
MovedAt: time.Now(), MovedAt: time.Now(),
} }
if err := manager.history.Add(entry); err != nil { if err := manager.history.Add(entry); err != nil {
log.Printf("could not save history: %v", err) job.logger().Error("could not save history", "error", err)
} }
} }
+19 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -76,6 +77,12 @@ func (manager *Manager) UpdatePreferences(next preferences.Preferences) error {
return err return err
} }
manager.policy.SetAllowedPorts(next.AllowedPorts) manager.policy.SetAllowedPorts(next.AllowedPorts)
slog.Debug("preferences saved",
"parallel_downloads", next.MaxConcurrentDownloads,
"connections_per_download", next.ConnectionsPerDownload,
"min_free_gb", next.MinFreeGB,
"download_paths", len(next.DownloadPaths),
)
return nil return nil
} }
@@ -137,6 +144,11 @@ func (manager *Manager) Create(request CreateRequest) (Snapshot, error) {
manager.store(job) manager.store(job)
job.saveRecord() job.saveRecord()
job.logger().Info("download added",
"url", job.displayURL,
"file_name", requestedName,
"target", job.snapshot().MoveTarget,
)
manager.launchDownload(job) manager.launchDownload(job)
return job.snapshot(), nil return job.snapshot(), nil
} }
@@ -186,7 +198,12 @@ func (manager *Manager) Delete(id string) error {
delete(manager.jobs, id) delete(manager.jobs, id)
manager.mutex.Unlock() manager.mutex.Unlock()
return os.RemoveAll(job.directory) if err := os.RemoveAll(job.directory); err != nil {
job.logger().Error("could not remove download files", "directory", job.directory, "error", err)
return err
}
job.logger().Info("download removed")
return nil
} }
func (manager *Manager) Shutdown(ctx context.Context) { func (manager *Manager) Shutdown(ctx context.Context) {
@@ -208,6 +225,7 @@ func (manager *Manager) Shutdown(ctx context.Context) {
select { select {
case <-finished: case <-finished:
case <-ctx.Done(): case <-ctx.Done():
slog.Warn("stopped waiting for downloads to save progress", "error", ctx.Err())
} }
} }
+20 -5
View File
@@ -5,7 +5,9 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time"
"ddc/internal/explain"
"ddc/internal/files" "ddc/internal/files"
) )
@@ -50,10 +52,9 @@ func (manager *Manager) Move(id string, request MoveRequest) error {
if !job.begin(StatusMoving, StatusReady) { if !job.begin(StatusMoving, StatusReady) {
return ErrJobBusy return ErrJobBusy
} }
go func() { manager.runInBackground(job, "move", func() {
defer job.workers.Done()
manager.moveFiles(job, sources, destination, request.Cleanup) manager.moveFiles(job, sources, destination, request.Cleanup)
}() })
return nil return nil
} }
@@ -109,13 +110,26 @@ func (manager *Manager) moveFiles(job *Job, sources []string, destination string
movedNames := make([]string, 0, len(sources)) movedNames := make([]string, 0, len(sources))
defer func() { manager.recordHistory(job, destination, movedNames) }() defer func() { manager.recordHistory(job, destination, movedNames) }()
started := time.Now()
for _, source := range sources { for _, source := range sources {
if err := files.MoveInto(source, destination, manager.reservedBytes(), job.Add); err != nil { if err := files.MoveInto(source, destination, manager.reservedBytes(), job.Add); err != nil {
job.setStatus(StatusReady, "Move failed: "+err.Error()) job.logger().Error("move failed",
"item", filepath.Base(source),
"destination", destination,
"moved_before_failure", len(movedNames),
"error", err,
)
job.setStatus(StatusReady, "Move failed: "+explain.Error(err))
return return
} }
movedNames = append(movedNames, filepath.Base(source)) movedNames = append(movedNames, filepath.Base(source))
} }
job.logger().Info("move finished",
"items", len(movedNames),
"bytes", totalBytes,
"destination", destination,
"duration", time.Since(started).Round(time.Second).String(),
)
message := fmt.Sprintf("Moved %d item(s) to %s", len(sources), destination) message := fmt.Sprintf("Moved %d item(s) to %s", len(sources), destination)
if !cleanup { if !cleanup {
@@ -123,7 +137,8 @@ func (manager *Manager) moveFiles(job *Job, sources []string, destination string
return return
} }
if err := os.RemoveAll(job.directory); err != nil { if err := os.RemoveAll(job.directory); err != nil {
message += ", cleanup failed: " + err.Error() job.logger().Warn("could not remove temporary files after move", "directory", job.directory, "error", err)
message += ", cleanup failed: " + explain.Error(err)
} }
job.setStatus(StatusCompleted, message) job.setStatus(StatusCompleted, message)
} }
+9 -4
View File
@@ -2,7 +2,7 @@ package jobs
import ( import (
"encoding/json" "encoding/json"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -69,23 +69,28 @@ func phaseFor(status Status, downloaded bool) string {
func (job *Job) saveRecord() { func (job *Job) saveRecord() {
data, err := json.MarshalIndent(job.record(), "", " ") data, err := json.MarshalIndent(job.record(), "", " ")
if err != nil { if err != nil {
log.Printf("could not encode download %s: %v", job.id, err) job.logger().Error("could not encode download state", "error", err)
return return
} }
job.recordMutex.Lock() job.recordMutex.Lock()
defer job.recordMutex.Unlock() defer job.recordMutex.Unlock()
if err := files.WriteAtomically(filepath.Join(job.directory, files.JobFileName), data); err != nil && !os.IsNotExist(err) { if err := files.WriteAtomically(filepath.Join(job.directory, files.JobFileName), data); err != nil && !os.IsNotExist(err) {
log.Printf("could not save download %s: %v", job.id, err) job.logger().Error("could not save download state", "error", err)
} }
} }
func readRecord(directory string) (jobRecord, bool) { func readRecord(directory string) (jobRecord, bool) {
data, err := os.ReadFile(filepath.Join(directory, files.JobFileName)) recordPath := filepath.Join(directory, files.JobFileName)
data, err := os.ReadFile(recordPath)
if err != nil { if err != nil {
if !os.IsNotExist(err) {
slog.Warn("could not read saved download state", "path", recordPath, "error", err)
}
return jobRecord{}, false return jobRecord{}, false
} }
var record jobRecord var record jobRecord
if err := json.Unmarshal(data, &record); err != nil { if err := json.Unmarshal(data, &record); err != nil {
slog.Warn("saved download state is damaged, treating the download as finished", "path", recordPath, "error", err)
return jobRecord{}, false return jobRecord{}, false
} }
return record, true return record, true
+4
View File
@@ -2,6 +2,7 @@ package jobs
import ( import (
"io/fs" "io/fs"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -20,12 +21,14 @@ func (manager *Manager) RestoreExisting() error {
} }
var queuedJobs []*Job var queuedJobs []*Job
restoredCount := 0
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() || !jobIDPattern.MatchString(entry.Name()) { if !entry.IsDir() || !jobIDPattern.MatchString(entry.Name()) {
continue continue
} }
job := manager.restoreJob(entry) job := manager.restoreJob(entry)
manager.store(job) manager.store(job)
restoredCount++
if job.status == StatusQueued { if job.status == StatusQueued {
queuedJobs = append(queuedJobs, job) queuedJobs = append(queuedJobs, job)
} }
@@ -38,6 +41,7 @@ func (manager *Manager) RestoreExisting() error {
job.workers.Add(1) job.workers.Add(1)
manager.launchDownload(job) manager.launchDownload(job)
} }
slog.Info("restored downloads", "total", restoredCount, "resuming", len(queuedJobs))
return nil return nil
} }
+42
View File
@@ -0,0 +1,42 @@
package jobs
import (
"log/slog"
"runtime/debug"
)
const shortIDLength = 8
func (job *Job) logger() *slog.Logger {
return slog.With("download", job.id[:min(len(job.id), shortIDLength)])
}
func (job *Job) isDownloaded() bool {
job.mutex.Lock()
defer job.mutex.Unlock()
return job.downloaded
}
func (manager *Manager) runInBackground(job *Job, task string, work func()) {
go func() {
defer job.workers.Done()
defer func() {
recovered := recover()
if recovered == nil {
return
}
job.logger().Error("background task crashed",
"task", task,
"panic", recovered,
"stack", string(debug.Stack()),
)
status := StatusFailed
if job.isDownloaded() {
status = StatusReady
}
job.setStatus(status, "Internal error during "+task+". The server log has the details.")
job.saveRecord()
}()
work()
}()
}
+4 -1
View File
@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"ddc/internal/explain"
"ddc/internal/files" "ddc/internal/files"
"ddc/internal/libraries" "ddc/internal/libraries"
"ddc/internal/preferences" "ddc/internal/preferences"
@@ -82,6 +83,7 @@ func (manager *Manager) moveToTarget(job *Job) {
} }
names, err := topLevelNames(job.directory) names, err := topLevelNames(job.directory)
if err == nil { if err == nil {
job.logger().Info("moving to chosen folder", "destination", job.target.displayPath(), "items", len(names))
err = manager.Move(job.id, MoveRequest{ err = manager.Move(job.id, MoveRequest{
Paths: names, Paths: names,
Destination: job.target.destination, Destination: job.target.destination,
@@ -90,7 +92,8 @@ func (manager *Manager) moveToTarget(job *Job) {
}) })
} }
if err != nil { if err != nil {
job.setStatus(StatusReady, "Move failed: "+err.Error()) job.logger().Error("automatic move failed", "destination", job.target.displayPath(), "error", err)
job.setStatus(StatusReady, "Move failed: "+explain.Error(err))
} }
} }
+33
View File
@@ -0,0 +1,33 @@
package logging
import (
"fmt"
"log/slog"
"os"
"strings"
)
func Setup(level, format string) error {
var parsedLevel slog.Level
if err := parsedLevel.UnmarshalText([]byte(level)); err != nil {
return fmt.Errorf("LOG_LEVEL must be debug, info, warn or error: %w", err)
}
options := &slog.HandlerOptions{Level: parsedLevel}
var handler slog.Handler
switch strings.ToLower(format) {
case "text":
handler = slog.NewTextHandler(os.Stdout, options)
case "json":
handler = slog.NewJSONHandler(os.Stdout, options)
default:
return fmt.Errorf("LOG_FORMAT must be text or json, got %q", format)
}
slog.SetDefault(slog.New(handler))
return nil
}
func Fatal(message string, err error, attributes ...any) {
slog.Error(message, append(attributes, "error", err)...)
os.Exit(1)
}
+7 -3
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -20,17 +22,18 @@ type sessionResponse struct {
func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Request) {
client := server.clientAddress(request) client := server.clientAddress(request)
if !server.limiter.Allowed(client) { if !server.limiter.Allowed(client) {
slog.Warn("login blocked after too many failures", "client", client, "request_id", requestIDFrom(request))
writeError(writer, http.StatusTooManyRequests, "too many failed attempts, try again later") writeError(writer, http.StatusTooManyRequests, "too many failed attempts, try again later")
return return
} }
var body loginRequest var body loginRequest
if err := decodeJSON(request, &body); err != nil { if !decodeBody(writer, request, &body) {
writeError(writer, http.StatusBadRequest, "invalid request body")
return return
} }
if !auth.CredentialsMatch(server.settings.Username, server.settings.Password, body.Username, body.Password) { if !auth.CredentialsMatch(server.settings.Username, server.settings.Password, body.Username, body.Password) {
server.limiter.RecordFailure(client) server.limiter.RecordFailure(client)
slog.Warn("login failed", "client", client, "username", body.Username, "request_id", requestIDFrom(request))
writeError(writer, http.StatusUnauthorized, "invalid username or password") writeError(writer, http.StatusUnauthorized, "invalid username or password")
return return
} }
@@ -38,9 +41,10 @@ func (server *Server) handleLogin(writer http.ResponseWriter, request *http.Requ
server.limiter.Reset(client) server.limiter.Reset(client)
token, err := server.sessions.Create() token, err := server.sessions.Create()
if err != nil { if err != nil {
writeError(writer, http.StatusInternalServerError, "could not create session") writeFailure(writer, request, fmt.Errorf("could not create session: %w", err))
return return
} }
slog.Info("login succeeded", "client", client, "request_id", requestIDFrom(request))
http.SetCookie(writer, server.sessionCookie(token, int(server.settings.SessionTTL.Seconds()))) http.SetCookie(writer, server.sessionCookie(token, int(server.settings.SessionTTL.Seconds())))
writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: true}) writeJSON(writer, http.StatusOK, sessionResponse{Authenticated: true})
} }
+10 -11
View File
@@ -2,9 +2,13 @@ package server
import "net/http" import "net/http"
type reorderRequest struct {
Direction string `json:"direction"`
}
func (server *Server) handlePause(writer http.ResponseWriter, request *http.Request) { func (server *Server) handlePause(writer http.ResponseWriter, request *http.Request) {
if err := server.manager.Pause(request.PathValue("id")); err != nil { if err := server.manager.Pause(request.PathValue("id")); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true}) writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
@@ -12,24 +16,19 @@ func (server *Server) handlePause(writer http.ResponseWriter, request *http.Requ
func (server *Server) handleResume(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleResume(writer http.ResponseWriter, request *http.Request) {
if err := server.manager.Resume(request.PathValue("id")); err != nil { if err := server.manager.Resume(request.PathValue("id")); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true}) writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
} }
type reorderRequest struct {
Direction string `json:"direction"`
}
func (server *Server) handleReorder(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleReorder(writer http.ResponseWriter, request *http.Request) {
var body reorderRequest var body reorderRequest
if err := decodeJSON(request, &body); err != nil { if !decodeBody(writer, request, &body) {
writeError(writer, http.StatusBadRequest, "invalid request body")
return return
} }
if err := server.manager.Reorder(request.PathValue("id"), body.Direction); err != nil { if err := server.manager.Reorder(request.PathValue("id"), body.Direction); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusOK, startedResponse{Started: true}) writeJSON(writer, http.StatusOK, startedResponse{Started: true})
@@ -39,9 +38,9 @@ func (server *Server) handleHistory(writer http.ResponseWriter, _ *http.Request)
writeJSON(writer, http.StatusOK, server.manager.History()) writeJSON(writer, http.StatusOK, server.manager.History())
} }
func (server *Server) handleClearHistory(writer http.ResponseWriter, _ *http.Request) { func (server *Server) handleClearHistory(writer http.ResponseWriter, request *http.Request) {
if err := server.manager.ClearHistory(); err != nil { if err := server.manager.ClearHistory(); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writer.WriteHeader(http.StatusNoContent) writer.WriteHeader(http.StatusNoContent)
+10 -13
View File
@@ -23,10 +23,10 @@ func (server *Server) handleLibraries(writer http.ResponseWriter, _ *http.Reques
writeJSON(writer, http.StatusOK, server.manager.Destinations()) writeJSON(writer, http.StatusOK, server.manager.Destinations())
} }
func (server *Server) handleDisk(writer http.ResponseWriter, _ *http.Request) { func (server *Server) handleDisk(writer http.ResponseWriter, request *http.Request) {
available, err := server.manager.AvailableBytes() available, err := server.manager.AvailableBytes()
if err != nil { if err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusOK, diskResponse{AvailableBytes: available, ReservedBytes: server.manager.Preferences().MinFreeBytes()}) writeJSON(writer, http.StatusOK, diskResponse{AvailableBytes: available, ReservedBytes: server.manager.Preferences().MinFreeBytes()})
@@ -38,13 +38,12 @@ func (server *Server) handleListJobs(writer http.ResponseWriter, _ *http.Request
func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.Request) {
var body jobs.CreateRequest var body jobs.CreateRequest
if err := decodeJSON(request, &body); err != nil { if !decodeBody(writer, request, &body) {
writeError(writer, http.StatusBadRequest, "invalid request body")
return return
} }
snapshot, err := server.manager.Create(body) snapshot, err := server.manager.Create(body)
if err != nil { if err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusCreated, snapshot) writeJSON(writer, http.StatusCreated, snapshot)
@@ -53,7 +52,7 @@ func (server *Server) handleCreateJob(writer http.ResponseWriter, request *http.
func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.Request) {
tree, err := server.manager.Files(request.PathValue("id")) tree, err := server.manager.Files(request.PathValue("id"))
if err != nil { if err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusOK, tree) writeJSON(writer, http.StatusOK, tree)
@@ -61,12 +60,11 @@ func (server *Server) handleJobFiles(writer http.ResponseWriter, request *http.R
func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Request) {
var body extractRequest var body extractRequest
if err := decodeJSON(request, &body); err != nil { if !decodeBody(writer, request, &body) {
writeError(writer, http.StatusBadRequest, "invalid request body")
return return
} }
if err := server.manager.Extract(request.PathValue("id"), body.Path); err != nil { if err := server.manager.Extract(request.PathValue("id"), body.Path); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true}) writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
@@ -74,12 +72,11 @@ func (server *Server) handleExtract(writer http.ResponseWriter, request *http.Re
func (server *Server) handleMove(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleMove(writer http.ResponseWriter, request *http.Request) {
var body jobs.MoveRequest var body jobs.MoveRequest
if err := decodeJSON(request, &body); err != nil { if !decodeBody(writer, request, &body) {
writeError(writer, http.StatusBadRequest, "invalid request body")
return return
} }
if err := server.manager.Move(request.PathValue("id"), body); err != nil { if err := server.manager.Move(request.PathValue("id"), body); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writeJSON(writer, http.StatusAccepted, startedResponse{Started: true}) writeJSON(writer, http.StatusAccepted, startedResponse{Started: true})
@@ -87,7 +84,7 @@ func (server *Server) handleMove(writer http.ResponseWriter, request *http.Reque
func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) {
if err := server.manager.Delete(request.PathValue("id")); err != nil { if err := server.manager.Delete(request.PathValue("id")); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
writer.WriteHeader(http.StatusNoContent) writer.WriteHeader(http.StatusNoContent)
+115
View File
@@ -0,0 +1,115 @@
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"time"
)
type contextKey int
const requestIDKey contextKey = iota
type statusRecorder struct {
http.ResponseWriter
status int
}
func (recorder *statusRecorder) WriteHeader(status int) {
if recorder.status == 0 {
recorder.status = status
}
recorder.ResponseWriter.WriteHeader(status)
}
func (recorder *statusRecorder) Write(data []byte) (int, error) {
if recorder.status == 0 {
recorder.status = http.StatusOK
}
return recorder.ResponseWriter.Write(data)
}
func (recorder *statusRecorder) Unwrap() http.ResponseWriter {
return recorder.ResponseWriter
}
func (server *Server) observe(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
started := time.Now()
requestID := newRequestID()
writer.Header().Set("X-Request-ID", requestID)
request = request.WithContext(context.WithValue(request.Context(), requestIDKey, requestID))
recorder := &statusRecorder{ResponseWriter: writer}
defer func() {
if recovered := recover(); recovered != nil {
if recovered == http.ErrAbortHandler {
panic(recovered)
}
slog.Error("request handler crashed",
"request_id", requestID,
"method", request.Method,
"path", request.URL.Path,
"panic", recovered,
"stack", string(debug.Stack()),
)
if recorder.status == 0 {
writeError(recorder, http.StatusInternalServerError, internalErrorMessage(requestID))
}
}
server.logRequest(request, recorder.status, time.Since(started))
}()
next.ServeHTTP(recorder, request)
})
}
func (server *Server) logRequest(request *http.Request, status int, duration time.Duration) {
if status == 0 {
status = http.StatusOK
}
slog.Log(request.Context(), requestLogLevel(request.Method, status), "request",
"request_id", requestIDFrom(request),
"method", request.Method,
"path", request.URL.Path,
"status", status,
"duration_ms", duration.Milliseconds(),
"client", server.clientAddress(request),
)
}
func requestLogLevel(method string, status int) slog.Level {
switch {
case status >= http.StatusInternalServerError:
return slog.LevelError
case status >= http.StatusBadRequest && status != http.StatusUnauthorized:
return slog.LevelWarn
case method != http.MethodGet && method != http.MethodHead:
return slog.LevelInfo
default:
return slog.LevelDebug
}
}
func requestIDFrom(request *http.Request) string {
if requestID, ok := request.Context().Value(requestIDKey).(string); ok {
return requestID
}
return ""
}
func newRequestID() string {
buffer := make([]byte, 6)
if _, err := rand.Read(buffer); err != nil {
return "unknown"
}
return hex.EncodeToString(buffer)
}
func internalErrorMessage(requestID string) string {
return "Something went wrong on the server (reference " + requestID + "). The server log has the details."
}
+38 -6
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"ddc/internal/diskspace" "ddc/internal/diskspace"
@@ -11,24 +12,55 @@ import (
"ddc/internal/preferences" "ddc/internal/preferences"
) )
type errorResponse struct {
Error string `json:"error"`
RequestID string `json:"requestId,omitempty"`
}
func writeJSON(writer http.ResponseWriter, status int, payload any) { func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8") writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status) writer.WriteHeader(status)
json.NewEncoder(writer).Encode(payload) if err := json.NewEncoder(writer).Encode(payload); err != nil {
slog.Debug("could not write response", "error", err)
}
} }
func writeError(writer http.ResponseWriter, status int, message string) { func writeError(writer http.ResponseWriter, status int, message string) {
writeJSON(writer, status, map[string]string{"error": message}) writeJSON(writer, status, errorResponse{Error: message, RequestID: writer.Header().Get("X-Request-ID")})
} }
func writeFailure(writer http.ResponseWriter, err error) { func writeFailure(writer http.ResponseWriter, request *http.Request, err error) {
writeError(writer, statusFor(err), err.Error()) status := statusFor(err)
if status != http.StatusInternalServerError {
writeError(writer, status, err.Error())
return
}
requestID := requestIDFrom(request)
slog.Error("request failed",
"request_id", requestID,
"method", request.Method,
"path", request.URL.Path,
"error", err,
)
writeError(writer, status, internalErrorMessage(requestID))
} }
func decodeJSON(request *http.Request, target any) error { func decodeBody(writer http.ResponseWriter, request *http.Request, target any) bool {
decoder := json.NewDecoder(request.Body) decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
return decoder.Decode(target) err := decoder.Decode(target)
if err == nil {
return true
}
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeError(writer, http.StatusRequestEntityTooLarge, "request body is too large")
return false
}
writeError(writer, http.StatusBadRequest, "invalid request body: "+err.Error())
return false
} }
func statusFor(err error) int { func statusFor(err error) int {
+1 -1
View File
@@ -59,7 +59,7 @@ func (server *Server) routes() http.Handler {
root.Handle("/api/", server.requireSession(api)) root.Handle("/api/", server.requireSession(api))
root.Handle("/", staticHandler()) root.Handle("/", staticHandler())
return securityHeaders(protectStateChanges(root)) return server.observe(securityHeaders(protectStateChanges(root)))
} }
func staticHandler() http.Handler { func staticHandler() http.Handler {
+4 -3
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"log/slog"
"net/http" "net/http"
"ddc/internal/preferences" "ddc/internal/preferences"
@@ -18,14 +19,14 @@ func (server *Server) handleGetSettings(writer http.ResponseWriter, _ *http.Requ
func (server *Server) handleUpdateSettings(writer http.ResponseWriter, request *http.Request) { func (server *Server) handleUpdateSettings(writer http.ResponseWriter, request *http.Request) {
var body preferences.Preferences var body preferences.Preferences
if err := decodeJSON(request, &body); err != nil { if !decodeBody(writer, request, &body) {
writeError(writer, http.StatusBadRequest, "invalid request body")
return return
} }
if err := server.manager.UpdatePreferences(body); err != nil { if err := server.manager.UpdatePreferences(body); err != nil {
writeFailure(writer, err) writeFailure(writer, request, err)
return return
} }
slog.Info("settings updated", "request_id", requestIDFrom(request))
writeJSON(writer, http.StatusOK, server.settingsSnapshot()) writeJSON(writer, http.StatusOK, server.settingsSnapshot())
} }
+40 -21
View File
@@ -3,7 +3,8 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"log" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -15,6 +16,7 @@ import (
"ddc/internal/config" "ddc/internal/config"
"ddc/internal/history" "ddc/internal/history"
"ddc/internal/jobs" "ddc/internal/jobs"
"ddc/internal/logging"
"ddc/internal/preferences" "ddc/internal/preferences"
"ddc/internal/server" "ddc/internal/server"
) )
@@ -22,45 +24,41 @@ import (
const ( const (
settingsFileName = ".settings.json" settingsFileName = ".settings.json"
historyFileName = ".history.json" historyFileName = ".history.json"
shutdownTimeout = 10 * time.Second
) )
func main() { func main() {
settings, err := config.Load() settings, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("configuration error: %v", err) logging.Fatal("configuration error", err)
}
if err := logging.Setup(settings.LogLevel, settings.LogFormat); err != nil {
logging.Fatal("logging configuration error", err)
} }
if err := os.MkdirAll(settings.DownloadDir, 0o755); err != nil { if err := prepareDownloadDir(&settings); err != nil {
log.Fatalf("download directory error: %v", err) logging.Fatal("download directory error", err, "path", settings.DownloadDir)
}
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) store, err := preferences.Open(filepath.Join(settings.DownloadDir, settingsFileName), settings.Defaults)
if err != nil { if err != nil {
log.Fatalf("settings error: %v", err) logging.Fatal("could not load settings", err)
} }
historyStore, err := history.Open(filepath.Join(settings.DownloadDir, historyFileName)) historyStore, err := history.Open(filepath.Join(settings.DownloadDir, historyFileName))
if err != nil { if err != nil {
log.Fatalf("history error: %v", err) logging.Fatal("could not load history", err)
} }
manager := jobs.NewManager(settings, store, historyStore) manager := jobs.NewManager(settings, store, historyStore)
if err := manager.RestoreExisting(); err != nil { if err := manager.RestoreExisting(); err != nil {
log.Printf("could not restore existing downloads: %v", err) slog.Error("could not restore existing downloads", "error", err)
} }
sessions := auth.NewSessionStore(settings.SessionTTL) sessions := auth.NewSessionStore(settings.SessionTTL)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: settings.ListenAddr, Addr: settings.ListenAddr,
Handler: server.New(settings, sessions, manager), Handler: server.New(settings, sessions, manager),
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelWarn),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,
@@ -71,20 +69,41 @@ func main() {
defer stopListening() defer stopListening()
go func() { go func() {
log.Printf("listening on %s", settings.ListenAddr) slog.Info("server started",
"listen", settings.ListenAddr,
"download_dir", settings.DownloadDir,
"library_roots", settings.LibraryRoots,
"log_level", settings.LogLevel,
)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err) logging.Fatal("server stopped unexpectedly", err)
} }
}() }()
<-shutdownSignal.Done() <-shutdownSignal.Done()
log.Printf("shutting down") slog.Info("shutting down, saving download progress")
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownContext, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel() defer cancel()
manager.Shutdown(shutdownContext) manager.Shutdown(shutdownContext)
if err := httpServer.Shutdown(shutdownContext); err != nil { if err := httpServer.Shutdown(shutdownContext); err != nil {
log.Printf("shutdown error: %v", err) slog.Error("shutdown did not finish cleanly", "error", err)
} }
slog.Info("stopped")
}
func prepareDownloadDir(settings *config.Settings) error {
if err := os.MkdirAll(settings.DownloadDir, 0o755); err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(settings.DownloadDir)
if err != nil {
return err
}
settings.DownloadDir = resolved
if err := ensureWritable(resolved); err != nil {
return fmt.Errorf("not writable by uid %d gid %d: %w", os.Getuid(), os.Getgid(), err)
}
return nil
} }
func ensureWritable(directory string) error { func ensureWritable(directory string) error {
+2
View File
@@ -42,6 +42,8 @@
</div> </div>
</header> </header>
<div id="connection-banner" class="banner" role="status" hidden></div>
<main class="content"> <main class="content">
<section class="card"> <section class="card">
<h2>New download</h2> <h2>New download</h2>
+42 -5
View File
@@ -1,8 +1,22 @@
export class ApiError extends Error { export class ApiError extends Error {
constructor(status, message) { constructor(status, message, requestId = "") {
super(message); super(message);
this.status = status; this.status = status;
this.requestId = requestId;
} }
get isNetworkError() {
return this.status === 0;
}
}
export function describeError(error) {
const message = error && error.message ? error.message : "Something went wrong.";
const alreadyReferenced = message.includes("reference");
if (error instanceof ApiError && error.requestId && error.status >= 500 && !alreadyReferenced) {
return `${message} (reference ${error.requestId})`;
}
return message;
} }
const unauthorizedListeners = new Set(); const unauthorizedListeners = new Set();
@@ -20,6 +34,22 @@ function parseBody(text) {
} }
} }
function fallbackMessage(status) {
if (status === 502 || status === 503 || status === 504) {
return `The server is not responding right now (${status}). Try again in a moment.`;
}
if (status === 413) return "The request was too large.";
return `Request failed (${status}).`;
}
async function send(path, options) {
try {
return await fetch(path, options);
} catch {
throw new ApiError(0, "Cannot reach the server. Check your connection.");
}
}
async function request(method, path, body) { async function request(method, path, body) {
const options = { method, credentials: "same-origin", headers: {} }; const options = { method, credentials: "same-origin", headers: {} };
if (body !== undefined) { if (body !== undefined) {
@@ -27,15 +57,22 @@ async function request(method, path, body) {
options.body = JSON.stringify(body); options.body = JSON.stringify(body);
} }
const response = await fetch(path, options); const response = await send(path, options);
const data = parseBody(await response.text()); let text = "";
try {
text = await response.text();
} catch {
throw new ApiError(0, "The connection dropped while reading the server response.");
}
const data = parseBody(text);
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && path !== "/api/login") { if (response.status === 401 && path !== "/api/login") {
unauthorizedListeners.forEach((listener) => listener()); unauthorizedListeners.forEach((listener) => listener());
} }
const message = data && data.error ? data.error : `Request failed (${response.status})`; const message = data && data.error ? data.error : fallbackMessage(response.status);
throw new ApiError(response.status, message); const requestId = (data && data.requestId) || response.headers.get("X-Request-ID") || "";
throw new ApiError(response.status, message, requestId);
} }
return data; return data;
} }
+31 -10
View File
@@ -1,4 +1,4 @@
import { api, onUnauthorized } from "./api.js"; import { api, describeError, onUnauthorized } from "./api.js";
import { element } from "./dom.js"; import { element } from "./dom.js";
import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js"; import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js";
import { formatBytes } from "./format.js"; import { formatBytes } from "./format.js";
@@ -12,6 +12,7 @@ import { renderTree, topLevelSelection } from "./tree.js";
const pollIntervalMs = 1500; const pollIntervalMs = 1500;
const filterStorageKey = "ddc-job-filter"; const filterStorageKey = "ddc-job-filter";
const failuresBeforeBanner = 2;
const elements = { const elements = {
loginView: document.getElementById("login-view"), loginView: document.getElementById("login-view"),
@@ -21,6 +22,7 @@ const elements = {
logoutButton: document.getElementById("logout-button"), logoutButton: document.getElementById("logout-button"),
settingsButton: document.getElementById("settings-button"), settingsButton: document.getElementById("settings-button"),
diskStatus: document.getElementById("disk-status"), diskStatus: document.getElementById("disk-status"),
connectionBanner: document.getElementById("connection-banner"),
jobFilter: document.getElementById("job-filter"), jobFilter: document.getElementById("job-filter"),
clearCompletedButton: document.getElementById("clear-completed-button"), clearCompletedButton: document.getElementById("clear-completed-button"),
jobList: document.getElementById("job-list"), jobList: document.getElementById("job-list"),
@@ -53,10 +55,12 @@ const state = {
selection: new Set(), selection: new Set(),
pollTimer: null, pollTimer: null,
refreshing: false, refreshing: false,
failedRefreshes: 0,
}; };
function showLogin() { function showLogin() {
stopPolling(); stopPolling();
setConnectionProblem(null);
closeDetail(); closeDetail();
elements.appView.hidden = true; elements.appView.hidden = true;
elements.loginView.hidden = false; elements.loginView.hidden = false;
@@ -82,7 +86,7 @@ async function showSettings() {
try { try {
await openSettings(); await openSettings();
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(describeError(error));
} }
} }
@@ -127,15 +131,31 @@ async function refresh() {
updateSpeeds(state.jobs); updateSpeeds(state.jobs);
handleTransitions(collectTransitions(state.jobs)); handleTransitions(collectTransitions(state.jobs));
elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`; elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`;
setConnectionProblem(null);
renderJobList(); renderJobList();
await syncDetail(); await syncDetail();
} catch (error) { } catch (error) {
if (error.status !== 401) console.error(error); if (error.status !== 401) {
console.error(error);
setConnectionProblem(error);
}
} finally { } finally {
state.refreshing = false; state.refreshing = false;
} }
} }
function setConnectionProblem(error) {
if (!error) {
state.failedRefreshes = 0;
elements.connectionBanner.hidden = true;
return;
}
state.failedRefreshes += 1;
if (state.failedRefreshes < failuresBeforeBanner) return;
elements.connectionBanner.textContent = `${describeError(error)} Retrying automatically.`;
elements.connectionBanner.hidden = false;
}
function handleTransitions(transitions) { function handleTransitions(transitions) {
notifyTransitions(transitions); notifyTransitions(transitions);
if (transitions.some((transition) => transition.previous === "moving")) loadHistory(); if (transitions.some((transition) => transition.previous === "moving")) loadHistory();
@@ -166,7 +186,7 @@ async function runJobAction(action) {
await action(); await action();
await refresh(); await refresh();
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(describeError(error));
} }
} }
@@ -183,7 +203,7 @@ function openDetail(job) {
renderJobList(); renderJobList();
syncDetail(); syncDetail();
loadLibraries().catch((error) => { loadLibraries().catch((error) => {
elements.actionError.textContent = error.message; elements.actionError.textContent = describeError(error);
}); });
elements.detail.scrollIntoView({ behavior: "smooth", block: "start" }); elements.detail.scrollIntoView({ behavior: "smooth", block: "start" });
} }
@@ -230,7 +250,7 @@ async function loadTree(jobId) {
}); });
updateSelectionSummary(); updateSelectionSummary();
} catch (error) { } catch (error) {
elements.actionError.textContent = error.message; elements.actionError.textContent = describeError(error);
} }
} }
@@ -247,7 +267,7 @@ async function extractArchive(path) {
await api.extract(state.selectedJobId, path); await api.extract(state.selectedJobId, path);
await refresh(); await refresh();
} catch (error) { } catch (error) {
elements.actionError.textContent = error.message; elements.actionError.textContent = describeError(error);
} }
} }
@@ -266,7 +286,7 @@ async function moveSelection() {
elements.subfolderInput.value = ""; elements.subfolderInput.value = "";
await refresh(); await refresh();
} catch (error) { } catch (error) {
elements.actionError.textContent = error.message; elements.actionError.textContent = describeError(error);
} }
} }
@@ -281,7 +301,7 @@ async function deleteJob(job) {
if (job.id === state.selectedJobId) closeDetail(); if (job.id === state.selectedJobId) closeDetail();
await refresh(); await refresh();
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(describeError(error));
} }
} }
@@ -304,7 +324,7 @@ async function clearHistory() {
await api.clearHistory(); await api.clearHistory();
await loadHistory(); await loadHistory();
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(describeError(error));
} }
} }
@@ -336,6 +356,7 @@ async function logout() {
} }
async function init() { async function init() {
window.addEventListener("unhandledrejection", (event) => console.error("Unhandled error", event.reason));
onUnauthorized(showLogin); onUnauthorized(showLogin);
setupLogin(elements.loginForm, elements.loginError, showApp); setupLogin(elements.loginForm, elements.loginError, showApp);
setupDownloadForm(refresh); setupDownloadForm(refresh);
+2 -2
View File
@@ -1,4 +1,4 @@
import { api } from "./api.js"; import { api, describeError } from "./api.js";
const fields = { const fields = {
form: document.getElementById("download-form"), form: document.getElementById("download-form"),
@@ -61,7 +61,7 @@ async function submitDownloads(event, onCreated) {
}); });
} catch (error) { } catch (error) {
failedUrls.push(url); failedUrls.push(url);
if (!firstError) firstError = error.message; if (!firstError) firstError = describeError(error);
} }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { api } from "./api.js"; import { api, describeError } from "./api.js";
import { element } from "./dom.js"; import { element } from "./dom.js";
import { notificationsEnabled, setNotificationsEnabled } from "./notify.js"; import { notificationsEnabled, setNotificationsEnabled } from "./notify.js";
@@ -77,7 +77,7 @@ async function saveSettings(event, onSaved) {
fields.dialog.close(); fields.dialog.close();
onSaved(data.settings); onSaved(data.settings);
} catch (error) { } catch (error) {
fields.error.textContent = error.message; fields.error.textContent = describeError(error);
} }
} }
+10
View File
@@ -349,6 +349,16 @@ button.block {
gap: 8px; gap: 8px;
} }
.banner {
background: var(--warning-soft);
color: var(--warning);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
text-align: center;
font-size: 0.9rem;
font-weight: 600;
}
.content { .content {
max-width: 980px; max-width: 980px;
margin: 0 auto; margin: 0 auto;