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