From 65b3fb10aaba62034af6ac5d3a328a09c1dc184c Mon Sep 17 00:00:00 2001
From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:24:48 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix=EF=BC=9A=E5=9C=A8=E5=8F=91=E5=B8=83?=
=?UTF-8?q?=E5=88=86=E6=94=AF=E6=81=A2=E5=A4=8D=20#1231=20=E5=93=8D?=
=?UTF-8?q?=E5=BA=94=E5=BC=8F=E5=9B=BE=E7=89=87=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/handlers/core/full_text.go | 9 +
internal/handlers/core/full_text_test.go | 30 ++++
.../handlers/media/media_handlers_test.go | 52 ++++++
internal/handlers/media/media_proxy.go | 156 ++++++++++--------
4 files changed, 177 insertions(+), 70 deletions(-)
diff --git a/internal/handlers/core/full_text.go b/internal/handlers/core/full_text.go
index ad7463a33..17e5efb88 100644
--- a/internal/handlers/core/full_text.go
+++ b/internal/handlers/core/full_text.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
+ "html"
"io"
"net/http"
"net/url"
@@ -120,7 +121,9 @@ func (h *Handler) FetchFullArticleContentContext(ctx context.Context, articleURL
}
extracted, extractErr := readability.FromReader(strings.NewReader(page), base)
var output bytes.Buffer
+ leadImageURL := ""
if extractErr == nil {
+ leadImageURL = extracted.ImageURL()
extractErr = extracted.RenderHTML(&output)
}
content := output.String()
@@ -128,6 +131,12 @@ func (h *Handler) FetchFullArticleContentContext(ctx context.Context, articleURL
// Explicit semantic article containers are a useful fallback for short pages.
content, _ = doc.Find("article,main,[role=main]").First().Html()
}
+ if !strings.Contains(strings.ToLower(content), "![]()
) + `)
` + content
+ }
+ }
content = textutil.PrepareArticleContent(content, base.String())
if strings.TrimSpace(content) == "" {
return "", fmt.Errorf("no readable article content")
diff --git a/internal/handlers/core/full_text_test.go b/internal/handlers/core/full_text_test.go
index 61284af1c..b57be4f3b 100644
--- a/internal/handlers/core/full_text_test.go
+++ b/internal/handlers/core/full_text_test.go
@@ -85,6 +85,36 @@ func TestFullTextReadabilityAndInvalidResponses(t *testing.T) {
}
}
+func TestFullTextReadabilityRestoresLeadImageWithoutDuplicates(t *testing.T) {
+ h := fullTextHandler(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ body := strings.Repeat("A long article sentence, with useful detail and punctuation. ", 30)
+ if r.URL.Path == "/existing" {
+ fmt.Fprintf(w, `Article
%s
`, body)
+ return
+ }
+ fmt.Fprintf(w, `Article
%s
`, body)
+ }))
+ defer server.Close()
+
+ content, err := h.FetchFullArticleContentContext(context.Background(), server.URL+"/lead", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(content, `src="`+server.URL+`/lead.jpg"`) || !strings.Contains(content, `referrerpolicy="no-referrer"`) {
+ t.Fatalf("lead image was not restored safely: %s", content)
+ }
+
+ content, err = h.FetchFullArticleContentContext(context.Background(), server.URL+"/existing", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(content, server.URL+"/body.jpg") || strings.Contains(content, server.URL+"/og.jpg") {
+ t.Fatalf("existing article image was duplicated or replaced: %s", content)
+ }
+}
+
func TestFullTextNoSelectorMatchIsAnError(t *testing.T) {
h := fullTextHandler(t)
id, err := h.DB.AddFeed(&models.Feed{Title: "f", URL: "https://example.org"})
diff --git a/internal/handlers/media/media_handlers_test.go b/internal/handlers/media/media_handlers_test.go
index afbeb0280..672317217 100644
--- a/internal/handlers/media/media_handlers_test.go
+++ b/internal/handlers/media/media_handlers_test.go
@@ -1,8 +1,11 @@
package media
import (
+ "encoding/base64"
"net/http"
"net/http/httptest"
+ "regexp"
+ "strings"
"testing"
"MrRSS/internal/database"
@@ -177,6 +180,55 @@ func TestProxyImagesInHTML_RelativeURLs(t *testing.T) {
}
}
+func TestRewriteHTMLContent_ResponsiveImageCandidates(t *testing.T) {
+ baseURL := "https://example.com/news/article"
+ htmlContent := `
+
+
+`
+
+ result := string(rewriteHTMLContent([]byte(htmlContent), baseURL))
+ for _, descriptor := range []string{" 1x", " 2x", " 320w", " 1280w"} {
+ if !strings.Contains(result, descriptor) {
+ t.Errorf("missing srcset descriptor %q in %s", descriptor, result)
+ }
+ }
+ if strings.Contains(result, `srcSet="/_next/image`) || strings.Contains(result, `data-srcset="images/`) {
+ t.Fatalf("responsive image candidates were not proxied: %s", result)
+ }
+
+ encodedURLs := regexp.MustCompile(`url_b64=([A-Za-z0-9+/=]+)`).FindAllStringSubmatch(result, -1)
+ var decodedURLs []string
+ for _, match := range encodedURLs {
+ decoded, err := base64.StdEncoding.DecodeString(match[1])
+ if err != nil {
+ t.Fatalf("decode proxied URL: %v", err)
+ }
+ decodedURLs = append(decodedURLs, string(decoded))
+ }
+ joinedURLs := strings.Join(decodedURLs, "\n")
+ for _, expected := range []string{
+ "https://example.com/_next/image?url=%2Fhero.jpg&w=1280&q=75",
+ "https://cdn.example.com/hero.jpg",
+ "https://example.com/news/images/small.jpg",
+ "https://example.com/news/images/large.jpg",
+ } {
+ if !strings.Contains(joinedURLs, expected) {
+ t.Errorf("missing decoded candidate %q in %s", expected, joinedURLs)
+ }
+ }
+ if strings.Contains(joinedURLs, "&") {
+ t.Fatalf("HTML entities leaked into proxied URLs: %s", joinedURLs)
+ }
+}
+
+func TestRewriteSrcsetAttribute_SkipsNonHTTPAndProxiedCandidates(t *testing.T) {
+ content := `
`
+ if got := rewriteSrcsetAttribute(content, "img", "srcset", "https://example.com/article"); got != content {
+ t.Fatalf("special srcset candidates changed:\nwant: %s\n got: %s", content, got)
+ }
+}
+
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && findInString(s, substr)))
diff --git a/internal/handlers/media/media_proxy.go b/internal/handlers/media/media_proxy.go
index ec5a6c3f2..b001e666d 100644
--- a/internal/handlers/media/media_proxy.go
+++ b/internal/handlers/media/media_proxy.go
@@ -823,6 +823,8 @@ func rewriteHTMLContent(bodyBytes []byte, baseURL string) []byte {
// Then rewrite img src attributes (now including the converted lazy images)
content = rewriteAttribute(content, "img", "src", baseURL)
+ content = rewriteSrcsetAttribute(content, "img", "srcset", baseURL)
+ content = rewriteSrcsetAttribute(content, "img", "data-srcset", baseURL)
// Rewrite iframe src attributes
content = rewriteAttribute(content, "iframe", "src", baseURL)
@@ -836,6 +838,8 @@ func rewriteHTMLContent(bodyBytes []byte, baseURL string) []byte {
// Rewrite source src attributes (for video/audio)
content = rewriteAttribute(content, "source", "src", baseURL)
+ content = rewriteSrcsetAttribute(content, "source", "srcset", baseURL)
+ content = rewriteSrcsetAttribute(content, "source", "data-srcset", baseURL)
// Rewrite track src attributes
content = rewriteAttribute(content, "track", "src", baseURL)
@@ -1055,89 +1059,101 @@ func parseHTMLAttributes(tag string) []htmlAttribute {
return attrs
}
-// rewriteAttribute rewrites a specific attribute in HTML tags
+// rewriteAttribute rewrites a specific URL attribute in HTML tags.
func rewriteAttribute(content, tag, attr, baseURL string) string {
- // Match all tags first
- tagRe := regexp.MustCompile(fmt.Sprintf(`<%s[^>]*>`, tag))
-
- matchCount := 0
- rewriteCount := 0
-
- result := tagRe.ReplaceAllStringFunc(content, func(match string) string {
- matchCount++
- // Try to find the attribute with double quotes
- doubleQuoteRe := regexp.MustCompile(fmt.Sprintf(`\s%s\s*=\s*"([^"]*)"`, attr))
- doubleQuoteMatch := doubleQuoteRe.FindStringSubmatch(match)
+ return rewriteAttributeValue(content, tag, attr, func(value string) (string, bool) {
+ return proxyWebpageResourceURL(value, baseURL)
+ })
+}
- var urlValue, quote string
+// rewriteSrcsetAttribute proxies every candidate URL while preserving its
+// density or width descriptor (for example, 2x or 640w).
+func rewriteSrcsetAttribute(content, tag, attr, baseURL string) string {
+ return rewriteAttributeValue(content, tag, attr, func(value string) (string, bool) {
+ var result strings.Builder
+ changed := false
+ for position := 0; position < len(value); {
+ prefixStart := position
+ for position < len(value) && (isHTMLSpace(value[position]) || value[position] == ',') {
+ position++
+ }
+ result.WriteString(value[prefixStart:position])
+ if position >= len(value) {
+ break
+ }
- if len(doubleQuoteMatch) >= 2 {
- // Found double-quoted attribute
- urlValue = doubleQuoteMatch[1]
- quote = `"`
- } else {
- // Try single quotes
- singleQuoteRe := regexp.MustCompile(fmt.Sprintf(`\s%s\s*=\s*'([^']*)'`, attr))
- singleQuoteMatch := singleQuoteRe.FindStringSubmatch(match)
- if len(singleQuoteMatch) >= 2 {
- urlValue = singleQuoteMatch[1]
- quote = `'`
+ urlStart := position
+ isDataURL := strings.HasPrefix(strings.ToLower(value[position:]), "data:")
+ for position < len(value) && !isHTMLSpace(value[position]) && (isDataURL || value[position] != ',') {
+ position++
+ }
+ candidate := value[urlStart:position]
+ if proxied, ok := proxyWebpageResourceURL(candidate, baseURL); ok {
+ result.WriteString(proxied)
+ changed = true
} else {
- // Try unquoted
- unquotedRe := regexp.MustCompile(fmt.Sprintf(`\s%s\s*=\s*([^\s>]+)`, attr))
- unquotedMatch := unquotedRe.FindStringSubmatch(match)
- if len(unquotedMatch) >= 2 {
- urlValue = unquotedMatch[1]
- quote = ""
- } else {
- // Attribute not found
- return match
- }
+ result.WriteString(candidate)
}
- }
- // Skip data: URLs, blob: URLs, and already proxied URLs
- if strings.HasPrefix(urlValue, "data:") ||
- strings.HasPrefix(urlValue, "blob:") ||
- strings.HasPrefix(urlValue, "/api/") ||
- strings.HasPrefix(urlValue, "#") {
- return match
+ descriptorStart := position
+ for position < len(value) && value[position] != ',' {
+ position++
+ }
+ result.WriteString(value[descriptorStart:position])
}
+ return result.String(), changed
+ })
+}
- rewriteCount++
- // if tag == "script" || tag == "link" {
- // log.Printf("[%s Rewrite] Rewriting %s %d: %s", strings.ToUpper(tag), attr, rewriteCount, urlValue)
- // }
-
- // Resolve relative URLs
- resolvedURL := resolveURL(urlValue, baseURL)
-
- // Create proxied URL with base64 encoding
- proxiedURL := fmt.Sprintf("/api/webpage/resource?url_b64=%s&referer_b64=%s",
- base64.StdEncoding.EncodeToString([]byte(resolvedURL)),
- base64.StdEncoding.EncodeToString([]byte(baseURL)))
+func rewriteAttributeValue(content, tag, attr string, rewrite func(string) (string, bool)) string {
+ tagRe := regexp.MustCompile(`(?i)<` + regexp.QuoteMeta(tag) + `\b[^>]*>`)
+ patterns := []*regexp.Regexp{
+ regexp.MustCompile(`(?i)\s+` + regexp.QuoteMeta(attr) + `\s*=\s*"([^"]*)"`),
+ regexp.MustCompile(`(?i)\s+` + regexp.QuoteMeta(attr) + `\s*=\s*'([^']*)'`),
+ regexp.MustCompile(`(?i)\s+` + regexp.QuoteMeta(attr) + `\s*=\s*([^\s>]+)`),
+ }
- // Replace the URL in the match
- // Use regex to replace attribute value more reliably
- if quote != "" {
- // Quoted value - replace using regex for more flexibility
- attrPattern := regexp.MustCompile(`(` + attr + `)\s*=\s*` + regexp.QuoteMeta(quote) + regexp.QuoteMeta(urlValue) + regexp.QuoteMeta(quote))
- replacement := fmt.Sprintf(`%s=%s%s%s`, attr, quote, proxiedURL, quote)
- return attrPattern.ReplaceAllString(match, replacement)
- } else {
- // Unquoted value - match until whitespace or > character
- // We need to capture the delimiter (space or >) to preserve it
- attrPattern := regexp.MustCompile(`(` + attr + `)\s*=\s*` + regexp.QuoteMeta(urlValue) + `([\s>])`)
- replacement := fmt.Sprintf(`%s="%s"$2`, attr, proxiedURL)
- return attrPattern.ReplaceAllString(match, replacement)
+ return tagRe.ReplaceAllStringFunc(content, func(match string) string {
+ for index, pattern := range patterns {
+ location := pattern.FindStringSubmatchIndex(match)
+ if len(location) < 4 {
+ continue
+ }
+ valueStart, valueEnd := location[2], location[3]
+ rewritten, changed := rewrite(match[valueStart:valueEnd])
+ if !changed {
+ return match
+ }
+ if index == len(patterns)-1 {
+ rewritten = `"` + rewritten + `"`
+ }
+ return match[:valueStart] + rewritten + match[valueEnd:]
}
+ return match
})
+}
- // if matchCount > 0 && (tag == "script" || tag == "link") {
- // log.Printf("[%s Rewrite] Found %d %s tags, rewrote %d %s attributes", strings.ToUpper(tag), matchCount, tag, rewriteCount, attr)
- // }
+func proxyWebpageResourceURL(value, baseURL string) (string, bool) {
+ value = strings.TrimSpace(html.UnescapeString(value))
+ lowerValue := strings.ToLower(value)
+ if value == "" || strings.HasPrefix(lowerValue, "data:") ||
+ strings.HasPrefix(lowerValue, "blob:") || strings.HasPrefix(value, "#") ||
+ strings.HasPrefix(value, "/api/") || strings.Contains(value, "/api/webpage/resource?") {
+ return value, false
+ }
- return result
+ resolvedURL := resolveURL(value, baseURL)
+ parsedURL, err := url.Parse(resolvedURL)
+ if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
+ return value, false
+ }
+ return fmt.Sprintf("/api/webpage/resource?url_b64=%s&referer_b64=%s",
+ base64.StdEncoding.EncodeToString([]byte(resolvedURL)),
+ base64.StdEncoding.EncodeToString([]byte(baseURL))), true
+}
+
+func isHTMLSpace(value byte) bool {
+ return value == ' ' || value == '\t' || value == '\n' || value == '\r' || value == '\f'
}
// rewriteLinkHref rewrites href attributes in link tags
From c28fb30fb1b7ef7550a6688d5ff74a1ce40a0dd0 Mon Sep 17 00:00:00 2001
From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:33:15 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix=EF=BC=9A=E8=A1=A5=E9=BD=90=E8=AE=BA?=
=?UTF-8?q?=E5=9D=9B=E6=87=92=E5=8A=A0=E8=BD=BD=E5=9B=BE=E7=89=87=E5=92=8C?=
=?UTF-8?q?=E5=AA=92=E4=BD=93=E9=98=B2=E7=9B=97=E9=93=BE=E9=87=8D=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/cache/media_cache.go | 78 +++++--
internal/cache/media_cache_test.go | 174 ++++++++++++++
internal/handlers/core/full_text.go | 22 +-
internal/handlers/core/full_text_test.go | 66 ++++++
.../handlers/media/media_handlers_test.go | 58 +++++
internal/handlers/media/media_network_test.go | 60 +++++
internal/handlers/media/media_proxy.go | 171 +++++++-------
internal/utils/httputil/httputil.go | 47 ++++
internal/utils/httputil/httputil_test.go | 219 ++++++++++++++++++
internal/utils/textutil/article_html.go | 37 ++-
internal/utils/textutil/article_html_test.go | 62 +++++
11 files changed, 873 insertions(+), 121 deletions(-)
diff --git a/internal/cache/media_cache.go b/internal/cache/media_cache.go
index 584dbea7e..5ba32e023 100644
--- a/internal/cache/media_cache.go
+++ b/internal/cache/media_cache.go
@@ -5,6 +5,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
+ "errors"
"fmt"
"io"
"net/http"
@@ -14,6 +15,8 @@ import (
"sort"
"strings"
"time"
+
+ "MrRSS/internal/utils/httputil"
)
// MediaCache handles caching of images and videos to work around anti-hotlinking
@@ -21,6 +24,16 @@ type MediaCache struct {
cacheDir string
}
+// MediaDownloadError distinguishes a failed upstream fetch from a cache storage
+// error, so callers do not repeat an already exhausted Referer fallback.
+type MediaDownloadError struct {
+ Err error
+ RefererFallbackAttempted bool
+}
+
+func (e *MediaDownloadError) Error() string { return e.Err.Error() }
+func (e *MediaDownloadError) Unwrap() error { return e.Err }
+
// NewMediaCache creates a new media cache instance
func NewMediaCache(cacheDir string) (*MediaCache, error) {
// Create cache directory if it doesn't exist
@@ -90,6 +103,7 @@ func (mc *MediaCache) Get(ctx context.Context, client *http.Client, url, referer
if err != nil {
return nil, "", fmt.Errorf("failed to download media: %w", err)
}
+ cachedPath = mc.GetCachedPath(url)
// Determine better file extension from Content-Type if available
if contentType != "" {
@@ -100,8 +114,24 @@ func (mc *MediaCache) Get(ctx context.Context, client *http.Client, url, referer
}
}
- // Save to cache
- if err := os.WriteFile(cachedPath, data, 0644); err != nil {
+ // Publish only a complete file, just as DownloadToFile does.
+ tmpFile, err := os.CreateTemp(mc.cacheDir, "download-*")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to create temporary cache file: %w", err)
+ }
+ defer os.Remove(tmpFile.Name())
+ _, writeErr := tmpFile.Write(data)
+ closeErr := tmpFile.Close()
+ if writeErr != nil {
+ return nil, "", fmt.Errorf("failed to cache media: %w", writeErr)
+ }
+ if closeErr != nil {
+ return nil, "", fmt.Errorf("failed to finish cache file: %w", closeErr)
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, "", err
+ }
+ if err := os.Rename(tmpFile.Name(), cachedPath); err != nil {
return nil, "", fmt.Errorf("failed to cache media: %w", err)
}
@@ -140,14 +170,14 @@ func (mc *MediaCache) DownloadToFile(ctx context.Context, client *http.Client, u
return "", "", err
}
- resp, err := client.Do(req)
+ resp, retried, err := httputil.DoWithRefererFallback(client, req)
if err != nil {
- return "", "", fmt.Errorf("failed to fetch media: %w", err)
+ return "", "", &MediaDownloadError{fmt.Errorf("failed to fetch media: %w", err), retried}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return "", "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ return "", "", &MediaDownloadError{fmt.Errorf("unexpected status code: %d", resp.StatusCode), retried}
}
tmpFile, err := os.CreateTemp(mc.cacheDir, "download-*")
@@ -155,18 +185,25 @@ func (mc *MediaCache) DownloadToFile(ctx context.Context, client *http.Client, u
return "", "", fmt.Errorf("failed to create temporary cache file: %w", err)
}
tmpPath := tmpFile.Name()
+ defer os.Remove(tmpPath)
written, copyErr := io.Copy(tmpFile, resp.Body)
closeErr := tmpFile.Close()
- if copyErr != nil || closeErr != nil || written == 0 {
- _ = os.Remove(tmpPath)
- if copyErr != nil {
- return "", "", fmt.Errorf("failed to stream media: %w", copyErr)
+ if copyErr != nil {
+ var pathErr *os.PathError
+ if errors.As(copyErr, &pathErr) {
+ return "", "", fmt.Errorf("failed to write cache file: %w", copyErr)
}
- if closeErr != nil {
- return "", "", fmt.Errorf("failed to finish cache file: %w", closeErr)
- }
- return "", "", fmt.Errorf("empty media response")
+ return "", "", &MediaDownloadError{fmt.Errorf("failed to stream media: %w", copyErr), retried}
+ }
+ if closeErr != nil {
+ return "", "", fmt.Errorf("failed to finish cache file: %w", closeErr)
+ }
+ if err := ctx.Err(); err != nil {
+ return "", "", &MediaDownloadError{err, retried}
+ }
+ if written == 0 || (resp.ContentLength >= 0 && written != resp.ContentLength) {
+ return "", "", &MediaDownloadError{fmt.Errorf("incomplete media response: received %d bytes, expected %d", written, resp.ContentLength), retried}
}
contentType := resp.Header.Get("Content-Type")
@@ -180,7 +217,6 @@ func (mc *MediaCache) DownloadToFile(ctx context.Context, client *http.Client, u
}
if err := os.Rename(tmpPath, finalPath); err != nil {
- _ = os.Remove(tmpPath)
return "", "", fmt.Errorf("failed to store media in cache: %w", err)
}
@@ -229,19 +265,25 @@ func (mc *MediaCache) download(ctx context.Context, client *http.Client, url, re
return nil, "", err
}
- resp, err := client.Do(req)
+ resp, retried, err := httputil.DoWithRefererFallback(client, req)
if err != nil {
- return nil, "", fmt.Errorf("failed to fetch media: %w", err)
+ return nil, "", &MediaDownloadError{fmt.Errorf("failed to fetch media: %w", err), retried}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return nil, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ return nil, "", &MediaDownloadError{fmt.Errorf("unexpected status code: %d", resp.StatusCode), retried}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
- return nil, "", fmt.Errorf("failed to read response body: %w", err)
+ return nil, "", &MediaDownloadError{fmt.Errorf("failed to read response body: %w", err), retried}
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, "", &MediaDownloadError{err, retried}
+ }
+ if len(data) == 0 || (resp.ContentLength >= 0 && int64(len(data)) != resp.ContentLength) {
+ return nil, "", &MediaDownloadError{fmt.Errorf("incomplete media response: received %d bytes, expected %d", len(data), resp.ContentLength), retried}
}
contentType := resp.Header.Get("Content-Type")
diff --git a/internal/cache/media_cache_test.go b/internal/cache/media_cache_test.go
index ea16ca7a0..d11dbd74d 100644
--- a/internal/cache/media_cache_test.go
+++ b/internal/cache/media_cache_test.go
@@ -3,15 +3,189 @@ package cache
import (
"context"
"errors"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
+ "sync/atomic"
"testing"
"time"
+
+ "MrRSS/internal/utils/httputil"
)
+func TestMediaCacheRefererFallback(t *testing.T) {
+ for _, inMemory := range []bool{false, true} {
+ for _, finalStatus := range []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound} {
+ name := fmt.Sprintf("memory=%v/status=%d", inMemory, finalStatus)
+ t.Run(name, func(t *testing.T) {
+ dir := t.TempDir()
+ mc, err := NewMediaCache(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var calls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ if r.Header.Get("Referer") != "" && finalStatus != http.StatusNotFound {
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+ w.Header().Set("Content-Type", "image/png")
+ w.WriteHeader(finalStatus)
+ _, _ = io.WriteString(w, "image")
+ }))
+ defer server.Close()
+ mediaURL := server.URL + "/image.png"
+ if inMemory {
+ _, _, err = mc.Get(context.Background(), server.Client(), mediaURL, "https://article.example")
+ } else {
+ _, _, err = mc.DownloadToFile(context.Background(), server.Client(), mediaURL, "https://article.example")
+ }
+ wantCalls := int32(2)
+ if finalStatus == http.StatusNotFound {
+ wantCalls = 1
+ }
+ if calls.Load() != wantCalls {
+ t.Fatalf("requests=%d want=%d", calls.Load(), wantCalls)
+ }
+ if finalStatus == http.StatusOK {
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, contentType, err := mc.Get(context.Background(), server.Client(), mediaURL, "https://article.example")
+ if err != nil || string(data) != "image" || contentType != "image/png" || calls.Load() != wantCalls {
+ t.Fatalf("cache hit: data=%q type=%q err=%v requests=%d", data, contentType, err, calls.Load())
+ }
+ } else {
+ var downloadErr *MediaDownloadError
+ if !errors.As(err, &downloadErr) || downloadErr.RefererFallbackAttempted != (wantCalls == 2) {
+ t.Fatalf("download error lost retry state: %v", err)
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("failed download left files: %v, %v", entries, err)
+ }
+ }
+ })
+ }
+ }
+}
+
+type failingMediaBody struct {
+ data string
+ err error
+ cancel context.CancelFunc
+ closed bool
+}
+
+func (b *failingMediaBody) Read(p []byte) (int, error) {
+ if b.data != "" {
+ n := copy(p, b.data)
+ b.data = b.data[n:]
+ return n, nil
+ }
+ if b.cancel != nil {
+ b.cancel()
+ }
+ return 0, b.err
+}
+
+func (b *failingMediaBody) Close() error {
+ b.closed = true
+ return nil
+}
+
+func TestMediaCacheIncompleteOrCancelledBodyNeverCached(t *testing.T) {
+ for _, inMemory := range []bool{false, true} {
+ for _, mode := range []string{"short", "read failure", "cancelled", "empty"} {
+ t.Run(fmt.Sprintf("memory=%v/%s", inMemory, mode), func(t *testing.T) {
+ dir := t.TempDir()
+ mc, err := NewMediaCache(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ body := &failingMediaBody{data: "partial", err: io.EOF}
+ length := int64(10)
+ if mode == "read failure" {
+ body.err = io.ErrUnexpectedEOF
+ } else if mode == "cancelled" {
+ body.cancel = cancel
+ length = -1
+ } else if mode == "empty" {
+ body.data = ""
+ length = 0
+ }
+ calls := 0
+ client := &http.Client{Transport: httputil.RoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ calls++
+ if calls == 1 {
+ return &http.Response{StatusCode: 403, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("forbidden")), Request: req}, nil
+ }
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"image/png"}}, Body: body, ContentLength: length, Request: req}, nil
+ })}
+ if inMemory {
+ _, _, err = mc.Get(ctx, client, "https://media.example/image.png", "https://article.example")
+ } else {
+ _, _, err = mc.DownloadToFile(ctx, client, "https://media.example/image.png", "https://article.example")
+ }
+ var downloadErr *MediaDownloadError
+ if !errors.As(err, &downloadErr) || !downloadErr.RefererFallbackAttempted || calls != 2 {
+ t.Fatalf("err=%v calls=%d", err, calls)
+ }
+ if mode == "cancelled" && !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation lost: %v", err)
+ }
+ if !body.closed {
+ t.Fatal("failed response body was not closed")
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("incomplete download left files: %v, %v", entries, err)
+ }
+ })
+ }
+ }
+}
+
+func TestMediaCacheStorageErrorAllowsDirectFallback(t *testing.T) {
+ for _, inMemory := range []bool{false, true} {
+ t.Run(fmt.Sprintf("memory=%v", inMemory), func(t *testing.T) {
+ // A file in place of the cache directory makes cache storage fail
+ // deterministically, including when tests run as a privileged user.
+ cachePath := filepath.Join(t.TempDir(), "not-a-directory")
+ if err := os.WriteFile(cachePath, []byte("occupied"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ mc := &MediaCache{cacheDir: cachePath}
+ calls := 0
+ client := &http.Client{Transport: httputil.RoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ calls++
+ code := 200
+ if calls == 1 {
+ code = 403
+ }
+ return &http.Response{StatusCode: code, Header: http.Header{"Content-Type": {"image/png"}}, Body: io.NopCloser(strings.NewReader("image")), ContentLength: 5, Request: req}, nil
+ })}
+ var err error
+ if inMemory {
+ _, _, err = mc.Get(context.Background(), client, "https://media.example/image.png", "https://article.example")
+ } else {
+ _, _, err = mc.DownloadToFile(context.Background(), client, "https://media.example/image.png", "https://article.example")
+ }
+ var downloadErr *MediaDownloadError
+ if err == nil || errors.As(err, &downloadErr) || calls != 2 {
+ t.Fatalf("storage error incorrectly prevents direct fallback: err=%v calls=%d", err, calls)
+ }
+ })
+ }
+}
+
func TestMediaCacheCancelledDownloadDoesNotWriteFile(t *testing.T) {
mc, err := NewMediaCache(t.TempDir())
if err != nil {
diff --git a/internal/handlers/core/full_text.go b/internal/handlers/core/full_text.go
index 17e5efb88..2395aa768 100644
--- a/internal/handlers/core/full_text.go
+++ b/internal/handlers/core/full_text.go
@@ -131,9 +131,9 @@ func (h *Handler) FetchFullArticleContentContext(ctx context.Context, articleURL
// Explicit semantic article containers are a useful fallback for short pages.
content, _ = doc.Find("article,main,[role=main]").First().Html()
}
- if !strings.Contains(strings.ToLower(content), "![]()
` + content
}
}
@@ -146,14 +146,13 @@ func (h *Handler) FetchFullArticleContentContext(ctx context.Context, articleURL
func normalizeArticleImages(doc *goquery.Document, base *url.URL) {
doc.Find("img").Each(func(_ int, img *goquery.Selection) {
- source := ""
- for _, attr := range []string{"data-src", "data-original", "data-lazy-src", "data-actualsrc", "data-original-src", "src"} {
- value := strings.TrimSpace(img.AttrOr(attr, ""))
- if value != "" && !strings.HasPrefix(value, "data:") {
- source = value
- break
+ attributes := make(map[string]string, len(img.Get(0).Attr))
+ for _, attr := range img.Get(0).Attr {
+ if attr.Namespace == "" {
+ attributes[strings.ToLower(attr.Key)] = attr.Val
}
}
+ source := textutil.ResolveArticleImageSource(attributes, base)
if source == "" {
srcset := img.AttrOr("data-srcset", img.AttrOr("srcset", ""))
candidates := strings.Split(srcset, ",")
@@ -164,9 +163,14 @@ func normalizeArticleImages(doc *goquery.Document, base *url.URL) {
}
}
}
- if resolved, err := base.Parse(source); source != "" && err == nil && (resolved.Scheme == "http" || resolved.Scheme == "https") {
+ if resolved, err := base.Parse(source); source != "" && err == nil && resolved.Hostname() != "" && (resolved.Scheme == "http" || resolved.Scheme == "https") {
img.SetAttr("src", resolved.String())
img.RemoveAttr("srcset").RemoveAttr("sizes").RemoveAttr("loading")
+ // Readability must not replace the selected source with another
+ // lazy attribute (for example a Discuz thumbnail on a lazy image).
+ for _, attr := range []string{"data-src", "data-original", "data-lazy-src", "data-actualsrc", "data-original-src", "data-srcset", "zoomfile", "file"} {
+ img.RemoveAttr(attr)
+ }
}
})
}
diff --git a/internal/handlers/core/full_text_test.go b/internal/handlers/core/full_text_test.go
index b57be4f3b..f3240bc53 100644
--- a/internal/handlers/core/full_text_test.go
+++ b/internal/handlers/core/full_text_test.go
@@ -7,8 +7,11 @@ import (
"fmt"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
+
+ "github.com/PuerkitoBio/goquery"
)
func fullTextHandler(t *testing.T) *Handler {
@@ -94,6 +97,10 @@ func TestFullTextReadabilityRestoresLeadImageWithoutDuplicates(t *testing.T) {
fmt.Fprintf(w, `Article
%s
`, body)
return
}
+ if r.URL.Path == "/no-lead" {
+ fmt.Fprintf(w, `Article
%s
`, body)
+ return
+ }
fmt.Fprintf(w, `Article
%s
`, body)
}))
defer server.Close()
@@ -113,6 +120,65 @@ func TestFullTextReadabilityRestoresLeadImageWithoutDuplicates(t *testing.T) {
if !strings.Contains(content, server.URL+"/body.jpg") || strings.Contains(content, server.URL+"/og.jpg") {
t.Fatalf("existing article image was duplicated or replaced: %s", content)
}
+ content, err = h.FetchFullArticleContentContext(context.Background(), server.URL+"/no-lead", nil)
+ if err != nil || strings.Contains(content, "
Forum articleForum article
%s

