package main import ( "archive/zip" "bytes" "encoding/json" "fmt" "image" "image/color" "image/jpeg" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func setupTestServer(t *testing.T) (*httptest.Server, *Server) { t.Helper() dir := t.TempDir() srv := NewServer(dir, 20<<20) require.NoError(t, srv.createGallery("test-gallery", "secret123")) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return ts, srv } // noRedirectClient returns an *http.Client that does NOT follow redirects, // so we can inspect 303 responses and Set-Cookie headers. func noRedirectClient() *http.Client { return &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } } // login performs a POST login and returns the session cookies on success. func login(t *testing.T, ts *httptest.Server, gallery, password string) []*http.Cookie { t.Helper() client := noRedirectClient() resp, err := client.PostForm(ts.URL+"/g/"+gallery, url.Values{"password": {password}}) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusSeeOther, resp.StatusCode, "login should redirect on success") return resp.Cookies() } // authedGet performs a GET with the given cookies attached. func authedGet(t *testing.T, rawURL string, cookies []*http.Cookie) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodGet, rawURL, nil) require.NoError(t, err) for _, c := range cookies { req.AddCookie(c) } resp, err := http.DefaultClient.Do(req) require.NoError(t, err) return resp } // makeTestJPEG creates a small valid JPEG in memory. func makeTestJPEG() []byte { img := image.NewRGBA(image.Rect(0, 0, 10, 10)) for y := 0; y < 10; y++ { for x := 0; x < 10; x++ { img.Set(x, y, color.RGBA{R: 255, A: 255}) } } var buf bytes.Buffer jpeg.Encode(&buf, img, nil) return buf.Bytes() } // uploadPhoto uploads a single JPEG to the gallery via multipart POST. func uploadPhoto(t *testing.T, ts *httptest.Server, gallery string, cookies []*http.Cookie, filename string, data []byte) *http.Response { t.Helper() var body bytes.Buffer mw := multipart.NewWriter(&body) part, err := mw.CreateFormFile("photos", filename) require.NoError(t, err) _, err = part.Write(data) require.NoError(t, err) require.NoError(t, mw.Close()) req, err := http.NewRequest(http.MethodPost, ts.URL+"/g/"+gallery+"/upload", &body) require.NoError(t, err) req.Header.Set("Content-Type", mw.FormDataContentType()) for _, c := range cookies { req.AddCookie(c) } resp, err := http.DefaultClient.Do(req) require.NoError(t, err) return resp } // bodyString reads and returns the full response body as a string. func bodyString(t *testing.T, resp *http.Response) string { t.Helper() defer resp.Body.Close() b, err := io.ReadAll(resp.Body) require.NoError(t, err) return string(b) } // setupWithPhoto creates a server, logs in, uploads one photo, and returns // everything needed for subsequent assertions. func setupWithPhoto(t *testing.T, filename string) (*httptest.Server, *Server, []*http.Cookie) { t.Helper() ts, srv := setupTestServer(t) cookies := login(t, ts, "test-gallery", "secret123") resp := uploadPhoto(t, ts, "test-gallery", cookies, filename, makeTestJPEG()) defer resp.Body.Close() var result map[string]int require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) require.Equal(t, 1, result["uploaded"]) return ts, srv, cookies } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- func TestIndexListsGalleries(t *testing.T) { ts, _ := setupTestServer(t) resp, err := http.Get(ts.URL + "/") require.NoError(t, err) body := bodyString(t, resp) assert.Equal(t, 200, resp.StatusCode) assert.Contains(t, body, "test-gallery") } func TestLoginFlow(t *testing.T) { ts, _ := setupTestServer(t) client := noRedirectClient() resp, err := client.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"secret123"}}) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusSeeOther, resp.StatusCode) var found bool for _, c := range resp.Cookies() { if c.Name == "session_test-gallery" && c.Value != "" { found = true } } assert.True(t, found, "session cookie should be set after login") } func TestSessionCookieHasSecureFlag(t *testing.T) { ts, _ := setupTestServer(t) client := noRedirectClient() resp, err := client.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"secret123"}}) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusSeeOther, resp.StatusCode) var sessionCookie *http.Cookie for _, c := range resp.Cookies() { if c.Name == "session_test-gallery" { sessionCookie = c break } } require.NotNil(t, sessionCookie, "session cookie must be present") assert.True(t, sessionCookie.Secure, "session cookie must have the Secure flag set") } func TestLoginWrongPassword(t *testing.T) { ts, _ := setupTestServer(t) resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}}) require.NoError(t, err) body := bodyString(t, resp) assert.Equal(t, 200, resp.StatusCode) assert.Contains(t, body, "Wrong password") } func TestGalleryViewEmpty(t *testing.T) { ts, _ := setupTestServer(t) cookies := login(t, ts, "test-gallery", "secret123") resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) body := bodyString(t, resp) assert.Equal(t, 200, resp.StatusCode) assert.Contains(t, body, "No photos yet") } func TestUploadAndView(t *testing.T) { ts, _, cookies := setupWithPhoto(t, "holiday.jpg") resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) body := bodyString(t, resp) assert.Contains(t, body, "holiday.jpg") } func TestThumbServing(t *testing.T) { ts, _, cookies := setupWithPhoto(t, "thumb-test.jpg") resp := authedGet(t, ts.URL+"/g/test-gallery/thumb/thumb-test.jpg", cookies) defer resp.Body.Close() assert.Equal(t, 200, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Type"), "image/jpeg") _, err := jpeg.Decode(resp.Body) assert.NoError(t, err, "thumbnail should be a decodable JPEG") } func TestThumbServesSourceWhenThumbNotReady(t *testing.T) { // When the background worker hasn't generated a thumb yet, // handleThumb should serve the source image as fallback. ts, srv := setupTestServer(t) cookies := login(t, ts, "test-gallery", "secret123") // Write a photo directly — don't start the thumb worker, so no thumb exists. dir := srv.galleryDir("test-gallery") original := makeTestJPEG() require.NoError(t, os.WriteFile(filepath.Join(dir, "nothumb.jpg"), original, 0644)) resp := authedGet(t, ts.URL+"/g/test-gallery/thumb/nothumb.jpg", cookies) body, err := io.ReadAll(resp.Body) resp.Body.Close() require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, original, body, "when thumbnail is not ready, should serve the source image as fallback") } func TestThumbWorkerGeneratesThumbnails(t *testing.T) { dir := t.TempDir() srv := NewServer(dir, 20<<20) require.NoError(t, srv.createGallery("test-gallery", "secret123")) // Write a photo directly. galleryDir := srv.galleryDir("test-gallery") require.NoError(t, os.WriteFile(filepath.Join(galleryDir, "worker-test.jpg"), makeTestJPEG(), 0644)) // Start the worker and trigger a scan. srv.startThumbWorker() srv.triggerThumbScan("test-gallery") // Wait for the thumbnail to appear (up to 5s). thumbPath := filepath.Join(galleryDir, ".thumbs-v2", "worker-test.jpg") require.Eventually(t, func() bool { _, err := os.Stat(thumbPath) return err == nil }, 5*time.Second, 50*time.Millisecond, "thumbnail should be generated by the background worker") // Verify it's a valid JPEG. data, err := os.ReadFile(thumbPath) require.NoError(t, err) _, err = jpeg.Decode(bytes.NewReader(data)) assert.NoError(t, err, "generated thumbnail should be a valid JPEG") } func TestUploadTriggersThumbWorker(t *testing.T) { dir := t.TempDir() srv := NewServer(dir, 20<<20) require.NoError(t, srv.createGallery("test-gallery", "secret123")) srv.startThumbWorker() ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) cookies := login(t, ts, "test-gallery", "secret123") resp := uploadPhoto(t, ts, "test-gallery", cookies, "upload-trigger.jpg", makeTestJPEG()) resp.Body.Close() // The upload should have triggered an immediate scan. Wait for the thumb. thumbPath := filepath.Join(srv.galleryDir("test-gallery"), ".thumbs-v2", "upload-trigger.jpg") require.Eventually(t, func() bool { _, err := os.Stat(thumbPath) return err == nil }, 5*time.Second, 50*time.Millisecond, "upload should trigger background thumbnail generation") } func TestOldThumbsDirCleanedUp(t *testing.T) { dir := t.TempDir() srv := NewServer(dir, 20<<20) require.NoError(t, srv.createGallery("test-gallery", "secret123")) // Create the old .thumbs directory with some content. oldThumbsDir := filepath.Join(srv.galleryDir("test-gallery"), ".thumbs") require.NoError(t, os.MkdirAll(oldThumbsDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(oldThumbsDir, "old.jpg"), []byte("old"), 0644)) srv.cleanupOldThumbs() _, err := os.Stat(oldThumbsDir) assert.True(t, os.IsNotExist(err), "old .thumbs directory should be removed") } func TestPhotoServing(t *testing.T) { ts, _, cookies := setupWithPhoto(t, "original.jpg") resp := authedGet(t, ts.URL+"/g/test-gallery/photo/original.jpg", cookies) body, err := io.ReadAll(resp.Body) resp.Body.Close() require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, makeTestJPEG(), body, "served photo should match uploaded content") } func TestZipDownload(t *testing.T) { ts, _, cookies := setupWithPhoto(t, "zip-pic.jpg") resp := authedGet(t, ts.URL+"/g/test-gallery/zip", cookies) zipBody, err := io.ReadAll(resp.Body) resp.Body.Close() require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, "application/zip", resp.Header.Get("Content-Type")) zr, err := zip.NewReader(bytes.NewReader(zipBody), int64(len(zipBody))) require.NoError(t, err) names := make([]string, len(zr.File)) for i, f := range zr.File { names[i] = f.Name } assert.Contains(t, names, "zip-pic.jpg") } func TestUnauthenticatedAccessBlocked(t *testing.T) { ts, _, _ := setupWithPhoto(t, "secret.jpg") paths := []string{ "/g/test-gallery", "/g/test-gallery/photo/secret.jpg", "/g/test-gallery/thumb/secret.jpg", "/g/test-gallery/zip", } for _, p := range paths { resp, err := http.Get(ts.URL + p) require.NoError(t, err) body := bodyString(t, resp) assert.Contains(t, body, `name="password"`, "path %q should show login form", p) assert.NotContains(t, body, "secret.jpg", "path %q should not leak photo filename", p) } } func TestRateLimiting(t *testing.T) { ts, _ := setupTestServer(t) for i := 0; i < loginRateMaxAttempts; i++ { resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}}) require.NoError(t, err) resp.Body.Close() assert.Equal(t, 200, resp.StatusCode, "attempt %d should be allowed", i+1) } // The next attempt should be rate-limited resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}}) require.NoError(t, err) body := bodyString(t, resp) assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) assert.Contains(t, body, "Too many login attempts") } func TestUploadSizeEnforcedAtReadTime(t *testing.T) { ts, srv := setupTestServer(t) srv.maxUploadSize = 256 cookies := login(t, ts, "test-gallery", "secret123") // Upload a file of exactly maxUploadSize bytes (passes the fh.Size // check: fh.Size <= maxUploadSize). The file must be saved to disk. exactData := bytes.Repeat([]byte{0xFF}, int(srv.maxUploadSize)) resp := uploadPhoto(t, ts, "test-gallery", cookies, "exact.jpg", exactData) var result map[string]int require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) resp.Body.Close() require.Equal(t, 1, result["uploaded"], "file at exactly maxUploadSize should be accepted") // Verify every file on disk is at most maxUploadSize bytes. // This is the invariant that io.LimitReader enforces as defense-in-depth: // even if the fh.Size check were somehow bypassed, the copy itself // must never write more than maxUploadSize bytes. dir := srv.galleryDir("test-gallery") entries, err := os.ReadDir(dir) require.NoError(t, err) for _, e := range entries { if e.IsDir() || !isAllowedExt(e.Name()) { continue } info, err := e.Info() require.NoError(t, err) assert.LessOrEqual(t, info.Size(), srv.maxUploadSize, "file %q on disk (%d bytes) must not exceed maxUploadSize (%d)", e.Name(), info.Size(), srv.maxUploadSize) } // Now test copyUploadFile directly: given a reader with more data than // maxUploadSize, the copy must be capped at maxUploadSize bytes. oversized := bytes.NewReader(bytes.Repeat([]byte{0xAB}, int(srv.maxUploadSize)+500)) tmpPath := filepath.Join(t.TempDir(), "test-copy.dat") n, err := srv.copyUploadFile(tmpPath, oversized) require.NoError(t, err) assert.LessOrEqual(t, n, srv.maxUploadSize, "copyUploadFile should write at most maxUploadSize bytes, got %d", n) } // makeUnsupportedJPEG creates a JPEG that Go's stdlib cannot decode // (unsupported luma/chroma subsampling ratio with H=3) but ImageMagick can. // This simulates real-world JPEGs that use features outside Go's support. func makeUnsupportedJPEG() []byte { img := image.NewRGBA(image.Rect(0, 0, 200, 200)) for y := 0; y < 200; y++ { for x := 0; x < 200; x++ { img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255}) } } var buf bytes.Buffer jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) data := buf.Bytes() // Find the SOF0 marker (FF C0) and change luma sampling factor to H=3, V=1. // Go's image/jpeg rejects h=3 or v=3 as "unsupported subsampling ratio", // but ImageMagick handles it gracefully. for i := 0; i < len(data)-1; i++ { if data[i] == 0xFF && data[i+1] == 0xC0 { // SOF0 header: marker(2) + length(2) + precision(1) + height(2) + width(2) + nf(1) + component(3*nf) compStart := i + 2 + 2 + 1 + 2 + 2 + 1 data[compStart+1] = 0x31 // H=3, V=1 break } } return data } func TestThumbFallbackToImageMagick(t *testing.T) { dir := t.TempDir() srv := NewServer(dir, 20<<20) require.NoError(t, srv.createGallery("test-gallery", "secret123")) // Create a JPEG that Go's stdlib can't decode but ImageMagick can. unsupportedJPEG := makeUnsupportedJPEG() // Write directly to the gallery dir. galleryDir := srv.galleryDir("test-gallery") require.NoError(t, os.WriteFile(filepath.Join(galleryDir, "tricky.jpg"), unsupportedJPEG, 0644)) // Start the worker and trigger a scan. srv.startThumbWorker() srv.triggerThumbScan("test-gallery") // Wait for the thumbnail to appear. thumbPath := filepath.Join(galleryDir, ".thumbs-v2", "tricky.jpg") require.Eventually(t, func() bool { _, err := os.Stat(thumbPath) return err == nil }, 5*time.Second, 50*time.Millisecond, "thumbnail should be generated by the background worker") // The thumbnail should be a valid, fully decodable JPEG. thumbData, err := os.ReadFile(thumbPath) require.NoError(t, err) _, err = jpeg.Decode(bytes.NewReader(thumbData)) assert.NoError(t, err, "thumbnail should be a fully decodable JPEG even when Go's stdlib can't decode the original") // The thumbnail must not be the raw original. assert.NotEqual(t, unsupportedJPEG, thumbData, "thumbnail must not be the broken original") } // makeLargeTestJPEG creates a larger JPEG that takes more time to thumbnail. func makeLargeTestJPEG() []byte { img := image.NewRGBA(image.Rect(0, 0, 3000, 2000)) for y := 0; y < 2000; y++ { for x := 0; x < 3000; x++ { img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255}) } } var buf bytes.Buffer jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) return buf.Bytes() } func TestWorkerGeneratesAllThumbnails(t *testing.T) { dir := t.TempDir() srv := NewServer(dir, 20<<20) require.NoError(t, srv.createGallery("test-gallery", "secret123")) // Write 20 photos directly to the gallery dir. galleryDir := srv.galleryDir("test-gallery") numPhotos := 20 for i := 0; i < numPhotos; i++ { filename := fmt.Sprintf("photo_%03d.jpg", i) require.NoError(t, os.WriteFile(filepath.Join(galleryDir, filename), makeTestJPEG(), 0644)) } // Start the worker and trigger a scan. srv.startThumbWorker() srv.triggerThumbScan("test-gallery") // Wait for all thumbnails to appear. thumbsDir := filepath.Join(galleryDir, ".thumbs-v2") require.Eventually(t, func() bool { entries, err := os.ReadDir(thumbsDir) if err != nil { return false } return len(entries) >= numPhotos }, 10*time.Second, 100*time.Millisecond, "all %d thumbnails should be generated by the background worker", numPhotos) // Verify each is a valid JPEG. for i := 0; i < numPhotos; i++ { filename := fmt.Sprintf("photo_%03d.jpg", i) data, err := os.ReadFile(filepath.Join(thumbsDir, filename)) require.NoError(t, err) _, err = jpeg.Decode(bytes.NewReader(data)) assert.NoError(t, err, "thumbnail %s should be a valid JPEG", filename) } } func setupTestServerNoThumbs(t *testing.T) (*httptest.Server, *Server) { t.Helper() dir := t.TempDir() srv := NewServer(dir, 20<<20) require.NoError(t, srv.createGalleryOpts("test-gallery", "secret123", false)) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return ts, srv } func TestNoThumbsGalleryServesSourceInGrid(t *testing.T) { ts, srv := setupTestServerNoThumbs(t) cookies := login(t, ts, "test-gallery", "secret123") // Upload a photo directly. dir := srv.galleryDir("test-gallery") require.NoError(t, os.WriteFile(filepath.Join(dir, "pic.jpg"), makeTestJPEG(), 0644)) // The gallery HTML should use /photo/ URLs, not /thumb/ URLs. resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) body := bodyString(t, resp) assert.Contains(t, body, "/g/test-gallery/photo/pic.jpg", "gallery grid should use photo URLs when thumbnails are disabled") assert.NotContains(t, body, "/g/test-gallery/thumb/pic.jpg", "gallery grid should NOT use thumb URLs when thumbnails are disabled") } func TestThumbsEnabledGalleryServesThumbsInGrid(t *testing.T) { ts, srv := setupTestServer(t) // default: thumbs enabled cookies := login(t, ts, "test-gallery", "secret123") dir := srv.galleryDir("test-gallery") require.NoError(t, os.WriteFile(filepath.Join(dir, "pic.jpg"), makeTestJPEG(), 0644)) resp := authedGet(t, ts.URL+"/g/test-gallery", cookies) body := bodyString(t, resp) assert.Contains(t, body, "/g/test-gallery/thumb/pic.jpg", "gallery grid should use thumb URLs when thumbnails are enabled") } func TestInvalidGalleryName404(t *testing.T) { ts, _ := setupTestServer(t) cases := []struct { name string path string }{ {"path traversal", "/g/../../etc"}, {"spaces", "/g/foo bar"}, {"dots", "/g/hello.world"}, {"xss", "/g/