Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions internal/web/gallery.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ type galleryFileView struct {
store.MediaItem
SizeHuman string
ContentType string
// Missing marks a DB row whose file can't be resolved or isn't on disk in
// the archive on this machine (issues #4/#15): the template renders an
// inert labeled card instead of a download link — a click on such a link
// fetches a 404 under a `download` attribute, which browsers surface as a
// silently failed download ("clicking does nothing").
Missing bool
}

// linkGroup is a set of deduplicated links sharing a domain. Total is the
Expand Down Expand Up @@ -209,16 +215,17 @@ func parseInt64(s string) int64 {
}

// decorateFiles stats each file in the read-only archive to add size and type.
// Files that can't be stat'd (missing/renamed) still render, just without
// size/type, so the listing never fails on a single bad attachment.
// Files that can't be stat'd (missing/renamed) still render — flagged Missing,
// without size/type so the listing never fails on a single bad attachment.
func (s *Server) decorateFiles(items []store.MediaItem) []galleryFileView {
out := make([]galleryFileView, 0, len(items))
for _, it := range items {
v := galleryFileView{MediaItem: it}
v := galleryFileView{MediaItem: it, Missing: true}
if full, ok := s.mediaFilePath(it.Source, it.ConversationName, it.RelPath); ok {
if info, err := os.Stat(full); err == nil && !info.IsDir() {
v.SizeHuman = humanSize(info.Size())
v.ContentType = fileContentType(full)
v.Missing = false
}
}
out = append(out, v)
Expand Down
260 changes: 260 additions & 0 deletions internal/web/media_issue15_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
package web

import (
"context"
"html"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"

"github.com/joestump/msgbrowse/internal/signal"
"github.com/joestump/msgbrowse/internal/source"
)

// newMediaFixtureServerKind builds a server whose WhatsApp archive root is a
// temp dir seeded with the given files (rel → bytes), plus one conversation
// whose messages reference the union of onDisk and dbOnly rel paths as
// attachments of the given kind. dbOnly entries get DB rows but no file — the
// issue #15/#4 "missing source file" shape.
func newMediaFixtureServerKind(t *testing.T, kind signal.AttachmentKind, onDisk map[string][]byte, dbOnly []string) (*Server, int64) {
t.Helper()
st, cfg, _ := newTestStoreAndConfig(t)

waRoot := t.TempDir()
rels := make([]string, 0, len(onDisk)+len(dbOnly))
for rel, blob := range onDisk {
abs := filepath.Join(waRoot, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, blob, 0o644); err != nil {
t.Fatal(err)
}
rels = append(rels, rel)
}
rels = append(rels, dbOnly...)

ctx := context.Background()
convID, err := st.UpsertConversation(ctx, source.WhatsApp, "Media Fixture")
if err != nil {
t.Fatal(err)
}
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
msgs := make([]signal.Message, 0, len(rels))
for i, rel := range rels {
ts := base.Add(time.Duration(i) * time.Minute)
msgs = append(msgs, signal.Message{
Conversation: "Media Fixture", Timestamp: ts, TimestampRaw: ts.Format(signal.TimestampLayout),
Sender: "Ada", Body: "att",
Attachments: []signal.Attachment{
{Kind: kind, RelPath: rel, OriginalName: filepath.Base(rel)},
},
})
}
if _, err := st.ReplaceConversationMessages(ctx, convID, source.WhatsApp, msgs); err != nil {
t.Fatal(err)
}

cfg.WhatsAppArchiveRoot = waRoot
srv, err := NewServer(st, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
return srv, convID
}

// newMediaFixtureServer is newMediaFixtureServerKind for image attachments.
func newMediaFixtureServer(t *testing.T, onDisk map[string][]byte, dbOnly []string) (*Server, int64) {
t.Helper()
return newMediaFixtureServerKind(t, signal.KindImage, onDisk, dbOnly)
}

// imgSrcRE pulls the src attribute out of every rendered <img> tag.
var imgSrcRE = regexp.MustCompile(`<img[^>]*\bsrc="([^"]+)"`)

// mediaImgSrcs returns the /media/... srcs of all <img> tags in body,
// html/template attribute escaping undone exactly as a browser would
// (html.UnescapeString handles &amp;, &#43;, … — not just &amp;).
func mediaImgSrcs(body string) []string {
var out []string
for _, m := range imgSrcRE.FindAllStringSubmatch(body, -1) {
src := html.UnescapeString(m[1])
if strings.HasPrefix(src, "/media/") {
out = append(out, src)
}
}
return out
}

// TestGalleryMissingImagePlaceholder is the issue #15 regression at the
// gallery layer: an attachment row whose file is absent from the archive must
// render msgbrowse's own labeled placeholder — never an <img> whose src will
// 404 into the browser's broken-image glyph, and never a download link that
// would save an error page. Present images on the same page keep rendering.
// Runs against BOTH the full gallery page and the /gallery/items
// infinite-scroll fragment, since both go through gallery_images_page.
func TestGalleryMissingImagePlaceholder(t *testing.T) {
srv, convID := newMediaFixtureServer(t,
map[string][]byte{"Media/real.jpg": []byte("\xff\xd8\xff\xdbfake-jpeg")},
[]string{"Media/ghost.jpg"},
)
id := itoa(convID)

for _, path := range []string{
"/gallery?tab=images&conversation=" + id,
"/gallery/items?tab=images&conversation=" + id,
} {
rec := get(t, srv, path)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d", path, rec.Code)
}
body := rec.Body.String()

// The present image still renders as a real tile.
srcs := mediaImgSrcs(body)
foundReal := false
for _, src := range srcs {
if strings.Contains(src, "real.jpg") {
foundReal = true
}
if strings.Contains(src, "ghost.jpg") {
t.Errorf("GET %s: missing file rendered as <img src=%q> (would 404 → broken glyph)", path, src)
}
}
if !foundReal {
t.Errorf("GET %s: present image lost its <img> tile", path)
}

// The absent one renders the labeled, inert placeholder.
if !contains(body, "media-tile-missing") || !contains(body, `<span class="ph-tag">missing</span>`) {
t.Errorf("GET %s: no labeled missing-placeholder rendered", path)
}
if regexp.MustCompile(`<a[^>]*ghost\.jpg`).MatchString(body) {
t.Errorf("GET %s: missing file rendered as a link (a click would fetch a 404)", path)
}
}

// The acceptance-criteria capture: what a broken tile's /media request
// actually returns is 404 (not 500, not an empty 200).
rec := get(t, srv, "/media/"+id+"/Media/ghost.jpg")
if rec.Code != http.StatusNotFound {
t.Errorf("missing file /media status = %d, want 404", rec.Code)
}
}

// TestTranscriptMissingImageNoBrokenImg pins the same fix at the transcript
// layer, which branches on imgTileState: a DB-only image renders the inert,
// labeled missing placeholder — not an <img> thumbnail destined to 404, and
// not a download anchor whose click fetches a 404 (a silently failed download,
// the issue #4 "clicking does nothing"). Same graceful degradation the gallery
// applies (TestGalleryMissingImagePlaceholder / TestGalleryFilesMissingInert).
func TestTranscriptMissingImageNoBrokenImg(t *testing.T) {
srv, convID := newMediaFixtureServer(t,
map[string][]byte{"Media/real.jpg": []byte("\xff\xd8\xff\xdbfake-jpeg")},
[]string{"Media/ghost.jpg"},
)

rec := get(t, srv, "/c/"+itoa(convID))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
for _, src := range mediaImgSrcs(body) {
if strings.Contains(src, "ghost.jpg") {
t.Errorf("transcript rendered missing file as <img src=%q>", src)
}
}
// The absent image renders the inert, labeled placeholder — never a link,
// so a click can't fetch a 404.
if regexp.MustCompile(`<a[^>]*ghost\.jpg`).MatchString(body) {
t.Error("transcript rendered missing image as a link (a click would fetch a 404)")
}
if !contains(body, "attach-chip-missing") || !contains(body, `<span class="ph-tag">missing</span>`) {
t.Error("transcript missing-image did not render the inert labeled placeholder")
}
if !contains(body, "real.jpg") {
t.Error("transcript lost the present image")
}
}

// TestGalleryFilesMissingInert is the issue #4 web-reproducible remnant: a
// Files-tab row whose file is absent must render as an inert labeled card,
// not a download anchor — a native click on <a download href> that answers
// 404 surfaces as a silently failed download, i.e. "clicking does nothing".
// Present files keep their hx-boost="false" download anchors.
func TestGalleryFilesMissingInert(t *testing.T) {
srv, convID := newMediaFixtureServerKind(t, signal.KindFile,
map[string][]byte{"Docs/real.pdf": []byte("%PDF-1.4 fake")},
[]string{"Docs/ghost.pdf"},
)
id := itoa(convID)

for _, path := range []string{
"/gallery?tab=files&conversation=" + id,
"/gallery/items?tab=files&conversation=" + id,
} {
rec := get(t, srv, path)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d", path, rec.Code)
}
body := rec.Body.String()
if regexp.MustCompile(`<a[^>]*ghost\.pdf`).MatchString(body) {
t.Errorf("GET %s: missing file rendered as a download anchor", path)
}
if !contains(body, "media-file-missing") || !contains(body, `<span class="ph-tag">missing</span>`) {
t.Errorf("GET %s: missing file card lost its label", path)
}
// The present file keeps the real, un-boosted download anchor.
m := regexp.MustCompile(`<a class="media-file-name"[^>]*>`).FindString(body)
if m == "" || !strings.Contains(m, `hx-boost="false"`) || !strings.Contains(m, "real.pdf") {
t.Errorf("GET %s: present file lost its download anchor: %q", path, m)
}
}
}

// TestMediaURLEncodingRoundTrip settles issue #15 hypothesis 2: RelPaths with
// spaces, unicode, '#', '%', '&', '+' and subfolders must round-trip
// mediaURL → rendered <img src> → GET → mux {path...} decode → archive file.
// Every tile the gallery emits for an on-disk file must fetch 200 — a
// mediaURL/handleMedia encoding disagreement would surface here as a 404.
func TestMediaURLEncodingRoundTrip(t *testing.T) {
jpeg := []byte("\xff\xd8\xff\xdbfake-jpeg")
onDisk := map[string][]byte{
"Media/pho to #1.jpg": jpeg, // space + hash
"Media/фото-ö.png": jpeg, // unicode
"Media/100%.jpg": jpeg, // literal percent
"Media/sub dir/a+b&c=d.jpg": jpeg, // subfolder + '+', '&', '='
"Media/semi;colon.jpg": jpeg, // ';' (path-segment param char)
}
srv, convID := newMediaFixtureServer(t, onDisk, nil)

rec := get(t, srv, "/gallery?tab=images&conversation="+itoa(convID))
if rec.Code != http.StatusOK {
t.Fatalf("gallery status = %d", rec.Code)
}
srcs := mediaImgSrcs(rec.Body.String())
// Grid thumbnail + lightbox original per image share one URL; dedupe.
seen := map[string]bool{}
for _, src := range srcs {
seen[src] = true
}
if len(seen) != len(onDisk) {
t.Fatalf("gallery rendered %d distinct /media srcs, want %d (all files exist): %v", len(seen), len(onDisk), seen)
}
for src := range seen {
res := get(t, srv, src)
if res.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200 (encoding round-trip broke)", src, res.Code)
continue
}
if cd := res.Header().Get("Content-Disposition"); cd != "inline" {
t.Errorf("GET %s: disposition = %q, want inline", src, cd)
}
}
}
82 changes: 72 additions & 10 deletions internal/web/media_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,71 @@ import (
"github.com/joestump/msgbrowse/internal/source"
)

