settings.go
⎇
Raw
1package handler
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "net/url"
9 "strconv"
10 "strings"
11 "time"
12
13 "vidarchive/internal/models"
14 "vidarchive/internal/tools"
15)
16
17// toolOpTimeout bounds an install or update. A slow mirror should not pin a
18// request or a goroutine forever.
19const toolOpTimeout = 10 * time.Minute
20
21type ToolsView struct {
22 YTDLPVersion string
23 YTDLPPath string
24 YTDLPMode string
25 DenoVersion string
26 DenoPath string
27 DenoMode string
28 AutoUpdate bool
29 JSRuntime bool
30 CheckedAt time.Time
31}
32
33// toolMode is the label for who owns a binary and who may update it.
34func toolMode(managed, updatable bool) string {
35 switch {
36 case managed:
37 return "managed"
38 case updatable:
39 return "external, updates enabled"
40 default:
41 return "external"
42 }
43}
44
45func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
46 presets, err := h.presetSvc.GetAll()
47 if err != nil {
48 h.serverError(w, r, "list presets", err)
49 return
50 }
51
52 settings, err := h.settingsSvc.GetAll()
53 if err != nil {
54 h.serverError(w, r, "load settings", err)
55 return
56 }
57
58 versions := h.toolVersions(r.Context())
59 toolsView := ToolsView{
60 YTDLPVersion: versions["yt-dlp"],
61 YTDLPPath: h.cfg.YTDLPPath,
62 YTDLPMode: toolMode(h.cfg.YTDLPManaged, h.cfg.CanUpdateYTDLP()),
63 DenoVersion: versions["deno"],
64 DenoPath: h.cfg.DenoPath,
65 DenoMode: toolMode(h.cfg.DenoManaged, h.cfg.CanUpdateDeno()),
66 AutoUpdate: settings.ToolAutoUpdate,
67 JSRuntime: settings.JSRuntimeEnabled,
68 CheckedAt: settings.ToolsCheckedAt,
69 }
70 if toolsView.DenoVersion == "" {
71 toolsView.DenoVersion = "not installed"
72 }
73
74 h.renderWithRequest(w, r, "settings", PageData{
75 Title: "Settings",
76 ActiveTab: "settings",
77 Data: struct {
78 Presets []*models.Preset
79 Settings *models.Settings
80 Tools ToolsView
81 }{
82 Presets: presets,
83 Settings: settings,
84 Tools: toolsView,
85 },
86 })
87}
88
89// UpdateTools runs the managed tools' own updaters and waits for them. The app
90// has no JavaScript, so a blocking POST is the only way to report a real result.
91//
92// ponytail: blocks one request for up to toolOpTimeout. Move to a queued job if
93// operators start updating from flaky connections.
94func (h *Handler) UpdateTools(w http.ResponseWriter, r *http.Request) {
95 // Deliberately not r.Context(): a closed tab must not cancel a replacement
96 // half way through.
97 ctx, cancel := context.WithTimeout(context.Background(), toolOpTimeout)
98 defer cancel()
99
100 summary, err := h.tools.Update(ctx)
101 h.invalidateToolVersions()
102 if err != nil {
103 // summary still holds whatever did update. Dropping it would invite a
104 // second, redundant click.
105 message := "Tool update failed: " + err.Error()
106 if summary != "" {
107 message = summary + " " + message
108 }
109 redirectWithError(w, r, "/settings", message, err)
110 return
111 }
112 redirectWithSuccess(w, r, "/settings", summary)
113}
114
115// UpdateToolSettings owns its own form. Folding it into the main settings form
116// would let a submit from either one clear the other's checkboxes.
117func (h *Handler) UpdateToolSettings(w http.ResponseWriter, r *http.Request) {
118 if !parseForm(w, r) {
119 return
120 }
121
122 jsRuntime := r.FormValue("js_runtime_enabled") == "1"
123 if err := h.settingsSvc.SetToolAutoUpdate(r.FormValue("tool_auto_update") == "1"); err != nil {
124 redirectWithError(w, r, "/settings", "Couldn't save the tool settings.", err)
125 return
126 }
127 if err := h.settingsSvc.SetJSRuntimeEnabled(jsRuntime); err != nil {
128 redirectWithError(w, r, "/settings", "Couldn't save the tool settings.", err)
129 return
130 }
131
132 if jsRuntime && h.cfg.DenoManaged && !tools.Installed(h.cfg.DenoPath) {
133 h.installJSRuntime()
134 redirectWithSuccess(w, r, "/settings", "Tool settings saved. The JS runtime is downloading in the background.")
135 return
136 }
137 redirectWithSuccess(w, r, "/settings", "Tool settings saved.")
138}
139
140// installJSRuntime detaches because deno is a large download and the request
141// should not wait for it. The settings page shows the version once it lands.
142func (h *Handler) installJSRuntime() {
143 go func() {
144 ctx, cancel := context.WithTimeout(context.Background(), toolOpTimeout)
145 defer cancel()
146 if err := h.tools.Ensure(ctx, true); err != nil {
147 slog.Error("JS runtime install failed", "err", err)
148 return
149 }
150 h.invalidateToolVersions()
151 }()
152}
153
154func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
155 if !parseForm(w, r) {
156 return
157 }
158
159 preset := &models.Preset{}
160 if err := applyPresetForm(preset, r); err != nil {
161 redirectWithError(w, r, "/settings", err.Error(), nil)
162 return
163 }
164
165 if err := h.presetSvc.Save(preset); err != nil {
166 redirectWithError(w, r, "/settings", "Couldn't create this preset.", err)
167 return
168 }
169
170 redirectWithSuccess(w, r, "/settings", "Preset created.")
171}
172
173// applyPresetForm is shared by create and update, so the two cannot drift apart
174// as fields are added.
175func applyPresetForm(p *models.Preset, r *http.Request) error {
176 name := strings.TrimSpace(r.FormValue("name"))
177 if name == "" {
178 return fmt.Errorf("A preset needs a name.")
179 }
180
181 // Mirrors the settings form's radio options. Empty means unspecified, and
182 // BuildArgs applies its own default.
183 formatMode := r.FormValue("format_mode")
184 switch formatMode {
185 case "", "default", "preset", "custom":
186 default:
187 return fmt.Errorf("Unknown format mode %q.", formatMode)
188 }
189
190 p.Name = name
191 p.Description = r.FormValue("description")
192 p.FormatMode = formatMode
193 p.Format = r.FormValue("format")
194 p.Quality = r.FormValue("quality")
195 p.CustomFormat = r.FormValue("custom_format")
196 p.AudioFormat = r.FormValue("audio_format")
197 p.SubLangs = r.FormValue("sub_langs")
198 p.CustomFlags = r.FormValue("custom_flags")
199 p.IsDefault = r.FormValue("is_default") == "1"
200 p.ExtractAudio = r.FormValue("extract_audio") == "1"
201 p.EmbedSubs = r.FormValue("embed_subs") == "1"
202 p.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
203 p.EmbedMetadata = r.FormValue("embed_metadata") == "1"
204 p.WriteInfoJSON = r.FormValue("write_info_json") == "1"
205 p.WriteComments = r.FormValue("write_comments") == "1"
206 p.CommentSort = strings.TrimSpace(r.FormValue("comment_sort"))
207 p.CommentExtractorArgs = strings.TrimSpace(r.FormValue("comment_extractor_args"))
208 p.MaxComments = 0
209 if raw := strings.TrimSpace(r.FormValue("max_comments")); raw != "" {
210 maxComments, err := strconv.Atoi(raw)
211 if err != nil || maxComments < 0 {
212 return fmt.Errorf("Max comments must be a non-negative number.")
213 }
214 p.MaxComments = maxComments
215 }
216 // Comments live in the info JSON sidecar, so the dependent options must stay
217 // consistent even for a client that submits the form without JavaScript.
218 if !p.WriteInfoJSON || !p.WriteComments {
219 p.WriteComments = false
220 p.CommentSort = ""
221 p.MaxComments = 0
222 p.CommentExtractorArgs = ""
223 }
224
225 return nil
226}
227
228func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
229 id, ok := parseID(w, r)
230 if !ok {
231 return
232 }
233
234 if !parseForm(w, r) {
235 return
236 }
237
238 preset, err := h.presetSvc.GetByID(id)
239 if err != nil {
240 http.Error(w, "Not found", http.StatusNotFound)
241 return
242 }
243
244 if err := applyPresetForm(preset, r); err != nil {
245 redirectWithError(w, r, "/settings", err.Error(), nil)
246 return
247 }
248
249 if err := h.presetSvc.Save(preset); err != nil {
250 redirectWithError(w, r, "/settings", "Couldn't update this preset.", err)
251 return
252 }
253
254 redirectWithSuccess(w, r, "/settings", "Preset updated.")
255}
256
257func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) {
258 id, ok := parseID(w, r)
259 if !ok {
260 return
261 }
262
263 if err := h.presetSvc.Delete(id); err != nil {
264 redirectWithError(w, r, "/settings", "Couldn't delete this preset.", err)
265 return
266 }
267
268 redirectWithSuccess(w, r, "/settings", "Preset deleted.")
269}
270
271func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
272 if !parseForm(w, r) {
273 return
274 }
275
276 var firstErr error
277 record := func(err error) {
278 if err != nil && firstErr == nil {
279 firstErr = err
280 }
281 }
282
283 if interval := r.FormValue("refresh_interval"); interval != "" {
284 record(h.settingsSvc.SetRefreshInterval(interval))
285 }
286 record(h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1"))
287 record(h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1"))
288 record(h.settingsSvc.SetCookies(r.FormValue("cookies")))
289
290 if firstErr != nil {
291 slog.Error("failed to update settings", "err", firstErr)
292 flashError(w, "Some settings couldn't be saved: "+firstErr.Error())
293 } else {
294 flashSuccess(w, "Settings saved.")
295 }
296 http.Redirect(w, r, "/settings", http.StatusSeeOther)
297}
298
299func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
300 if !parseForm(w, r) {
301 return
302 }
303
304 theme := r.FormValue("theme")
305 if theme == "" {
306 theme = "auto"
307 }
308
309 setCookie(w, "theme", theme)
310
311 http.Redirect(w, r, localReferer(r), http.StatusSeeOther)
312}
313
314// localReferer keeps only the path: Referer is attacker-controlled, so honouring
315// its host would make this route an open redirect.
316func localReferer(r *http.Request) string {
317 ref, err := url.Parse(r.Header.Get("Referer"))
318 if err != nil || !strings.HasPrefix(ref.Path, "/") {
319 return "/"
320 }
321 // Browsers read "//host" and "/\host" as protocol-relative URLs, so such a
322 // path still redirects off-site.
323 if strings.HasPrefix(ref.Path, "//") || strings.HasPrefix(ref.Path, `/\`) {
324 return "/"
325 }
326 if ref.RawQuery == "" {
327 return ref.Path
328 }
329 return ref.Path + "?" + ref.RawQuery
330}
331