this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: Imgur handling more advanced + tests

+283 -59
+1
Makefile
··· 14 14 go fmt ./... 15 15 find internal/templates -type f \( -name "*.html" -o -name "*.xml" \) -exec sed -i '' 's/[ \t]*$$//' {} + 16 16 find tests -type f -name "*.sh" -exec sed -i '' 's/[ \t]*$$//' {} + 17 + git diff --check 17 18 18 19 GIT_COMMIT=$(shell git rev-parse --short HEAD) 19 20 LDFLAGS=-ldflags "-X tumble/internal/version.CommitHash=$(GIT_COMMIT)"
+1 -1
cmd/tumble/main.go
··· 144 144 } 145 145 146 146 // Init Service 147 - svc := service.NewContentService(cfg) 147 + svc := service.NewContentService(cfg, store) 148 148 149 149 // Init Renderer 150 150 renderer, err := templates.NewRenderer(cfg)
+1
conf/config-test-sqlite.yaml
··· 1 + config-test.yaml
+1 -1
conf/config.yaml
··· 1 - config-test.yaml 1 + config-dev-mysql.yaml
+75 -29
internal/service/content.go
··· 1 1 package service 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "html/template" ··· 16 17 17 18 type ContentService struct { 18 19 Config *config.Config 20 + Store data.Store 19 21 } 20 22 21 23 type DisplayItem struct { ··· 42 44 SuppressOG bool `json:"suppress_og"` 43 45 } 44 46 45 - func NewContentService(cfg *config.Config) *ContentService { 46 - return &ContentService{Config: cfg} 47 + func NewContentService(cfg *config.Config, store data.Store) *ContentService { 48 + return &ContentService{Config: cfg, Store: store} 47 49 } 48 50 49 51 func (s *ContentService) ProcessIRCLink(item data.IRCLink) DisplayItem { ··· 95 97 // Imgur 96 98 isImgur := false 97 99 if strings.Contains(item.URL, "imgur.com") { 100 + baseURL := s.Config.BaseURL 101 + 98 102 // Gallery Check 99 103 if strings.Contains(item.URL, "/gallery/") || strings.Contains(item.URL, "/a/") { 100 - // It is a gallery 101 - // Create a nice looking card for the gallery 102 - // <i class="fa-regular fa-images"></i> is a font-awesome icon if available, but let's stick to text/SVG or existing styles. 103 - // converting to simple link with a visual cue 104 - embed := fmt.Sprintf( 105 - `<span class="imgur-gallery-card" style="display: inline-block; overflow: hidden; border: 1px solid #444; border-radius: 5px; background-color: #222; max-width: 400px; width: 100%%; vertical-align: top;"> 106 - <a href="%s" target="_blank" style="color: #fff; text-decoration: none; display: block;"> 107 - <span class="gallery-image-container" style="display: block; background-color: #000; text-align: center; min-height: 200px; line-height: 200px;"> 108 - <span style="font-size: 48px;">📸</span> 109 - </span> 110 - <span style="display: block; padding: 10px;"> 111 - <span style="font-weight: bold; display: block;">Imgur Gallery</span> 112 - <span style="font-size: 0.9em; opacity: 0.8; display: block;">%s</span> 113 - </span> 114 - </a> 115 - </span>`, item.URL, item.Title) 104 + // Extract gallery ID 105 + re := regexp.MustCompile(`imgur\.com/(?:gallery|a)/([a-zA-Z0-9]+)`) 106 + matches := re.FindStringSubmatch(item.URL) 116 107 117 - d.Content = template.HTML(embed) 118 - isImgur = true 119 - d.SuppressOG = true 108 + if len(matches) > 1 { 109 + baseURL := s.Config.BaseURL 120 110 111 + // Try to get thumbnail from LinkPreview (OpenGraph og:image) 112 + // Only render as gallery if we have a valid preview with an image 113 + thumbnailURL := "" 114 + if s.Store != nil { 115 + if preview, err := s.Store.GetLinkPreview(context.TODO(), item.URL); err == nil && preview != nil { 116 + var meta map[string]string 117 + if err := json.Unmarshal(preview.Data, &meta); err == nil { 118 + // Extract only the og:image, not description or other text 119 + if imgURL, ok := meta["image"]; ok && imgURL != "" { 120 + thumbnailURL = imgURL 121 + } 122 + } 123 + } 124 + } 125 + 126 + // Only render gallery card if we have a valid thumbnail 127 + // Otherwise, show 404 error for deleted/unavailable galleries 128 + if thumbnailURL != "" { 129 + // Build gallery card routing through IRC link handler 130 + // Detect and hide Imgur placeholder to maintain zero-tolerance requirement 131 + embed := fmt.Sprintf( 132 + `<span class="imgur-gallery-card" style="display: inline-block; overflow: hidden; border: 1px solid #444; border-radius: 5px; background-color: #222; max-width: 400px; width: 100%%; vertical-align: top;"> 133 + <a href="http://%s/irclink/?%d" target="_blank" style="color: #fff; text-decoration: none; display: block;"> 134 + <span class="gallery-image-container" style="display: block; background-color: #000; text-align: center; min-height: 200px; position: relative;"> 135 + <img src="%s" style="max-width: 100%%; max-height: 200px; display: block; margin: 0 auto;" 136 + onload="if(this.naturalWidth===161 && this.naturalHeight===81){this.style.display='none'; this.nextElementSibling.style.display='block';}" 137 + onerror="this.style.display='none'; this.nextElementSibling.style.display='block';" /> 138 + <span style="font-size: 48px; line-height: 200px; display: none;">📸</span> 139 + </span> 140 + <span style="display: block; padding: 10px;"> 141 + <span style="font-weight: bold; display: block;">Imgur Gallery</span> 142 + <span style="font-size: 0.9em; opacity: 0.8; display: block;">%s</span> 143 + </span> 144 + </a> 145 + </span>`, 146 + baseURL, item.ID, thumbnailURL, item.Title) 147 + 148 + d.Content = template.HTML(embed) 149 + isImgur = true 150 + d.SuppressOG = true 151 + } else { 152 + // No valid preview - gallery is likely deleted/unavailable 153 + // Render as 404 error with gray link, same as other broken images 154 + d.Content = template.HTML(fmt.Sprintf( 155 + `<a href="http://%s/irclink/?%d" target="_blank"><span class='http-error-badge'>404</span> <span class='missing-link'>%s</span></a>`, 156 + baseURL, item.ID, item.URL)) 157 + isImgur = true 158 + d.SuppressOG = true 159 + } 160 + } 121 161 } else { 122 162 // Single Image / Video Detection 123 - re := regexp.MustCompile(`imgur\.com\/(?:.*[\/-])?([a-zA-Z0-9]{5,})(?:\..*)?$`) 163 + re := regexp.MustCompile(`imgur\.com/(?:.*[\\/-])?([a-zA-Z0-9]{5,})(?:\..*)?$`) 124 164 matches := re.FindStringSubmatch(item.URL) 125 165 if len(matches) > 1 { 126 166 id := matches[1] 127 - videoURL := fmt.Sprintf("https://i.imgur.com/%s.mp4", id) 128 167 imgURL := fmt.Sprintf("https://i.imgur.com/%s.jpg", id) 129 168 130 - // We render a video tag by default. If it fails to load (404 for static images, or other errors), 131 - // the onerror handler swaps it for a standard image tag. 132 - // This avoids server-side rate limits (HTTP 429) and speeds up response time. 133 - // Note: We wrap it in the anchor tag in the Go code, but the onerror replaces the VIDEO tag specifically. 169 + // Render image wrapped in IRC link handler anchor 170 + // Use visibility toggle pattern (same as gallery) to avoid race conditions 171 + // Detect Imgur placeholder by dimensions (161x81px) or true 404 errors 134 172 embed := fmt.Sprintf( 135 - `<a href="%s" target="_blank"><video autoplay loop muted playsinline style="max-width: 500px;" src="%s" onerror="this.onerror=null;this.outerHTML='<img src=\'%s\' style=\'max-width: 500px;\' />'"></video></a>`, 136 - item.URL, videoURL, imgURL) 173 + `<a href="http://%s/irclink/?%d" target="_blank" style="display: inline-block; position: relative;"> 174 + <img src="%s" style="max-width: 500px; display: block;" 175 + onload="if(this.naturalWidth===161 && this.naturalHeight===81){this.style.display='none'; this.nextElementSibling.style.display='inline';}" 176 + onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';" /> 177 + <span style="display: none;"> 178 + <span class='http-error-badge'>404</span> 179 + <span class='missing-link'>%s</span> 180 + </span> 181 + </a>`, 182 + baseURL, item.ID, imgURL, item.URL) 137 183 138 184 d.Content = template.HTML(embed) 139 185 isImgur = true
+178 -2
internal/service/content_test.go
··· 1 1 package service 2 2 3 3 import ( 4 + "fmt" 4 5 "strings" 5 6 "testing" 6 7 "time" ··· 11 12 12 13 func TestProcessIRCLink_Flickr(t *testing.T) { 13 14 cfg := &config.Config{BaseURL: "tumble.test"} 14 - svc := NewContentService(cfg) 15 + svc := NewContentService(cfg, nil) 15 16 16 17 tests := []struct { 17 18 name string ··· 80 81 81 82 func TestProcessImage_Flickr(t *testing.T) { 82 83 cfg := &config.Config{BaseURL: "tumble.test"} 83 - svc := NewContentService(cfg) 84 + svc := NewContentService(cfg, nil) 84 85 85 86 tests := []struct { 86 87 name string ··· 141 142 }) 142 143 } 143 144 } 145 + 146 + func TestProcessIRCLink_Imgur(t *testing.T) { 147 + cfg := &config.Config{BaseURL: "tumble.test"} 148 + svc := NewContentService(cfg, nil) 149 + 150 + tests := []struct { 151 + name string 152 + item data.IRCLink 153 + wantType string // "single", "gallery" 154 + wantIRCLinkHandler bool // Should route through /irclink/? 155 + wantImgurCDN bool // Should use i.imgur.com 156 + wantErrorHandler bool // Should have onerror handler 157 + wantSuppressOG bool 158 + wantGalleryCard bool 159 + }{ 160 + { 161 + name: "Single Imgur Image - Standard URL", 162 + item: data.IRCLink{ 163 + ID: 42, 164 + Title: "Cool Picture", 165 + URL: "https://imgur.com/abc123", 166 + ContentType: "text/html", 167 + User: "testuser", 168 + Timestamp: time.Now(), 169 + }, 170 + wantType: "single", 171 + wantIRCLinkHandler: true, 172 + wantImgurCDN: true, 173 + wantErrorHandler: true, 174 + wantSuppressOG: true, 175 + }, 176 + { 177 + name: "Single Imgur Image - With Extension", 178 + item: data.IRCLink{ 179 + ID: 43, 180 + Title: "Another Picture", 181 + URL: "https://imgur.com/xyz789.jpg", 182 + ContentType: "image/jpeg", 183 + User: "testuser", 184 + Timestamp: time.Now(), 185 + }, 186 + wantType: "single", 187 + wantIRCLinkHandler: true, 188 + wantImgurCDN: true, 189 + wantErrorHandler: true, 190 + wantSuppressOG: true, 191 + }, 192 + { 193 + name: "Single Imgur Image - Direct i.imgur.com", 194 + item: data.IRCLink{ 195 + ID: 44, 196 + Title: "Direct CDN", 197 + URL: "https://i.imgur.com/def456.png", 198 + ContentType: "image/png", 199 + User: "testuser", 200 + Timestamp: time.Now(), 201 + }, 202 + wantType: "single", 203 + wantIRCLinkHandler: true, 204 + wantImgurCDN: true, 205 + wantErrorHandler: true, 206 + wantSuppressOG: true, 207 + }, 208 + { 209 + name: "Imgur Gallery - /gallery/ URL (no preview)", 210 + item: data.IRCLink{ 211 + ID: 45, 212 + Title: "Gallery Title", 213 + URL: "https://imgur.com/gallery/abcGallery", 214 + ContentType: "text/html", 215 + User: "testuser", 216 + Timestamp: time.Now(), 217 + }, 218 + wantType: "gallery", 219 + wantIRCLinkHandler: true, 220 + wantSuppressOG: true, 221 + wantGalleryCard: false, // No LinkPreview, so should show 404 error 222 + }, 223 + { 224 + name: "Imgur Gallery - /a/ URL (no preview)", 225 + item: data.IRCLink{ 226 + ID: 46, 227 + Title: "Album Title", 228 + URL: "https://imgur.com/a/xyzAlbum", 229 + ContentType: "text/html", 230 + User: "testuser", 231 + Timestamp: time.Now(), 232 + }, 233 + wantType: "gallery", 234 + wantIRCLinkHandler: true, 235 + wantSuppressOG: true, 236 + wantGalleryCard: false, // No LinkPreview, so should show 404 error 237 + }, 238 + } 239 + 240 + for _, tt := range tests { 241 + t.Run(tt.name, func(t *testing.T) { 242 + got := svc.ProcessIRCLink(tt.item) 243 + html := string(got.Content) 244 + 245 + // Verify SuppressOG 246 + if got.SuppressOG != tt.wantSuppressOG { 247 + t.Errorf("ProcessIRCLink() SuppressOG = %v, want %v", got.SuppressOG, tt.wantSuppressOG) 248 + } 249 + 250 + // CRITICAL: Verify IRC link handler routing (click tracking) 251 + if tt.wantIRCLinkHandler { 252 + expectedIRCLink := fmt.Sprintf("http://%s/irclink/?%d", cfg.BaseURL, tt.item.ID) 253 + if !strings.Contains(html, expectedIRCLink) { 254 + t.Errorf("ProcessIRCLink() html should contain IRC link handler %v, got %v", expectedIRCLink, html) 255 + } 256 + 257 + // CRITICAL: Ensure we NEVER link directly to imgur.com (bypassing click tracking) 258 + // Gallery cards should not have direct imgur.com links 259 + if strings.Contains(html, `href="https://imgur.com`) || strings.Contains(html, `href="http://imgur.com`) { 260 + t.Errorf("ProcessIRCLink() html should NOT contain direct imgur.com links, got %v", html) 261 + } 262 + } 263 + 264 + // Verify Imgur CDN usage for single images 265 + if tt.wantImgurCDN { 266 + if !strings.Contains(html, "i.imgur.com") { 267 + t.Errorf("ProcessIRCLink() html should contain i.imgur.com CDN URL") 268 + } 269 + } 270 + 271 + // Verify error handler for single images 272 + if tt.wantErrorHandler { 273 + if !strings.Contains(html, "onerror=") { 274 + t.Errorf("ProcessIRCLink() html should contain onerror handler") 275 + } 276 + // Verify it uses the standard error pattern 277 + if !strings.Contains(html, "http-error-badge") { 278 + t.Errorf("ProcessIRCLink() html should use http-error-badge class for errors") 279 + } 280 + if !strings.Contains(html, "missing-link") { 281 + t.Errorf("ProcessIRCLink() html should use missing-link class for errors") 282 + } 283 + } 284 + 285 + // Verify gallery card structure (only if we expect a card) 286 + if tt.wantGalleryCard { 287 + if !strings.Contains(html, "imgur-gallery-card") { 288 + t.Errorf("ProcessIRCLink() html should contain imgur-gallery-card class") 289 + } 290 + if !strings.Contains(html, "Imgur Gallery") { 291 + t.Errorf("ProcessIRCLink() html should contain 'Imgur Gallery' text") 292 + } 293 + // Verify gallery has preview image attempt 294 + if !strings.Contains(html, "<img src=") { 295 + t.Errorf("ProcessIRCLink() gallery should attempt to show preview image") 296 + } 297 + } 298 + 299 + // Verify galleries WITHOUT previews show error badge (not gallery card) 300 + if tt.wantType == "gallery" && !tt.wantGalleryCard { 301 + if !strings.Contains(html, "http-error-badge") { 302 + t.Errorf("ProcessIRCLink() gallery without preview should show http-error-badge, got: %v", html) 303 + } 304 + if !strings.Contains(html, "missing-link") { 305 + t.Errorf("ProcessIRCLink() gallery without preview should show missing-link class") 306 + } 307 + // Should NOT contain gallery card elements 308 + if strings.Contains(html, "imgur-gallery-card") { 309 + t.Errorf("ProcessIRCLink() gallery without preview should NOT show gallery card") 310 + } 311 + } 312 + 313 + // Verify target="_blank" for all Imgur links 314 + if !strings.Contains(html, `target="_blank"`) { 315 + t.Errorf("ProcessIRCLink() html should open in new tab with target=_blank") 316 + } 317 + }) 318 + } 319 + }
+1 -1
internal/templates/views/header.html
··· 226 226 if (isCompact) { 227 227 compactIcon.textContent = "view_agenda"; // Action: switch to expanded 228 228 } else { 229 - compactIcon.textContent = "view_headline"; // Action: switch to compact 229 + compactIcon.textContent = "view_headline"; // Action: switch to compac 230 230 } 231 231 } 232 232
+1 -1
internal/templates/views/index.html
··· 20 20 document.documentElement.setAttribute("data-theme", "dark"); 21 21 } 22 22 23 - // Compact Mode Init 23 + // Compact Mode Ini 24 24 var isCompact = localStorage.getItem("compact") === "true"; 25 25 if (isCompact) { 26 26 document.documentElement.classList.add("compact-mode");
+24 -24
tests/preview_test.sh
··· 45 45 # --- REDDIT --- 46 46 # --- REDDIT --- 47 47 # Valid: Should have rich metadata (provider_name or type) 48 - test_preview "Reddit Valid" \ 49 - "https://www.reddit.com/r/valheim/comments/leqdj6/our_first_encounter_with_the_troll/" \ 48 + test_preview "Reddit Valid" 49 + "https://www.reddit.com/r/valheim/comments/leqdj6/our_first_encounter_with_the_troll/" 50 50 '.provider_name == "Reddit" or .title != null' 51 51 52 52 # Invalid: specific non-existent post. 53 - test_preview "Reddit Invalid" \ 54 - "https://www.reddit.com/r/valheim/comments/INVALID_ID_12345/" \ 53 + test_preview "Reddit Invalid" 54 + "https://www.reddit.com/r/valheim/comments/INVALID_ID_12345/" 55 55 '.error != null or (.title | contains("Page not found") or contains("Reddit"))' 56 56 57 57 # --- SPOTIFY --- 58 58 # Valid 59 - test_preview "Spotify Valid" \ 60 - "https://open.spotify.com/episode/7makk4oTQel546B0PZlDM5" \ 59 + test_preview "Spotify Valid" 60 + "https://open.spotify.com/episode/7makk4oTQel546B0PZlDM5" 61 61 '.provider_name == "Spotify" or .type == "rich"' 62 62 63 63 64 64 # Invalid 65 - test_preview "Spotify Invalid" \ 66 - "https://open.spotify.com/track/INVALID_TRACK_ID" \ 65 + test_preview "Spotify Invalid" 66 + "https://open.spotify.com/track/INVALID_TRACK_ID" 67 67 '.provider_name == "Spotify"' 68 68 69 69 # --- IMGUR --- 70 70 # Valid 71 - test_preview "Imgur Valid" \ 72 - "https://imgur.com/only-one-jack-black-0qetp3u" \ 71 + test_preview "Imgur Valid" 72 + "https://imgur.com/only-one-jack-black-0qetp3u" 73 73 '.title != null' 74 74 75 75 # Invalid 76 - test_preview "Imgur Invalid" \ 77 - "https://imgur.com/gallery/INVALID_GALLERY_ID" \ 76 + test_preview "Imgur Invalid" 77 + "https://imgur.com/gallery/INVALID_GALLERY_ID" 78 78 '.error != null or (.title | contains("Imgur"))' 79 79 80 80 # --- YOUTUBE --- 81 81 # Valid 82 - test_preview "YouTube Valid" \ 83 - "https://www.youtube.com/watch?v=dQw4w9WgXcQ" \ 82 + test_preview "YouTube Valid" 83 + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 84 84 '.provider_name == "YouTube"' 85 85 86 86 # Invalid (Video Unavailable) - Special handling in preview.go returning status 404 87 - test_preview "YouTube Invalid" \ 88 - "https://youtu.be/Ie_Wl9eNffE" \ 87 + test_preview "YouTube Invalid" 88 + "https://youtu.be/Ie_Wl9eNffE" 89 89 '.status == 404 and .error == "Video Unavailable"' 90 90 91 91 # --- TWITTER --- 92 92 # Valid - Returns empty object {} on success per preview_twitter.go 93 - test_preview "Twitter Valid" \ 94 - "https://x.com/jcockhren/status/1229101594505097216" \ 93 + test_preview "Twitter Valid" 94 + "https://x.com/jcockhren/status/1229101594505097216" 95 95 '. == {}' 96 96 97 97 # Invalid - Falls back to scrape. Twitter returns a generic page title / icon. 98 - test_preview "Twitter Invalid" \ 99 - "https://x.com/jcockhren/status/0000000000000000000" \ 98 + test_preview "Twitter Invalid" 99 + "https://x.com/jcockhren/status/0000000000000000000" 100 100 '.error == "Tweet Unavailable" and .status == 404' 101 101 102 102 # --- TIKTOK --- 103 103 # Valid 104 - test_preview "TikTok Valid" \ 105 - "https://www.tiktok.com/@tiagogreis/video/6830059644233223429" \ 104 + test_preview "TikTok Valid" 105 + "https://www.tiktok.com/@tiagogreis/video/6830059644233223429" 106 106 '.provider_name == "TikTok" or .type == "video"' 107 107 108 108 109 109 # Invalid - Falls back to scrape. 110 - test_preview "TikTok Invalid" \ 111 - "https://www.tiktok.com/@user/video/1234567890123456789" \ 110 + test_preview "TikTok Invalid" 111 + "https://www.tiktok.com/@user/video/1234567890123456789" 112 112 '.title | contains("TikTok")' 113 113 114 114