server_test.go
⎇
Raw
1package server
2
3import (
4 "database/sql"
5 "html"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "testing"
16
17 "vidarchive/internal/config"
18 "vidarchive/internal/database"
19 "vidarchive/internal/handler"
20 "vidarchive/internal/repository"
21 "vidarchive/internal/service"
22 "vidarchive/internal/worker"
23)
24
25func setupTestServer(t *testing.T) (*Server, *config.Config, func()) {
26 srv, cfg, _, cleanup := setupTestServerDB(t)
27 return srv, cfg, cleanup
28}
29
30// setupTestServerDB is setupTestServer with the database handle exposed, for
31// tests that need to break the database on purpose.
32func setupTestServerDB(t *testing.T) (*Server, *config.Config, *sql.DB, func()) {
33 t.Helper()
34 dataDir := t.TempDir()
35 t.Setenv("VIDARCHIVE_DATA_DIR", dataDir)
36
37 cfg := config.New()
38 if err := os.MkdirAll(cfg.LibraryDir, 0755); err != nil {
39 t.Fatalf("create library dir: %v", err)
40 }
41 if err := os.MkdirAll(cfg.TempDir, 0755); err != nil {
42 t.Fatalf("create temp dir: %v", err)
43 }
44
45 db, err := database.New(cfg)
46 if err != nil {
47 t.Fatalf("init db: %v", err)
48 }
49
50 presetRepo := repository.NewPresetRepository(db)
51 downloadRepo := repository.NewDownloadRepository(db)
52 settingsRepo := repository.NewSettingsRepository(db)
53 subscriptionRepo := repository.NewSubscriptionRepository(db)
54
55 presetSvc := service.NewPresetService(presetRepo)
56 librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath)
57 settingsSvc := service.NewSettingsService(settingsRepo)
58 subscriptionSvc := service.NewSubscriptionService(subscriptionRepo, cfg)
59 downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, subscriptionSvc, cfg)
60 workerPool := worker.New(downloadSvc, cfg.Workers)
61
62 h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, subscriptionSvc, workerPool)
63 if err != nil {
64 t.Fatalf("init handler: %v", err)
65 }
66
67 srv := New(cfg, h)
68 cleanup := func() {
69 workerPool.Stop()
70 db.Close()
71 }
72 return srv, cfg, db, cleanup
73}
74
75func createItem(t *testing.T, libraryDir, relPath, name string, files map[string]string) {
76 t.Helper()
77 itemDir := filepath.Join(libraryDir, relPath)
78 if err := os.MkdirAll(itemDir, 0755); err != nil {
79 t.Fatalf("create item dir: %v", err)
80 }
81 marker := filepath.Join(itemDir, ".vidarchive-item.toml")
82 if err := os.WriteFile(marker, []byte("name = \""+name+"\"\nduration = -1\n"), 0644); err != nil {
83 t.Fatalf("write marker: %v", err)
84 }
85 for filename, content := range files {
86 path := filepath.Join(itemDir, filename)
87 if err := os.WriteFile(path, []byte(content), 0644); err != nil {
88 t.Fatalf("write file %s: %v", filename, err)
89 }
90 }
91}
92
93func TestLibraryEngagementViews(t *testing.T) {
94 srv, cfg, cleanup := setupTestServer(t)
95 defer cleanup()
96
97 createItem(t, cfg.LibraryDir, "engagement", "Engagement", map[string]string{
98 "video.mp4": "dummy video",
99 "info.json": `{"comments":[{"id":"parent","author":"<parent>","text":"safe <comment>","time_text":"today"},{"id":"reply","parent":"parent","author":"<child>","text":"reply","time_text":"today"}],"heatmap":[{"start_time":0,"end_time":10,"value":1}]}`,
100 })
101
102 detail := getWith(srv.Router(), "/library/item/engagement", nil)
103 if detail.Code != http.StatusOK {
104 t.Fatalf("detail status = %d", detail.Code)
105 }
106 body := detail.Body.String()
107 for _, want := range []string{"Playback heatmap", "&lt;parent&gt;", "&lt;child&gt;", "safe &lt;comment&gt;", "replying to <strong>&lt;parent&gt;</strong>", "View all comments"} {
108 if !strings.Contains(body, want) {
109 t.Errorf("detail missing %q", want)
110 }
111 }
112
113 all := getWith(srv.Router(), "/library/comments/engagement", nil)
114 if all.Code != http.StatusOK || !strings.Contains(all.Body.String(), "Engagement comments") {
115 t.Fatalf("comments view status/body unexpected: %d %s", all.Code, all.Body.String())
116 }
117
118 // A real item whose path ends in "comments" must remain reachable through
119 // the item route now that the full comments view has its own namespace.
120 createItem(t, cfg.LibraryDir, "playlist/comments", "Nested Comments Item", map[string]string{
121 "video.mp4": "dummy video",
122 })
123 item := getWith(srv.Router(), "/library/item/playlist/comments", nil)
124 if item.Code != http.StatusOK || !strings.Contains(item.Body.String(), "Nested Comments Item") {
125 t.Fatalf("item path ending in comments was shadowed: %d %s", item.Code, item.Body.String())
126 }
127}
128
129func TestCommentsViewMalformedSidecarIsError(t *testing.T) {
130 srv, cfg, cleanup := setupTestServer(t)
131 defer cleanup()
132
133 createItem(t, cfg.LibraryDir, "bad-comments", "Bad comments", map[string]string{
134 "video.mp4": "dummy video",
135 "info.json": `{"comments":[`,
136 })
137 w := getWith(srv.Router(), "/library/comments/bad-comments", nil)
138 if w.Code != http.StatusInternalServerError {
139 t.Fatalf("malformed comments status = %d, want 500", w.Code)
140 }
141 if !strings.Contains(w.Body.String(), "Something went wrong") {
142 t.Fatalf("malformed comments response = %q", w.Body.String())
143 }
144}
145
146func TestCommentsViewEncodedPath(t *testing.T) {
147 srv, cfg, cleanup := setupTestServer(t)
148 defer cleanup()
149
150 const item = "nested/An Item +"
151 createItem(t, cfg.LibraryDir, item, "Encoded comments", map[string]string{
152 "video.mp4": "dummy video",
153 "info.json": `{"comments":[{"author":"author","text":"hello"}]}`,
154 })
155
156 reqURL := "/library/comments/" + (&url.URL{Path: item}).EscapedPath()
157 w := getWith(srv.Router(), reqURL, nil)
158 if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Encoded comments") {
159 t.Fatalf("encoded comments route %s: status=%d body=%s", reqURL, w.Code, w.Body.String())
160 }
161}
162
163func TestNestedLibraryItem(t *testing.T) {
164 srv, cfg, cleanup := setupTestServer(t)
165 defer cleanup()
166
167 createItem(t, cfg.LibraryDir, "test/My Item [id]", "My Item", map[string]string{
168 "My Item [id].mp4": "dummy video",
169 })
170
171 router := srv.Router()
172
173 req := httptest.NewRequest("GET", "/library/item/test/My%20Item%20%5Bid%5D", nil)
174 w := httptest.NewRecorder()
175 router.ServeHTTP(w, req)
176 if w.Code != http.StatusOK {
177 body, _ := io.ReadAll(w.Body)
178 t.Errorf("expected 200, got %d: %s", w.Code, string(body))
179 }
180}
181
182// A directory whose name contains a literal '+' must round-trip: in a URL path
183// '+' is a literal plus (not a space), reachable raw or percent-encoded.
184func TestLiteralPlusInPathSegment(t *testing.T) {
185 srv, cfg, cleanup := setupTestServer(t)
186 defer cleanup()
187
188 createItem(t, cfg.LibraryDir, "C++ Tutorial", "C++ Tutorial", map[string]string{
189 "C++ Tutorial.mp4": "dummy video",
190 })
191
192 router := srv.Router()
193 for _, path := range []string{
194 "/library/item/C++%20Tutorial",
195 "/library/item/C%2B%2B%20Tutorial",
196 } {
197 req := httptest.NewRequest("GET", path, nil)
198 w := httptest.NewRecorder()
199 router.ServeHTTP(w, req)
200 if w.Code != http.StatusOK {
201 body, _ := io.ReadAll(w.Body)
202 t.Errorf("%s: expected 200, got %d: %s", path, w.Code, string(body))
203 }
204 }
205}
206
207func TestMediaFileQueryDecoding(t *testing.T) {
208 srv, cfg, cleanup := setupTestServer(t)
209 defer cleanup()
210
211 createItem(t, cfg.LibraryDir, "My Item [id]", "My Item", map[string]string{
212 "My Item [id].mp4": "dummy video",
213 "My+Other.mp4": "dummy video plus",
214 })
215
216 router := srv.Router()
217
218 tests := []struct {
219 path string
220 expected int
221 }{
222 {"/media/item/My%20Item%20%5Bid%5D?file=My+Item+%5Bid%5D.mp4", http.StatusOK},
223 {"/media/item/My%20Item%20%5Bid%5D?file=My%20Item%20%5Bid%5D.mp4", http.StatusOK},
224 {"/media/item/My%20Item%20%5Bid%5D?file=My%2BOther.mp4", http.StatusOK},
225 {"/media/item/My%20Item%20%5Bid%5D?file=missing.mp4", http.StatusNotFound},
226 }
227 for _, tc := range tests {
228 req := httptest.NewRequest("GET", tc.path, nil)
229 w := httptest.NewRecorder()
230 router.ServeHTTP(w, req)
231 if w.Code != tc.expected {
232 body, _ := io.ReadAll(w.Body)
233 t.Errorf("%s: expected %d, got %d: %s", tc.path, tc.expected, w.Code, string(body))
234 }
235 }
236}
237
238// TestSubtitleServedByPathSegment guards the regression where subtitle tracks
239// are linked as <item>/subtitles/<lang> (language as a trailing path segment),
240// but the handler only recognized the "/subtitles" suffix with a ?lang= query.
241// The path form fell through to media serving and returned 400 "Missing file".
242func TestSubtitleServedByPathSegment(t *testing.T) {
243 srv, cfg, cleanup := setupTestServer(t)
244 defer cleanup()
245
246 // An item whose name contains non-ASCII + spaces, like the reported URL.
247 const item = "ずんだパーリナイ ⧸ なみぐる [ywXQ9SqsaBQ]"
248 createItem(t, cfg.LibraryDir, item, "Vid", map[string]string{
249 "video.mp4": "dummy video",
250 })
251 vtt := "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhi\n"
252 subDir := filepath.Join(cfg.LibraryDir, item, "subtitles")
253 if err := os.MkdirAll(subDir, 0755); err != nil {
254 t.Fatal(err)
255 }
256 if err := os.WriteFile(filepath.Join(subDir, "eng.vtt"), []byte(vtt), 0644); err != nil {
257 t.Fatal(err)
258 }
259
260 router := srv.Router()
261 reqURL := "/media/item/" + (&url.URL{Path: item}).EscapedPath() + "/subtitles/eng"
262 req := httptest.NewRequest("GET", reqURL, nil)
263 w := httptest.NewRecorder()
264 router.ServeHTTP(w, req)
265 if w.Code != http.StatusOK {
266 body, _ := io.ReadAll(w.Body)
267 t.Fatalf("expected 200 for %s, got %d: %s", reqURL, w.Code, string(body))
268 }
269 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/vtt") {
270 t.Errorf("expected text/vtt content-type, got %q", ct)
271 }
272 if body, _ := io.ReadAll(w.Body); !strings.Contains(string(body), "WEBVTT") {
273 t.Errorf("expected the .vtt contents, got %q", string(body))
274 }
275
276 // A traversal attempt in the language segment must be rejected, not served.
277 bad := httptest.NewRequest("GET", "/media/item/"+(&url.URL{Path: item}).EscapedPath()+"/subtitles/..%2f..%2fsecret", nil)
278 bw := httptest.NewRecorder()
279 router.ServeHTTP(bw, bad)
280 if bw.Code == http.StatusOK {
281 t.Errorf("traversal in language segment was served (status %d)", bw.Code)
282 }
283}
284
285func TestPathTraversalBlocked(t *testing.T) {
286 srv, cfg, cleanup := setupTestServer(t)
287 defer cleanup()
288
289 outside := filepath.Join(cfg.DataDir, "secret")
290 if err := os.MkdirAll(outside, 0755); err != nil {
291 t.Fatalf("create outside dir: %v", err)
292 }
293 marker := filepath.Join(outside, ".vidarchive-item.toml")
294 if err := os.WriteFile(marker, []byte("name = \"secret\"\nduration = -1\n"), 0644); err != nil {
295 t.Fatalf("write marker: %v", err)
296 }
297
298 router := srv.Router()
299
300 req := httptest.NewRequest("GET", "/library/item/../secret", nil)
301 w := httptest.NewRecorder()
302 router.ServeHTTP(w, req)
303 if w.Code != http.StatusNotFound {
304 t.Errorf("expected 404 for path traversal, got %d", w.Code)
305 }
306}
307
308func TestPerFileExistingThumbnailServed(t *testing.T) {
309 srv, cfg, cleanup := setupTestServer(t)
310 defer cleanup()
311
312 // A pre-existing per-file thumbnail (<stem>.thumbnail.webp) is served for the
313 // matching ?file= request without re-extraction.
314 createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{
315 "video.mp4": "dummy video",
316 "video.thumbnail.webp": "GENERATED-THUMB",
317 })
318
319 router := srv.Router()
320 req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail?file=video.mp4", nil)
321 w := httptest.NewRecorder()
322 router.ServeHTTP(w, req)
323 if w.Code != http.StatusOK {
324 body, _ := io.ReadAll(w.Body)
325 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
326 }
327 if body, _ := io.ReadAll(w.Body); string(body) != "GENERATED-THUMB" {
328 t.Errorf("expected the existing per-file thumbnail, got %q", string(body))
329 }
330}
331
332func TestAudioThumbnailPlaceholder(t *testing.T) {
333 srv, cfg, cleanup := setupTestServer(t)
334 defer cleanup()
335
336 createItem(t, cfg.LibraryDir, "audio-item", "Audio Item", map[string]string{
337 "song.mp3": "dummy audio",
338 })
339
340 router := srv.Router()
341 req := httptest.NewRequest("GET", "/media/item/audio-item/thumbnail", nil)
342 w := httptest.NewRecorder()
343 router.ServeHTTP(w, req)
344 if w.Code != http.StatusOK {
345 body, _ := io.ReadAll(w.Body)
346 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
347 }
348 body, _ := io.ReadAll(w.Body)
349 if len(body) == 0 {
350 t.Errorf("placeholder thumbnail body was empty")
351 }
352}
353
354func TestMultiFileCardThumbnailURLsDecodeToFilenames(t *testing.T) {
355 srv, cfg, cleanup := setupTestServer(t)
356 defer cleanup()
357
358 // Filenames with spaces are the case that broke: the template must emit a
359 // query value that the handler decodes back to the exact filename (the bug
360 // was double-escaping spaces to %2b, which decodes to '+').
361 files := map[string]string{
362 "01 - Color Bars.mp4": "v",
363 "02 - Test Pattern.mp4": "v",
364 }
365 createItem(t, cfg.LibraryDir, "multi", "Multi", files)
366
367 req := httptest.NewRequest("GET", "/library", nil)
368 w := httptest.NewRecorder()
369 srv.Router().ServeHTTP(w, req)
370 body, _ := io.ReadAll(w.Body)
371
372 re := regexp.MustCompile(`thumbnail\?file=([^"]+)`)
373 matches := re.FindAllStringSubmatch(string(body), -1)
374 if len(matches) != len(files) {
375 t.Fatalf("expected %d per-file thumbnail URLs in the card, got %d", len(files), len(matches))
376 }
377 for _, m := range matches {
378 vals, err := url.ParseQuery("file=" + m[1])
379 if err != nil {
380 t.Fatalf("bad query %q: %v", m[1], err)
381 }
382 got := vals.Get("file")
383 if _, ok := files[got]; !ok {
384 t.Errorf("thumbnail file=%q decodes to %q, which is not a real filename (double-encoding regression)", m[1], got)
385 }
386 }
387}
388
389// TestNestedFolderLinkRoundTrip guards the double-encoding regression: a folder
390// whose name contains a space was linked with urlEncodePath *inside* a ?path=
391// query, which html/template then re-escaped (%20 -> %2520). Clicking the link
392// landed on a path the server decoded to "playlist%20test%202" — a directory
393// that doesn't exist — so the folder rendered empty and the breadcrumb showed
394// the literal "%20". The link must round-trip: its decoded ?path must be the
395// real directory, the item inside must render, and the breadcrumb must show the
396// human-readable name.
397func TestNestedFolderLinkRoundTrip(t *testing.T) {
398 srv, cfg, cleanup := setupTestServer(t)
399 defer cleanup()
400
401 createItem(t, cfg.LibraryDir, "subs/playlist test 2/Vid One", "Vid One", map[string]string{
402 "video.mp4": "dummy video",
403 })
404 router := srv.Router()
405
406 // List the parent and pull out the generated folder link.
407 req := httptest.NewRequest("GET", "/library?path=subs", nil)
408 w := httptest.NewRecorder()
409 router.ServeHTTP(w, req)
410 if w.Code != http.StatusOK {
411 t.Fatalf("list /library?path=subs: got %d", w.Code)
412 }
413 body := w.Body.String()
414
415 folderHref := regexp.MustCompile(`href="(/library\?path=[^"]+)" class="folder-item"`).FindStringSubmatch(body)
416 if folderHref == nil {
417 t.Fatalf("no folder link rendered for nested folder; body:\n%s", body)
418 }
419 href := html.UnescapeString(folderHref[1])
420
421 // The link's decoded ?path must be the real directory, not a still-encoded one.
422 u, err := url.Parse(href)
423 if err != nil {
424 t.Fatalf("parse folder href %q: %v", href, err)
425 }
426 if got := u.Query().Get("path"); got != "subs/playlist test 2" {
427 t.Fatalf("folder link path decodes to %q, want %q (double-encoding regression)", got, "subs/playlist test 2")
428 }
429
430 // Follow the link exactly as a browser would. The folder must not be empty,
431 // and the breadcrumb must show the readable name (never the encoded form).
432 req = httptest.NewRequest("GET", href, nil)
433 w = httptest.NewRecorder()
434 router.ServeHTTP(w, req)
435 if w.Code != http.StatusOK {
436 t.Fatalf("follow folder link %q: got %d", href, w.Code)
437 }
438 nested := w.Body.String()
439 if !strings.Contains(nested, "Vid One") {
440 t.Errorf("nested folder rendered empty — item 'Vid One' missing; body:\n%s", nested)
441 }
442 // The breadcrumb must display the readable name, not the percent-encoded form.
443 if !strings.Contains(nested, ">playlist test 2<") {
444 t.Errorf("breadcrumb missing readable folder name 'playlist test 2'")
445 }
446 if strings.Contains(nested, ">playlist%20test%202<") {
447 t.Errorf("breadcrumb displays the encoded name instead of a space (regression)")
448 }
449 // No link may carry a double-encoded path (%2520 == %25 + 20 == re-escaped %20).
450 if strings.Contains(nested, "%2520") {
451 t.Errorf("a link is double-encoded (%%2520) — urlEncodePath inside a ?path= query (regression)")
452 }
453}
454
455func TestListingDoesNotExtractThumbnails(t *testing.T) {
456 srv, cfg, cleanup := setupTestServer(t)
457 defer cleanup()
458
459 createItem(t, cfg.LibraryDir, "novid", "No Thumb", map[string]string{
460 "video.mp4": "dummy video",
461 })
462
463 router := srv.Router()
464 req := httptest.NewRequest("GET", "/library", nil)
465 w := httptest.NewRecorder()
466 router.ServeHTTP(w, req)
467 if w.Code != http.StatusOK {
468 t.Fatalf("expected 200, got %d", w.Code)
469 }
470
471 // Rendering the listing must not have created any thumbnail file.
472 entries, err := os.ReadDir(filepath.Join(cfg.LibraryDir, "novid"))
473 if err != nil {
474 t.Fatal(err)
475 }
476 for _, e := range entries {
477 if strings.Contains(e.Name(), ".thumbnail.") {
478 t.Errorf("listing extracted a thumbnail (%q) — should happen on request only", e.Name())
479 }
480 }
481}
482
483func TestGeneratedThumbnailServedOverIcon(t *testing.T) {
484 srv, cfg, cleanup := setupTestServer(t)
485 defer cleanup()
486
487 createItem(t, cfg.LibraryDir, "gen", "Gen", map[string]string{
488 "video.mp4": "dummy video",
489 "video.thumbnail.webp": "WEBPDATA",
490 })
491
492 router := srv.Router()
493 req := httptest.NewRequest("GET", "/media/item/gen/thumbnail?file=video.mp4", nil)
494 w := httptest.NewRecorder()
495 router.ServeHTTP(w, req)
496 if w.Code != http.StatusOK {
497 t.Fatalf("expected 200, got %d", w.Code)
498 }
499 if body, _ := io.ReadAll(w.Body); string(body) != "WEBPDATA" {
500 t.Errorf("expected generated thumbnail contents, got %q", string(body))
501 }
502}
503
504func TestThumbnailExtractedOnRequest(t *testing.T) {
505 if _, err := exec.LookPath("ffmpeg"); err != nil {
506 t.Skip("ffmpeg not on PATH")
507 }
508 srv, cfg, cleanup := setupTestServer(t)
509 defer cleanup()
510
511 itemDir := filepath.Join(cfg.LibraryDir, "realvid")
512 createItem(t, cfg.LibraryDir, "realvid", "Real", nil)
513 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
514 "-f", "lavfi", "-i", "testsrc=duration=3:size=64x64:rate=5",
515 "-pix_fmt", "yuv420p", filepath.Join(itemDir, "realvid.mp4"), "-y")
516 if out, err := cmd.CombinedOutput(); err != nil {
517 t.Fatalf("make test video: %v\n%s", err, out)
518 }
519
520 router := srv.Router()
521 req := httptest.NewRequest("GET", "/media/item/realvid/thumbnail?file=realvid.mp4", nil)
522 w := httptest.NewRecorder()
523 router.ServeHTTP(w, req)
524 if w.Code != http.StatusOK {
525 t.Fatalf("expected 200, got %d", w.Code)
526 }
527 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
528 t.Errorf("expected image content-type, got %q", ct)
529 }
530 body, _ := io.ReadAll(w.Body)
531 if len(body) == 0 {
532 t.Error("served thumbnail body was empty")
533 }
534
535 // A real thumbnail file should now exist on disk, with no temp leftovers.
536 entries, _ := os.ReadDir(itemDir)
537 var found bool
538 for _, e := range entries {
539 if strings.Contains(e.Name(), ".thumbnail.") {
540 found = true
541 }
542 if strings.Contains(e.Name(), ".tmp") {
543 t.Errorf("leftover temp file %q", e.Name())
544 }
545 }
546 if !found {
547 t.Error("no thumbnail file written to disk after request")
548 }
549}
550
551func TestLibraryPageIsFast(t *testing.T) {
552 srv, cfg, cleanup := setupTestServer(t)
553 defer cleanup()
554
555 for i := 0; i < 50; i++ {
556 createItem(t, cfg.LibraryDir, "item-"+string(rune('a'+i)), "Item", map[string]string{
557 "video.mp4": "dummy",
558 })
559 }
560
561 router := srv.Router()
562 req := httptest.NewRequest("GET", "/library", nil)
563 w := httptest.NewRecorder()
564 router.ServeHTTP(w, req)
565 if w.Code != http.StatusOK {
566 body, _ := io.ReadAll(w.Body)
567 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
568 }
569}
570