Harden web/download pipeline and split handler modules

Replace shell-based downloader execution with validated arguments, enforce request hardening and safer defaults, and refactor handlers/router/state so job control is safer and easier to maintain.
This commit is contained in:
2026-04-14 10:21:11 +02:00
parent 6e016b802b
commit 1c82b619c4
25 changed files with 1722 additions and 667 deletions

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
@@ -21,23 +22,131 @@ type JobInfo struct {
AbortChan chan struct{}
ResumeChan chan struct{}
Cmd *exec.Cmd
Paused bool
TempDir string
mu sync.RWMutex
paused bool
abortOnce sync.Once
}
func NewJobInfo() *JobInfo {
return &JobInfo{
AbortChan: make(chan struct{}),
ResumeChan: make(chan struct{}, 1),
}
}
func (j *JobInfo) SetPaused(value bool) {
j.mu.Lock()
defer j.mu.Unlock()
j.paused = value
}
func (j *JobInfo) IsPaused() bool {
j.mu.RLock()
defer j.mu.RUnlock()
return j.paused
}
func (j *JobInfo) SetCmd(cmd *exec.Cmd) {
j.mu.Lock()
defer j.mu.Unlock()
j.Cmd = cmd
}
func (j *JobInfo) SetTempDir(tempDir string) {
j.mu.Lock()
defer j.mu.Unlock()
j.TempDir = tempDir
}
func (j *JobInfo) GetTempDir() string {
j.mu.RLock()
defer j.mu.RUnlock()
return j.TempDir
}
func (j *JobInfo) KillProcess() {
j.mu.RLock()
cmd := j.Cmd
j.mu.RUnlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
func (j *JobInfo) SignalResume() {
select {
case j.ResumeChan <- struct{}{}:
default:
}
}
func (j *JobInfo) Abort() {
j.abortOnce.Do(func() {
close(j.AbortChan)
})
}
func (j *JobInfo) IsAborted() bool {
select {
case <-j.AbortChan:
return true
default:
return false
}
}
var (
jobsMutex sync.Mutex
jobs = make(map[string]*JobInfo)
ErrDownloadPaused = errors.New("download paused")
ErrDownloadAborted = errors.New("download aborted")
)
var sanitizeFilenameRegex = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
var windowsReservedNames = map[string]struct{}{
"CON": {}, "PRN": {}, "AUX": {}, "NUL": {},
"COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {}, "COM6": {}, "COM7": {}, "COM8": {}, "COM9": {},
"LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {}, "LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {},
}
func sanitizeFilename(filename string) string {
filename = regexp.MustCompile(`[<>:"/\\|?*]`).ReplaceAllString(filename, "_")
filename = sanitizeFilenameRegex.ReplaceAllString(filename, "_")
filename = strings.Trim(filename, ". ")
filename = strings.Trim(filename, ".")
base := filename
if idx := strings.LastIndex(filename, "."); idx > 0 {
base = filename[:idx]
}
if _, reserved := windowsReservedNames[strings.ToUpper(base)]; reserved {
filename = "_" + filename
}
if filename == "" {
filename = "_"
}
return filename
}
func safeUploadPath(filename string) (string, error) {
cleanName := strings.TrimSpace(filename)
if cleanName == "" {
return "", fmt.Errorf("filename is required")
}
baseName := filepath.Base(cleanName)
if baseName != cleanName {
return "", fmt.Errorf("invalid filename")
}
if strings.Contains(baseName, "..") {
return "", fmt.Errorf("invalid filename")
}
return filepath.Join(uploadDir, baseName), nil
}
func isValidURL(toTest string) bool {
_, err := url.ParseRequestURI(toTest)
return err == nil
@@ -185,13 +294,14 @@ func groupItemsBySeason(items []Item) map[string][]Item {
}
func filterSelectedItems(items []Item, selectedItems []string) []Item {
var filtered []Item
set := make(map[string]struct{}, len(selectedItems))
for _, s := range selectedItems {
set[s] = struct{}{}
}
filtered := make([]Item, 0, len(selectedItems))
for _, item := range items {
for _, selected := range selectedItems {
if item.Filename == selected {
filtered = append(filtered, item)
break
}
if _, ok := set[item.Filename]; ok {
filtered = append(filtered, item)
}
}
return filtered
@@ -225,31 +335,27 @@ func extractNumber(s string) int {
return num
}
var episodeNumberRegex = regexp.MustCompile(`(?i)S\d+E(\d+)`)
func extractEpisodeNumber(filename string) int {
parts := strings.Split(filename, "E")
if len(parts) > 1 {
num, _ := strconv.Atoi(parts[1])
return num
match := episodeNumberRegex.FindStringSubmatch(filename)
if len(match) < 2 {
return 0
}
return 0
num, _ := strconv.Atoi(match[1])
return num
}
func processItems(filename string, items []Item) error {
jobsMutex.Lock()
jobInfo := &JobInfo{
AbortChan: make(chan struct{}),
ResumeChan: make(chan struct{}),
}
jobs[filename] = jobInfo
jobsMutex.Unlock()
jobInfo := NewJobInfo()
setJob(filename, jobInfo)
defer func() {
jobsMutex.Lock()
delete(jobs, filename)
jobsMutex.Unlock()
removeJob(filename)
if jobInfo.TempDir != "" {
os.RemoveAll(jobInfo.TempDir)
tempDir := jobInfo.GetTempDir()
if tempDir != "" {
_ = os.RemoveAll(tempDir)
}
}()
@@ -258,34 +364,45 @@ func processItems(filename string, items []Item) error {
for i := 0; i < len(items); i++ {
select {
case <-jobInfo.AbortChan:
updateProgress(filename, 100, "Aborted")
updateProgress(filename, 100, "Aborted", "aborted")
logger.LogJobState(filename, "aborted")
return fmt.Errorf("download aborted")
return ErrDownloadAborted
default:
if jobInfo.Paused {
if jobInfo.IsPaused() {
select {
case <-jobInfo.ResumeChan:
jobInfo.Paused = false
jobInfo.SetPaused(false)
logger.LogJobState(filename, "resumed")
case <-jobInfo.AbortChan:
updateProgress(filename, 100, "Aborted")
updateProgress(filename, 100, "Aborted", "aborted")
logger.LogJobState(filename, "aborted")
return fmt.Errorf("download aborted")
return ErrDownloadAborted
}
}
updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename)
updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename, "running")
err := downloadFile(filename, items[i], jobInfo)
if err != nil {
if err.Error() == "download paused" {
if errors.Is(err, ErrDownloadPaused) {
logger.LogJobState(filename, "paused")
removeCompletedEpisodes(filename, items[:i])
if remErr := removeCompletedEpisodes(filename, items[:i]); remErr != nil {
logger.LogError("Process Items", fmt.Sprintf("Error updating partial progress file: %v", remErr))
}
i--
continue
}
if errors.Is(err, ErrDownloadAborted) {
updateProgress(filename, 100, "Aborted", "aborted")
logger.LogJobState(filename, "aborted")
return ErrDownloadAborted
}
updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename, "failed")
logger.LogError("Process Items", fmt.Sprintf("Error downloading item %s: %v", items[i].Filename, err))
return fmt.Errorf("error downloading %s: %w", items[i].Filename, err)
}
}
}
updateProgress(filename, 100, "")
updateProgress(filename, 100, "", "completed")
logger.LogJobState(filename, "completed successfully")
return nil
}