// TestImgRenderable covers the gallery/transcript placeholder decision: web
// formats always render; a non-web format (HEIC) renders only once a transcoded
// JPEG derivative exists on disk.
func TestImgRenderable(t *testing.T) {
// TestImgTileState covers the gallery/transcript placeholder decision per
// issue #15's hypotheses:
//
// - hypothesis 1 (missing source file): a web-native format whose file is
// NOT on disk classifies "missing" — previously imgRenderable returned
// true by extension alone, the grid emitted an <img>, and /media 404'd
// into the browser's broken-image glyph. This is the root-cause fix.
// - hypothesis 3 (transcode gap): a convertible format (HEIC) is "img" only
// once its derived JPEG actually exists on disk (stat, not path
// expectation); with the original present but no derivative it is
// "nopreview", and with neither it is "missing".
// - unresolvable paths (source root not configured) classify "missing"
// rather than rendering an <img> whose src would 400.
//
// The HEIC cases run against a temp WhatsApp root (flat layout, so no
// conversation-dir setup) to avoid writing into the committed fixture archive.
func TestImgTileState(t *testing.T) {
srv, _, _ := newTestServer(t)

if !srv.imgRenderable(source.Signal, "Harper", "media/cat.jpg") {
t.Error("jpg should be renderable")
// Web-native and on disk (fixture file) → img.
if got := srv.imgTileState(source.Signal, "Harper", "media/cabin.jpg"); got != tileImg {
t.Errorf("existing jpg state = %q, want %q", got, tileImg)
}
if srv.imgRenderable(source.Signal, "Harper", "media/IMG_0001.heic") {
if !srv.imgRenderable(source.Signal, "Harper", "media/cabin.jpg") {
t.Error("existing jpg should be renderable")
}

// Hypothesis 1: DB row exists, file absent → missing, NOT renderable.
if got := srv.imgTileState(source.Signal, "Harper", "media/ghost.jpg"); got != tileMissing {
t.Errorf("missing jpg state = %q, want %q", got, tileMissing)
}
if srv.imgRenderable(source.Signal, "Harper", "media/ghost.jpg") {
t.Error("jpg with no file on disk must NOT be renderable (issue #15)")
}

// Unresolvable: WhatsApp root not configured yet → missing, even though
// the extension alone reads web-native.
if got := srv.imgTileState(source.WhatsApp, "Ada", "photo.jpg"); got != tileMissing {
t.Errorf("unconfigured-root state = %q, want %q", got, tileMissing)
}

// HEIC cases on a temp WhatsApp root (roots resolve per call — #160).
waRoot := t.TempDir()
srv.rootsCfg.WhatsAppArchiveRoot = waRoot

// Hypothesis 3: HEIC with no derivative and no original → missing.
if got := srv.imgTileState(source.WhatsApp, "Ada", "IMG_0001.heic"); got != tileMissing {
t.Errorf("absent heic state = %q, want %q", got, tileMissing)
}

// HEIC original present but not transcoded → download placeholder.
heicAbs := filepath.Join(waRoot, "IMG_0001.heic")
if err := os.WriteFile(heicAbs, []byte("heic-bytes"), 0o644); err != nil {
t.Fatal(err)
}
if got := srv.imgTileState(source.WhatsApp, "Ada", "IMG_0001.heic"); got != tileNoPreview {
t.Errorf("untranscoded heic state = %q, want %q", got, tileNoPreview)
}
if srv.imgRenderable(source.WhatsApp, "Ada", "IMG_0001.heic") {
t.Error("heic with no derivative should NOT be renderable")
}

// Drop a fake derivative at the exact path the server will look for.
abs, ok := srv.mediaFilePath(source.Signal, "Harper", "media/IMG_0001.heic")
// Drop a fake derivative at the exact path the server will look for: the
// stat-based gate flips to img.
abs, ok := srv.mediaFilePath(source.WhatsApp, "Ada", "IMG_0001.heic")
if !ok {
t.Fatal("mediaFilePath failed to resolve")
}
Expand All @@ -34,7 +84,19 @@ func TestImgRenderable(t *testing.T) {
if err := os.WriteFile(d, []byte("\xff\xd8\xff jpeg"), 0o600); err != nil {
t.Fatal(err)
}
if !srv.imgRenderable(source.Signal, "Harper", "media/IMG_0001.heic") {
if got := srv.imgTileState(source.WhatsApp, "Ada", "IMG_0001.heic"); got != tileImg {
t.Errorf("transcoded heic state = %q, want %q", got, tileImg)
}
if !srv.imgRenderable(source.WhatsApp, "Ada", "IMG_0001.heic") {
t.Error("heic WITH a derivative should be renderable")
}

// The derivative also carries a HEIC whose ORIGINAL has since gone
// missing: handleMedia serves the derivative, so the tile still displays.
if err := os.Remove(heicAbs); err != nil {
t.Fatal(err)
}
if got := srv.imgTileState(source.WhatsApp, "Ada", "IMG_0001.heic"); got != tileImg {
t.Errorf("derivative-only heic state = %q, want %q", got, tileImg)
}
}
Loading
Loading