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:
193
src/utils.go
193
src/utils.go
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user