aboutsummaryrefslogtreecommitdiff
path: root/gallery_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'gallery_test.go')
-rw-r--r--gallery_test.go558
1 files changed, 558 insertions, 0 deletions
diff --git a/gallery_test.go b/gallery_test.go
new file mode 100644
index 0000000..125a19a
--- /dev/null
+++ b/gallery_test.go
@@ -0,0 +1,558 @@
+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"
+ "sync"
+ "sync/atomic"
+ "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 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) {
+ ts, srv := setupTestServer(t)
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ // Create a JPEG that Go's stdlib can't decode but ImageMagick can.
+ unsupportedJPEG := makeUnsupportedJPEG()
+
+ // Write directly to the gallery dir (bypass upload, test thumbnail generation).
+ dir := srv.galleryDir("test-gallery")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "tricky.jpg"), unsupportedJPEG, 0644))
+
+ // Request the thumbnail — should use ImageMagick fallback and return a valid JPEG.
+ resp := authedGet(t, ts.URL+"/g/test-gallery/thumb/tricky.jpg", cookies)
+ defer resp.Body.Close()
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Contains(t, resp.Header.Get("Content-Type"), "image/jpeg")
+
+ // The thumbnail should be a valid, fully decodable JPEG.
+ thumbData, err := io.ReadAll(resp.Body)
+ 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 served as fallback.
+ assert.NotEqual(t, unsupportedJPEG, thumbData,
+ "thumbnail must not be the broken original served as fallback")
+}
+
+// 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 TestConcurrentThumbnailGeneration(t *testing.T) {
+ ts, srv := setupTestServer(t)
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ // Upload 40 photos directly to the gallery dir (bypass upload handler for speed).
+ dir := srv.galleryDir("test-gallery")
+ largeJPEG := makeLargeTestJPEG()
+ numPhotos := 40
+ for i := 0; i < numPhotos; i++ {
+ filename := fmt.Sprintf("photo_%03d.jpg", i)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, filename), largeJPEG, 0644))
+ }
+
+ // Fire all thumbnail requests concurrently, like a browser would.
+ var wg sync.WaitGroup
+ var failures atomic.Int32
+ deadline := 30 * time.Second
+
+ for i := 0; i < numPhotos; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ filename := fmt.Sprintf("photo_%03d.jpg", idx)
+
+ client := &http.Client{Timeout: deadline}
+ req, err := http.NewRequest(http.MethodGet, ts.URL+"/g/test-gallery/thumb/"+filename, nil)
+ if err != nil {
+ failures.Add(1)
+ return
+ }
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ failures.Add(1)
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ failures.Add(1)
+ return
+ }
+
+ // Must be a valid JPEG
+ _, err = jpeg.Decode(resp.Body)
+ if err != nil {
+ failures.Add(1)
+ }
+ }(i)
+ }
+
+ wg.Wait()
+ assert.Equal(t, int32(0), failures.Load(),
+ "all %d concurrent thumbnail requests should succeed, but %d failed", numPhotos, failures.Load())
+
+ // Verify that thumbnail generation concurrency was bounded.
+ // With thumbSemaphore in place, peak concurrency must not exceed the limit.
+ peak := srv.peakThumbConcurrency.Load()
+ t.Logf("peak thumbnail generation concurrency: %d", peak)
+ assert.LessOrEqual(t, peak, int32(maxThumbConcurrency),
+ "peak concurrency (%d) should not exceed maxThumbConcurrency (%d)", peak, maxThumbConcurrency)
+}
+
+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/<script>"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp, err := http.Get(ts.URL + tc.path)
+ require.NoError(t, err)
+ resp.Body.Close()
+ assert.Equal(t, 404, resp.StatusCode)
+ })
+ }
+}