this repo has no description
1
fork

Configure Feed

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

feat: add Reddit video embed support with proper sizing

Replace OEmbed blockquote with redditmedia.com iframe for
Reddit video posts. Resolve /s/ share link redirects, fetch
uncropped images and dimensions from the Reddit JSON API,
and extract video metadata from OG tags. Size the embed
iframe using actual video dimensions with aspect ratio
clamping for portrait content. Respect caching config for
Cache-Control headers on preview responses.

+380 -29
+23 -4
internal/handler/preview.go
··· 10 10 "strings" 11 11 "time" 12 12 13 + "tumble/internal/data" 14 + 13 15 "golang.org/x/net/html" 14 - "tumble/internal/data" 15 16 ) 16 17 17 18 const ( ··· 121 122 122 123 // Cache hit - serve response 123 124 w.Header().Set("Content-Type", "application/json") 124 - w.Header().Set("Cache-Control", "public, max-age=86400") 125 + if !h.Config.Caching.Enabled { 126 + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 127 + } else { 128 + w.Header().Set("Cache-Control", "public, max-age=86400") 129 + } 125 130 json.NewEncoder(w).Encode(meta) 126 131 return true 127 132 } ··· 284 289 h.Store.InsertLinkPreview(r.Context(), urlParam, data) 285 290 } 286 291 } 287 - // Client-side Caching Header (24h) 288 - w.Header().Set("Cache-Control", "public, max-age=86400") 292 + if !h.Config.Caching.Enabled { 293 + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 294 + } else { 295 + // Client-side Caching Header (24h) 296 + w.Header().Set("Cache-Control", "public, max-age=86400") 297 + } 289 298 json.NewEncoder(w).Encode(meta) 290 299 } 291 300 ··· 481 490 metadata["image"] = content 482 491 } else if property == "og:site_name" { 483 492 metadata["provider_name"] = content 493 + } else if property == "og:video" || property == "og:video:url" { 494 + metadata["video"] = content 495 + } else if property == "og:video:secure_url" { 496 + metadata["video_secure_url"] = content 497 + } else if property == "og:video:width" { 498 + metadata["video_width"] = content 499 + } else if property == "og:video:height" { 500 + metadata["video_height"] = content 501 + } else if property == "og:type" { 502 + metadata["og_type"] = content 484 503 } else if name == "twitter:image" { 485 504 metadata["twitter_image"] = content 486 505 } else if name == "twitter:title" {
+264 -22
internal/handler/preview_reddit.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 + "io" 6 7 "net/http" 7 8 "net/url" 9 + "regexp" 10 + "strconv" 11 + "strings" 8 12 ) 9 13 10 - // GetRedditPreview implements the hybrid Scrape + OEmbed approach 14 + // GetRedditPreview implements the hybrid Scrape + OEmbed approach. 15 + // For video posts, it builds a proper video embed using Reddit's media 16 + // embed player instead of the OEmbed blockquote. 11 17 func (h *Handler) GetRedditPreview(targetURL string) (map[string]string, error) { 18 + // 0. Resolve share link redirects (/s/ URLs redirect to canonical post URL) 19 + resolvedURL := targetURL 20 + if strings.Contains(targetURL, "/s/") { 21 + if resolved, err := h.resolveRedirect(targetURL); err == nil && resolved != "" { 22 + resolvedURL = resolved 23 + } 24 + } 25 + 12 26 // 1. Scrape with Slackbot UA for rich metadata (Title, Image, Description, Icon) 13 27 slackbotUA := "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)" 14 - meta, err := h.scrapeOpenGraph(targetURL, slackbotUA) 28 + meta, err := h.scrapeOpenGraph(resolvedURL, slackbotUA) 15 29 if err != nil { 16 - // If scrape fails, we might still try OEmbed, but usually if scrape fails, OEmbed might also fail/be lesser. 17 - // Let's rely on fallback. But for now, let's proceed to OEmbed only if scrape worked partially? 18 - // Or if scrape failed completely, just try OEmbed as last resort. 19 - // For simplicity, let's initialize map if nil 20 30 if meta == nil { 21 31 meta = make(map[string]string) 22 32 } 23 33 } 24 34 25 - // 2. Fetch OEmbed for the Embed HTML (Video Player) 26 - // We manually fetch from Reddit's OEmbed endpoint to bypass the provider check in global tryOEmbed. 27 - oembedEndpoint := "https://www.reddit.com/oembed" 28 - reqURL := fmt.Sprintf("%s?url=%s&format=json", oembedEndpoint, url.QueryEscape(targetURL)) 35 + // 2. Check for video post via OG tags first, then Reddit JSON API 36 + postID := extractRedditPostID(resolvedURL) 37 + if postID != "" { 38 + // Enhance metadata with uncropped image from Reddit JSON API 39 + // This is critical for vertical videos where og:image is cropped to 16:9 40 + if imgURL, w, h, err := h.fetchRedditJSONDetails(postID); err == nil && imgURL != "" { 41 + meta["image"] = imgURL 42 + meta["embed_width"] = fmt.Sprintf("%d", w) 43 + meta["embed_height"] = fmt.Sprintf("%d", h) 44 + } 29 45 30 - oembedMeta, err2 := h.manualFetchOEmbed(reqURL) 31 - if err2 == nil { 32 - // Merge OEmbed HTML into Scraped Meta 33 - if html, ok := oembedMeta["html"]; ok { 34 - meta["embed_html"] = html 46 + isVideo := isRedditVideoPost(meta) 47 + var videoInfo *redditVideoInfo 48 + if !isVideo { 49 + videoInfo = h.getRedditVideoInfo(postID) 50 + isVideo = videoInfo != nil 35 51 } 36 - // If Scrape failed to get type, use OEmbed type 37 - if _, ok := meta["type"]; !ok { 38 - if t, ok := oembedMeta["type"]; ok { 39 - meta["type"] = t 52 + if isVideo { 53 + embedURL := fmt.Sprintf("https://www.redditmedia.com/mediaembed/%s", postID) 54 + meta["embed_html"] = fmt.Sprintf( 55 + `<iframe src="%s" style="width:100%%;border:none;" allowfullscreen></iframe>`, 56 + embedURL, 57 + ) 58 + meta["type"] = "video" 59 + // Note: fetchRedditJSONDetails already sets embed_width/height if available, 60 + // but we keep the fallback to videoInfo if needed (though JSON is preferred). 61 + if _, ok := meta["embed_width"]; !ok { 62 + if videoInfo != nil && videoInfo.Width > 0 && videoInfo.Height > 0 { 63 + meta["embed_width"] = fmt.Sprintf("%d", videoInfo.Width) 64 + meta["embed_height"] = fmt.Sprintf("%d", videoInfo.Height) 65 + } 40 66 } 41 67 } 42 - // Force type to video/rich if we have html 43 - if meta["embed_html"] != "" { 44 - meta["type"] = "rich" 68 + } 69 + 70 + // 3. If no video embed, fall back to OEmbed for blockquote embed 71 + if meta["embed_html"] == "" { 72 + oembedEndpoint := "https://www.reddit.com/oembed" 73 + reqURL := fmt.Sprintf("%s?url=%s&format=json", oembedEndpoint, url.QueryEscape(resolvedURL)) 74 + 75 + oembedMeta, err2 := h.manualFetchOEmbed(reqURL) 76 + if err2 == nil { 77 + if html, ok := oembedMeta["html"]; ok { 78 + meta["embed_html"] = html 79 + } 80 + if _, ok := meta["type"]; !ok { 81 + if t, ok := oembedMeta["type"]; ok { 82 + meta["type"] = t 83 + } 84 + } 85 + if meta["embed_html"] != "" { 86 + meta["type"] = "rich" 87 + } 45 88 } 46 89 } 47 90 91 + // Clean up internal-only OG keys before responding 92 + delete(meta, "video") 93 + delete(meta, "video_secure_url") 94 + delete(meta, "video_width") 95 + delete(meta, "video_height") 96 + delete(meta, "og_type") 97 + 48 98 // Ensure provider_name is always set for Reddit URLs 49 99 if meta["provider_name"] == "" { 50 100 meta["provider_name"] = "Reddit" ··· 53 103 return meta, nil 54 104 } 55 105 106 + // resolveRedirect follows HTTP redirects and returns the final URL. 107 + func (h *Handler) resolveRedirect(targetURL string) (string, error) { 108 + req, err := http.NewRequest("HEAD", targetURL, nil) 109 + if err != nil { 110 + return "", err 111 + } 112 + req.Header.Set("User-Agent", "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)") 113 + 114 + client := &http.Client{ 115 + Timeout: h.Config.RequestTimeout, 116 + } 117 + resp, err := client.Do(req) 118 + if err != nil { 119 + return "", err 120 + } 121 + defer resp.Body.Close() 122 + 123 + return resp.Request.URL.String(), nil 124 + } 125 + 126 + // isRedditVideoPost checks OG metadata for indicators that the post contains video. 127 + func isRedditVideoPost(meta map[string]string) bool { 128 + if v := meta["video"]; v != "" { 129 + return true 130 + } 131 + if v := meta["video_secure_url"]; v != "" { 132 + return true 133 + } 134 + if t := meta["og_type"]; strings.Contains(t, "video") { 135 + return true 136 + } 137 + return false 138 + } 139 + 140 + type redditVideoInfo struct { 141 + Width int // embed player's data-video-width (NOT the raw video dimensions) 142 + Height int // embed player's data-video-height 143 + } 144 + 145 + var ( 146 + dataVideoWidthRe = regexp.MustCompile(`data-video-width="(\d+)"`) 147 + dataVideoHeightRe = regexp.MustCompile(`data-video-height="(\d+)"`) 148 + ) 149 + 150 + // getRedditVideoInfo fetches the redditmedia embed page to detect video posts 151 + // and extract the embed player's actual display dimensions. 152 + // Returns nil if the post has no video embed. 153 + func (h *Handler) getRedditVideoInfo(postID string) *redditVideoInfo { 154 + embedURL := fmt.Sprintf("https://www.redditmedia.com/mediaembed/%s", postID) 155 + 156 + req, err := http.NewRequest("GET", embedURL, nil) 157 + if err != nil { 158 + return nil 159 + } 160 + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 161 + 162 + client := &http.Client{ 163 + Timeout: h.Config.RequestTimeout, 164 + } 165 + resp, err := client.Do(req) 166 + if err != nil { 167 + return nil 168 + } 169 + defer resp.Body.Close() 170 + 171 + if resp.StatusCode >= 400 { 172 + return nil 173 + } 174 + 175 + body, err := io.ReadAll(resp.Body) 176 + if err != nil { 177 + return nil 178 + } 179 + html := string(body) 180 + 181 + // Only treat as video if the embed page contains a video player 182 + if !strings.Contains(html, "video-player") { 183 + return nil 184 + } 185 + 186 + info := &redditVideoInfo{} 187 + if m := dataVideoWidthRe.FindStringSubmatch(html); len(m) >= 2 { 188 + info.Width, _ = strconv.Atoi(m[1]) 189 + } 190 + if m := dataVideoHeightRe.FindStringSubmatch(html); len(m) >= 2 { 191 + info.Height, _ = strconv.Atoi(m[1]) 192 + } 193 + return info 194 + } 195 + 196 + var redditPostIDRe = regexp.MustCompile(`/comments/([a-z0-9]+)`) 197 + 198 + // extractRedditPostID extracts the post ID from a canonical Reddit URL. 199 + // URL format: https://www.reddit.com/r/{subreddit}/comments/{post_id}/{slug}/ 200 + func extractRedditPostID(rawURL string) string { 201 + matches := redditPostIDRe.FindStringSubmatch(rawURL) 202 + if len(matches) >= 2 { 203 + return matches[1] 204 + } 205 + return "" 206 + } 207 + 56 208 // manualFetchOEmbed fetches OEmbed data from a fully constructed URL 57 209 func (h *Handler) manualFetchOEmbed(reqURL string) (map[string]string, error) { 58 210 req, err := http.NewRequest("GET", reqURL, nil) ··· 91 243 92 244 return m, nil 93 245 } 246 + 247 + // fetchRedditJSONDetails fetches the uncropped image and dimensions from the Reddit JSON API. 248 + // This is critical because the standard og:image is often cropped to 16:9, breaking vertical video embeds. 249 + func (h *Handler) fetchRedditJSONDetails(postID string) (imageURL string, width, height int, err error) { 250 + fmt.Printf("[Debug] fetchRedditJSONDetails called for %s\n", postID) 251 + jsonURL := fmt.Sprintf("https://www.reddit.com/comments/%s.json", postID) 252 + req, err := http.NewRequest("GET", jsonURL, nil) 253 + if err != nil { 254 + return "", 0, 0, err 255 + } 256 + // Use a standard browser UA to avoid bot detection/rate limiting 257 + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 258 + 259 + client := &http.Client{ 260 + Timeout: h.Config.RequestTimeout, 261 + } 262 + resp, err := client.Do(req) 263 + if err != nil { 264 + return "", 0, 0, err 265 + } 266 + defer resp.Body.Close() 267 + 268 + if resp.StatusCode != http.StatusOK { 269 + return "", 0, 0, fmt.Errorf("reddit json status: %d", resp.StatusCode) 270 + } 271 + 272 + // Define a minimal struct to parse the deep JSON structure 273 + var response []struct { 274 + Data struct { 275 + Children []struct { 276 + Data struct { 277 + Preview struct { 278 + Images []struct { 279 + Source struct { 280 + URL string `json:"url"` 281 + Width int `json:"width"` 282 + Height int `json:"height"` 283 + } `json:"source"` 284 + } `json:"images"` 285 + } `json:"preview"` 286 + Media struct { 287 + RedditVideo struct { 288 + Width int `json:"width"` 289 + Height int `json:"height"` 290 + DashURL string `json:"dash_url"` 291 + HLSURL string `json:"hls_url"` 292 + FallbackURL string `json:"fallback_url"` 293 + } `json:"reddit_video"` 294 + } `json:"media"` 295 + } `json:"data"` 296 + } `json:"children"` 297 + } `json:"data"` 298 + } 299 + 300 + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 301 + return "", 0, 0, err 302 + } 303 + 304 + if len(response) > 0 && len(response[0].Data.Children) > 0 { 305 + post := response[0].Data.Children[0].Data 306 + 307 + // Priority 1: Check deep media info (most accurate for videos) 308 + if post.Media.RedditVideo.Width > 0 && post.Media.RedditVideo.Height > 0 { 309 + // Use the fallback URL (mp4) or HLS if needed 310 + url := post.Media.RedditVideo.FallbackURL 311 + if url == "" { 312 + url = post.Media.RedditVideo.HLSURL 313 + } 314 + if url == "" { 315 + url = post.Media.RedditVideo.DashURL 316 + } 317 + if url == "" { 318 + // We need *some* string for the caller to accept the dimensions 319 + // Use the post ID as a placeholder if nothing else 320 + url = fmt.Sprintf("https://v.redd.it/%s", postID) 321 + } 322 + return url, post.Media.RedditVideo.Width, post.Media.RedditVideo.Height, nil 323 + } 324 + 325 + // Priority 2: Check preview images 326 + if len(post.Preview.Images) > 0 { 327 + source := post.Preview.Images[0].Source 328 + // Reddit JSON URLs often contain &amp; encoding 329 + url := strings.ReplaceAll(source.URL, "&amp;", "&") 330 + return url, source.Width, source.Height, nil 331 + } 332 + } 333 + 334 + return "", 0, 0, fmt.Errorf("no preview image found") 335 + }
+93 -3
internal/templates/views/index.html
··· 169 169 } 170 170 171 171 // Create preview endpoint URL 172 - var previewUrl = "/api/v1/preview?url=" + encodeURIComponent(url); 172 + // Append timestamp to bypass previously aggressive browser-cached responses 173 + var previewUrl = "/api/v1/preview?url=" + encodeURIComponent(url) + "&t=" + new Date().getTime(); 173 174 174 175 // Load preview asynchronously 175 176 var xhr = new XMLHttpRequest(); ··· 343 344 var embedHtmlAttr = ''; 344 345 if (data.embed_html) { 345 346 embedHtmlAttr = ' data-embed-html="' + encodeURIComponent(data.embed_html) + '"'; 347 + if (data.embed_width) embedHtmlAttr += ' data-embed-width="' + data.embed_width + '"'; 348 + if (data.embed_height) embedHtmlAttr += ' data-embed-height="' + data.embed_height + '"'; 346 349 } 347 350 348 351 // Determine aspect ratio ··· 368 371 "</div>"; 369 372 } else if (!imageUrl && data.embed_html && (data.type === "video" || data.type === "rich")) { 370 373 // Embed exists but no thumbnail image — create container for auto-expand 371 - var embedHtmlAttr = ' data-embed-html="' + encodeURIComponent(data.embed_html) + '"'; 372 - previewHTML += '<div class="og-image is-video"' + embedHtmlAttr + ' data-aspect="video"></div>'; 374 + var embedHtmlAttr2 = ' data-embed-html="' + encodeURIComponent(data.embed_html) + '"'; 375 + if (data.embed_width) embedHtmlAttr2 += ' data-embed-width="' + data.embed_width + '"'; 376 + if (data.embed_height) embedHtmlAttr2 += ' data-embed-height="' + data.embed_height + '"'; 377 + previewHTML += '<div class="og-image is-video"' + embedHtmlAttr2 + ' data-aspect="video"></div>'; 373 378 } 374 379 375 380 // Text Content ··· 419 424 var embedHtmlEncoded = container.getAttribute('data-embed-html'); 420 425 if (embedHtmlEncoded) { 421 426 var embedHtml = decodeURIComponent(embedHtmlEncoded); 427 + 428 + // Reddit video embeds (redditmedia.com/mediaembed/) use src directly 429 + // to avoid iframe-in-iframe and ensure the video player works correctly 430 + var redditVideoMatch = embedHtml.match(/src="(https:\/\/www\.redditmedia\.com\/mediaembed\/[^"]+)"/); 431 + if (redditVideoMatch) { 432 + var iframe = document.createElement('iframe'); 433 + iframe.sandbox = 'allow-scripts allow-same-origin allow-popups allow-presentation'; 434 + iframe.src = redditVideoMatch[1]; 435 + iframe.style.width = '100%'; 436 + iframe.style.border = 'none'; 437 + iframe.style.display = 'block'; 438 + iframe.allowFullscreen = true; 439 + container.setAttribute('data-embed-type', 'reddit'); 440 + 441 + // Size iframe to match the embed player's aspect ratio. 442 + // Dimensions come from the embed page's data-video-width/height 443 + // (the player's display ratio, not the raw video dimensions). 444 + // Controls overlay on the video so no extra padding is needed. 445 + var ew = parseInt(container.getAttribute('data-embed-width')); 446 + var eh = parseInt(container.getAttribute('data-embed-height')); 447 + console.log("[Debug] replaceWithVideo: ew=" + ew + ", eh=" + eh + ", html=" + container.outerHTML); 448 + window.__redditDebug = window.__redditDebug || []; 449 + window.__redditDebug.push({ew: ew, eh: eh, html: container.outerHTML}); 450 + 451 + // Capture image dimensions before clearing container 452 + var img = container.querySelector('img'); 453 + var naturalRatio = (img && img.naturalWidth) ? (img.naturalHeight / img.naturalWidth) : 0; 454 + 455 + // Essential for CSS to allow height > 600px (see screen.css .og-image[data-embed-type="reddit"]) 456 + container.setAttribute('data-embed-type', 'reddit'); 457 + 458 + // If image dimensions aren't available yet, verify them via a new Image object 459 + if (!naturalRatio && img && img.src) { 460 + var tester = new Image(); 461 + tester.onload = function() { 462 + if (this.naturalWidth > 0) { 463 + naturalRatio = this.naturalHeight / this.naturalWidth; 464 + iframe.setAttribute('data-debug-loaded', 'true'); 465 + iframe.setAttribute('data-debug-ratio', naturalRatio); 466 + iframe.setAttribute('data-debug-img-src', img.src); 467 + sizeRedditEmbed(); // Recalculate with new ratio 468 + } 469 + }; 470 + tester.src = img.src; 471 + } 472 + 473 + function sizeRedditEmbed() { 474 + var cw = container.offsetWidth || 640; 475 + iframe.setAttribute('data-debug-ew', ew); 476 + iframe.setAttribute('data-debug-eh', eh); 477 + 478 + // Priority 1: Use explicit embed dimensions if they exist 479 + if (ew && eh) { 480 + var calculatedHeight = Math.round(cw * eh / ew); 481 + // Reddit's mediaembed player doesn't fill portrait iframes properly (it letterboxes). 482 + // Clamp the aspect ratio to a maximum of 1:1 (square) to avoid huge whitespace below portrait videos. 483 + if (calculatedHeight > cw) { 484 + iframe.style.height = cw + 'px'; 485 + } else { 486 + iframe.style.height = calculatedHeight + 'px'; 487 + } 488 + } 489 + // Priority 2: Use natural image aspect ratio if valid 490 + else if (naturalRatio) { 491 + iframe.style.height = Math.round(cw * naturalRatio) + 'px'; 492 + } 493 + // Priority 3: Default to 16:9 494 + else { 495 + iframe.style.height = Math.round(cw * 9 / 16) + 'px'; 496 + } 497 + iframe.setAttribute('data-debug-final-height', iframe.style.height); 498 + } 499 + sizeRedditEmbed(); 500 + 501 + if (window.ResizeObserver) { 502 + new ResizeObserver(sizeRedditEmbed).observe(container); 503 + } 504 + 505 + container.innerHTML = ''; 506 + container.appendChild(iframe); 507 + container.onclick = null; 508 + container.classList.remove('is-video'); 509 + container.style.cursor = 'default'; 510 + return; 511 + } 422 512 423 513 // Security: Use sandboxed iframe to isolate external embed content 424 514 // This prevents embedded content from accessing the parent page