package handler import ( "context" "fmt" "log/slog" "net/http" "net/url" "strconv" "strings" "time" "vidarchive/internal/models" "vidarchive/internal/tools" ) // toolOpTimeout bounds an install or update. A slow mirror should not pin a // request or a goroutine forever. const toolOpTimeout = 10 * time.Minute type ToolsView struct { YTDLPVersion string YTDLPPath string YTDLPMode string DenoVersion string DenoPath string DenoMode string AutoUpdate bool JSRuntime bool CheckedAt time.Time } // toolMode is the label for who owns a binary and who may update it. func toolMode(managed, updatable bool) string { switch { case managed: return "managed" case updatable: return "external, updates enabled" default: return "external" } } func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) { presets, err := h.presetSvc.GetAll() if err != nil { h.serverError(w, r, "list presets", err) return } settings, err := h.settingsSvc.GetAll() if err != nil { h.serverError(w, r, "load settings", err) return } versions := h.toolVersions(r.Context()) toolsView := ToolsView{ YTDLPVersion: versions["yt-dlp"], YTDLPPath: h.cfg.YTDLPPath, YTDLPMode: toolMode(h.cfg.YTDLPManaged, h.cfg.CanUpdateYTDLP()), DenoVersion: versions["deno"], DenoPath: h.cfg.DenoPath, DenoMode: toolMode(h.cfg.DenoManaged, h.cfg.CanUpdateDeno()), AutoUpdate: settings.ToolAutoUpdate, JSRuntime: settings.JSRuntimeEnabled, CheckedAt: settings.ToolsCheckedAt, } if toolsView.DenoVersion == "" { toolsView.DenoVersion = "not installed" } h.renderWithRequest(w, r, "settings", PageData{ Title: "Settings", ActiveTab: "settings", Data: struct { Presets []*models.Preset Settings *models.Settings Tools ToolsView }{ Presets: presets, Settings: settings, Tools: toolsView, }, }) } // UpdateTools runs the managed tools' own updaters and waits for them. The app // has no JavaScript, so a blocking POST is the only way to report a real result. // // ponytail: blocks one request for up to toolOpTimeout. Move to a queued job if // operators start updating from flaky connections. func (h *Handler) UpdateTools(w http.ResponseWriter, r *http.Request) { // Deliberately not r.Context(): a closed tab must not cancel a replacement // half way through. ctx, cancel := context.WithTimeout(context.Background(), toolOpTimeout) defer cancel() summary, err := h.tools.Update(ctx) h.invalidateToolVersions() if err != nil { // summary still holds whatever did update. Dropping it would invite a // second, redundant click. message := "Tool update failed: " + err.Error() if summary != "" { message = summary + " " + message } redirectWithError(w, r, "/settings", message, err) return } redirectWithSuccess(w, r, "/settings", summary) } // UpdateToolSettings owns its own form. Folding it into the main settings form // would let a submit from either one clear the other's checkboxes. func (h *Handler) UpdateToolSettings(w http.ResponseWriter, r *http.Request) { if !parseForm(w, r) { return } jsRuntime := r.FormValue("js_runtime_enabled") == "1" if err := h.settingsSvc.SetToolAutoUpdate(r.FormValue("tool_auto_update") == "1"); err != nil { redirectWithError(w, r, "/settings", "Couldn't save the tool settings.", err) return } if err := h.settingsSvc.SetJSRuntimeEnabled(jsRuntime); err != nil { redirectWithError(w, r, "/settings", "Couldn't save the tool settings.", err) return } if jsRuntime && h.cfg.DenoManaged && !tools.Installed(h.cfg.DenoPath) { h.installJSRuntime() redirectWithSuccess(w, r, "/settings", "Tool settings saved. The JS runtime is downloading in the background.") return } redirectWithSuccess(w, r, "/settings", "Tool settings saved.") } // installJSRuntime detaches because deno is a large download and the request // should not wait for it. The settings page shows the version once it lands. func (h *Handler) installJSRuntime() { go func() { ctx, cancel := context.WithTimeout(context.Background(), toolOpTimeout) defer cancel() if err := h.tools.Ensure(ctx, true); err != nil { slog.Error("JS runtime install failed", "err", err) return } h.invalidateToolVersions() }() } func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) { if !parseForm(w, r) { return } preset := &models.Preset{} if err := applyPresetForm(preset, r); err != nil { redirectWithError(w, r, "/settings", err.Error(), nil) return } if err := h.presetSvc.Save(preset); err != nil { redirectWithError(w, r, "/settings", "Couldn't create this preset.", err) return } redirectWithSuccess(w, r, "/settings", "Preset created.") } // applyPresetForm is shared by create and update, so the two cannot drift apart // as fields are added. func applyPresetForm(p *models.Preset, r *http.Request) error { name := strings.TrimSpace(r.FormValue("name")) if name == "" { return fmt.Errorf("A preset needs a name.") } // Mirrors the settings form's radio options. Empty means unspecified, and // BuildArgs applies its own default. formatMode := r.FormValue("format_mode") switch formatMode { case "", "default", "preset", "custom": default: return fmt.Errorf("Unknown format mode %q.", formatMode) } p.Name = name p.Description = r.FormValue("description") p.FormatMode = formatMode p.Format = r.FormValue("format") p.Quality = r.FormValue("quality") p.CustomFormat = r.FormValue("custom_format") p.AudioFormat = r.FormValue("audio_format") p.SubLangs = r.FormValue("sub_langs") p.CustomFlags = r.FormValue("custom_flags") p.IsDefault = r.FormValue("is_default") == "1" p.ExtractAudio = r.FormValue("extract_audio") == "1" p.EmbedSubs = r.FormValue("embed_subs") == "1" p.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1" p.EmbedMetadata = r.FormValue("embed_metadata") == "1" p.WriteInfoJSON = r.FormValue("write_info_json") == "1" p.WriteComments = r.FormValue("write_comments") == "1" p.CommentSort = strings.TrimSpace(r.FormValue("comment_sort")) p.CommentExtractorArgs = strings.TrimSpace(r.FormValue("comment_extractor_args")) p.MaxComments = 0 if raw := strings.TrimSpace(r.FormValue("max_comments")); raw != "" { maxComments, err := strconv.Atoi(raw) if err != nil || maxComments < 0 { return fmt.Errorf("Max comments must be a non-negative number.") } p.MaxComments = maxComments } // Comments live in the info JSON sidecar, so the dependent options must stay // consistent even for a client that submits the form without JavaScript. if !p.WriteInfoJSON || !p.WriteComments { p.WriteComments = false p.CommentSort = "" p.MaxComments = 0 p.CommentExtractorArgs = "" } return nil } func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) { id, ok := parseID(w, r) if !ok { return } if !parseForm(w, r) { return } preset, err := h.presetSvc.GetByID(id) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } if err := applyPresetForm(preset, r); err != nil { redirectWithError(w, r, "/settings", err.Error(), nil) return } if err := h.presetSvc.Save(preset); err != nil { redirectWithError(w, r, "/settings", "Couldn't update this preset.", err) return } redirectWithSuccess(w, r, "/settings", "Preset updated.") } func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) { id, ok := parseID(w, r) if !ok { return } if err := h.presetSvc.Delete(id); err != nil { redirectWithError(w, r, "/settings", "Couldn't delete this preset.", err) return } redirectWithSuccess(w, r, "/settings", "Preset deleted.") } func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { if !parseForm(w, r) { return } var firstErr error record := func(err error) { if err != nil && firstErr == nil { firstErr = err } } if interval := r.FormValue("refresh_interval"); interval != "" { record(h.settingsSvc.SetRefreshInterval(interval)) } record(h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1")) record(h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1")) record(h.settingsSvc.SetCookies(r.FormValue("cookies"))) if firstErr != nil { slog.Error("failed to update settings", "err", firstErr) flashError(w, "Some settings couldn't be saved: "+firstErr.Error()) } else { flashSuccess(w, "Settings saved.") } http.Redirect(w, r, "/settings", http.StatusSeeOther) } func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) { if !parseForm(w, r) { return } theme := r.FormValue("theme") if theme == "" { theme = "auto" } setCookie(w, "theme", theme) http.Redirect(w, r, localReferer(r), http.StatusSeeOther) } // localReferer keeps only the path: Referer is attacker-controlled, so honouring // its host would make this route an open redirect. func localReferer(r *http.Request) string { ref, err := url.Parse(r.Header.Get("Referer")) if err != nil || !strings.HasPrefix(ref.Path, "/") { return "/" } // Browsers read "//host" and "/\host" as protocol-relative URLs, so such a // path still redirects off-site. if strings.HasPrefix(ref.Path, "//") || strings.HasPrefix(ref.Path, `/\`) { return "/" } if ref.RawQuery == "" { return ref.Path } return ref.Path + "?" + ref.RawQuery }