detached 0db378a

fix(filtering): use HTTP headers as version fallback for uBlock lists

commit
0db378a5008929687f8e677a5509847e8519898d
author
bnema · · 2025-11-29 13:47 UTC
parent
dc3eca3
signature
GPG Unverified2B82207D370127A0 The key is unknown
2 files · +61 -12
M internal/filtering/updater.go +39 -11
@@ -36,6 +36,7 @@ type FilterUpdate struct {
3636 Previous []byte
3737 Current []byte
3838 Hash string
39+ Version string // Version extracted from content or HTTP headers
3940 Compiled *CompiledFilters
4041 }
4142
@@ -176,39 +177,51 @@ func (fu *FilterUpdater) fetchRemoteVersion(ctx context.Context, url string) (st
176177 return "", fmt.Errorf("failed to read response: %w", err)
177178 }
178179
179- // Parse version from header (format: "! Version: 202511291248")
180+ // Parse version from content header (format: "! Version: 202511291248")
180181 scanner := bufio.NewScanner(bytes.NewReader(data))
181182 for scanner.Scan() {
182183 line := scanner.Text()
183184 if strings.HasPrefix(line, "! Version:") {
184185 version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
185- return version, nil
186+ if version != "" && !strings.Contains(version, "%") {
187+ return version, nil
188+ }
186189 }
187190 }
188191
189- return "", fmt.Errorf("version header not found in filter list")
192+ // Fallback: use HTTP headers (ETag or Last-Modified)
193+ if etag := resp.Header.Get("ETag"); etag != "" {
194+ return "etag:" + etag, nil
195+ }
196+ if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
197+ return "lastmod:" + lastMod, nil
198+ }
199+
200+ return "", fmt.Errorf("no version info found for filter list")
190201 }
191202
192203 // needsUpdate determines if a filter list needs updating based on version comparison
193204 func (fu *FilterUpdater) needsUpdate(ctx context.Context, url string) bool {
205+ // Extract short name for logging
206+ shortName := url[strings.LastIndex(url, "/")+1:]
207+
194208 remoteVersion, err := fu.fetchRemoteVersion(ctx, url)
195209 if err != nil {
196- logging.Debug(fmt.Sprintf("Failed to fetch remote version for %s: %v", url, err))
197- // On error, assume update needed to be safe
210+ logging.Info(fmt.Sprintf("[filtering] %s: fetch failed, will update (%v)", shortName, err))
198211 return true
199212 }
200213
201214 storedVersion := fu.manager.store.GetSourceVersion(url)
202215 if storedVersion == "" {
203- logging.Debug(fmt.Sprintf("No stored version for %s, update needed", url))
216+ logging.Info(fmt.Sprintf("[filtering] %s: no stored version, will update", shortName))
204217 return true
205218 }
206219
207220 needsUpdate := remoteVersion != storedVersion
208221 if needsUpdate {
209- logging.Info(fmt.Sprintf("Filter %s has new version: %s (stored: %s)", url, remoteVersion, storedVersion))
222+ logging.Info(fmt.Sprintf("[filtering] %s: new version %s (was %s)", shortName, remoteVersion, storedVersion))
210223 } else {
211- logging.Debug(fmt.Sprintf("Filter %s is up to date: %s", url, remoteVersion))
224+ logging.Info(fmt.Sprintf("[filtering] %s: up to date (%s)", shortName, storedVersion))
212225 }
213226
214227 return needsUpdate
@@ -265,12 +278,23 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified,
265278 // Calculate content hash
266279 hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))
267280
281+ // Extract version from content or use HTTP headers as fallback
282+ version := fu.extractVersion(currentContent)
283+ if version == "" {
284+ if etagHeader := resp.Header.Get("ETag"); etagHeader != "" {
285+ version = "etag:" + etagHeader
286+ } else if lastModHeader := resp.Header.Get("Last-Modified"); lastModHeader != "" {
287+ version = "lastmod:" + lastModHeader
288+ }
289+ }
290+
268291 // Create update object
269292 update := &FilterUpdate{
270293 ListID: fu.getListID(url),
271294 URL: url,
272295 Current: currentContent,
273296 Hash: hash,
297+ Version: version,
274298 }
275299
276300 // Get previous version for diffing (if available)
@@ -453,8 +477,8 @@ func (fu *FilterUpdater) applyUpdate(update *FilterUpdate) error {
453477 fu.storePreviousContent(update.ListID, update.Current)
454478
455479 // Store the version for future update checks
456- if version := fu.extractVersion(update.Current); version != "" {
457- if err := fu.manager.store.SetSourceVersion(update.URL, version); err != nil {
480+ if update.Version != "" {
481+ if err := fu.manager.store.SetSourceVersion(update.URL, update.Version); err != nil {
458482 logging.Warn(fmt.Sprintf("Failed to store version for %s: %v", update.URL, err))
459483 }
460484 }
@@ -470,7 +494,11 @@ func (fu *FilterUpdater) extractVersion(content []byte) string {
470494 for i := 0; i < 20 && scanner.Scan(); i++ {
471495 line := scanner.Text()
472496 if strings.HasPrefix(line, "! Version:") {
473- return strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
497+ version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
498+ // Skip placeholder values like %timestamp%
499+ if version != "" && !strings.Contains(version, "%") {
500+ return version
501+ }
474502 }
475503 }
476504 return ""
@@ -36,6 +36,7 @@ type FilterUpdate struct {
36 Previous []byte36 Previous []byte
37 Current []byte37 Current []byte
38 Hash string38 Hash string
39+ Version string // Version extracted from content or HTTP headers
39 Compiled *CompiledFilters40 Compiled *CompiledFilters
40 }41 }
41 42
@@ -176,39 +177,51 @@ func (fu *FilterUpdater) fetchRemoteVersion(ctx context.Context, url string) (st
176 return "", fmt.Errorf("failed to read response: %w", err)177 return "", fmt.Errorf("failed to read response: %w", err)
177 }178 }
178 179
179- // Parse version from header (format: "! Version: 202511291248")180+ // Parse version from content header (format: "! Version: 202511291248")
180 scanner := bufio.NewScanner(bytes.NewReader(data))181 scanner := bufio.NewScanner(bytes.NewReader(data))
181 for scanner.Scan() {182 for scanner.Scan() {
182 line := scanner.Text()183 line := scanner.Text()
183 if strings.HasPrefix(line, "! Version:") {184 if strings.HasPrefix(line, "! Version:") {
184 version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))185 version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
185- return version, nil186+ if version != "" && !strings.Contains(version, "%") {
187+ return version, nil
188+ }
186 }189 }
187 }190 }
188 191
189- return "", fmt.Errorf("version header not found in filter list")192+ // Fallback: use HTTP headers (ETag or Last-Modified)
193+ if etag := resp.Header.Get("ETag"); etag != "" {
194+ return "etag:" + etag, nil
195+ }
196+ if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
197+ return "lastmod:" + lastMod, nil
198+ }
199+
200+ return "", fmt.Errorf("no version info found for filter list")
190 }201 }
191 202
192 // needsUpdate determines if a filter list needs updating based on version comparison203 // needsUpdate determines if a filter list needs updating based on version comparison
193 func (fu *FilterUpdater) needsUpdate(ctx context.Context, url string) bool {204 func (fu *FilterUpdater) needsUpdate(ctx context.Context, url string) bool {
205+ // Extract short name for logging
206+ shortName := url[strings.LastIndex(url, "/")+1:]
207+
194 remoteVersion, err := fu.fetchRemoteVersion(ctx, url)208 remoteVersion, err := fu.fetchRemoteVersion(ctx, url)
195 if err != nil {209 if err != nil {
196- logging.Debug(fmt.Sprintf("Failed to fetch remote version for %s: %v", url, err))210+ logging.Info(fmt.Sprintf("[filtering] %s: fetch failed, will update (%v)", shortName, err))
197- // On error, assume update needed to be safe
198 return true211 return true
199 }212 }
200 213
201 storedVersion := fu.manager.store.GetSourceVersion(url)214 storedVersion := fu.manager.store.GetSourceVersion(url)
202 if storedVersion == "" {215 if storedVersion == "" {
203- logging.Debug(fmt.Sprintf("No stored version for %s, update needed", url))216+ logging.Info(fmt.Sprintf("[filtering] %s: no stored version, will update", shortName))
204 return true217 return true
205 }218 }
206 219
207 needsUpdate := remoteVersion != storedVersion220 needsUpdate := remoteVersion != storedVersion
208 if needsUpdate {221 if needsUpdate {
209- logging.Info(fmt.Sprintf("Filter %s has new version: %s (stored: %s)", url, remoteVersion, storedVersion))222+ logging.Info(fmt.Sprintf("[filtering] %s: new version %s (was %s)", shortName, remoteVersion, storedVersion))
210 } else {223 } else {
211- logging.Debug(fmt.Sprintf("Filter %s is up to date: %s", url, remoteVersion))224+ logging.Info(fmt.Sprintf("[filtering] %s: up to date (%s)", shortName, storedVersion))
212 }225 }
213 226
214 return needsUpdate227 return needsUpdate
@@ -265,12 +278,23 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified,
265 // Calculate content hash278 // Calculate content hash
266 hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))279 hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))
267 280
281+ // Extract version from content or use HTTP headers as fallback
282+ version := fu.extractVersion(currentContent)
283+ if version == "" {
284+ if etagHeader := resp.Header.Get("ETag"); etagHeader != "" {
285+ version = "etag:" + etagHeader
286+ } else if lastModHeader := resp.Header.Get("Last-Modified"); lastModHeader != "" {
287+ version = "lastmod:" + lastModHeader
288+ }
289+ }
290+
268 // Create update object291 // Create update object
269 update := &FilterUpdate{292 update := &FilterUpdate{
270 ListID: fu.getListID(url),293 ListID: fu.getListID(url),
271 URL: url,294 URL: url,
272 Current: currentContent,295 Current: currentContent,
273 Hash: hash,296 Hash: hash,
297+ Version: version,
274 }298 }
275 299
276 // Get previous version for diffing (if available)300 // Get previous version for diffing (if available)
@@ -453,8 +477,8 @@ func (fu *FilterUpdater) applyUpdate(update *FilterUpdate) error {
453 fu.storePreviousContent(update.ListID, update.Current)477 fu.storePreviousContent(update.ListID, update.Current)
454 478
455 // Store the version for future update checks479 // Store the version for future update checks
456- if version := fu.extractVersion(update.Current); version != "" {480+ if update.Version != "" {
457- if err := fu.manager.store.SetSourceVersion(update.URL, version); err != nil {481+ if err := fu.manager.store.SetSourceVersion(update.URL, update.Version); err != nil {
458 logging.Warn(fmt.Sprintf("Failed to store version for %s: %v", update.URL, err))482 logging.Warn(fmt.Sprintf("Failed to store version for %s: %v", update.URL, err))
459 }483 }
460 }484 }
@@ -470,7 +494,11 @@ func (fu *FilterUpdater) extractVersion(content []byte) string {
470 for i := 0; i < 20 && scanner.Scan(); i++ {494 for i := 0; i < 20 && scanner.Scan(); i++ {
471 line := scanner.Text()495 line := scanner.Text()
472 if strings.HasPrefix(line, "! Version:") {496 if strings.HasPrefix(line, "! Version:") {
473- return strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))497+ version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
498+ // Skip placeholder values like %timestamp%
499+ if version != "" && !strings.Contains(version, "%") {
500+ return version
501+ }
474 }502 }
475 }503 }
476 return ""504 return ""
M internal/filtering/updater_test.go +22 -1
@@ -18,6 +18,7 @@ func TestFetchRemoteVersion(t *testing.T) {
1818 responseStatus int
1919 wantVersion string
2020 wantErr bool
21+ useETag bool
2122 }{
2223 {
2324 name: "valid version header",
@@ -41,7 +42,17 @@ func TestFetchRemoteVersion(t *testing.T) {
4142 wantErr: false,
4243 },
4344 {
44- name: "no version header",
45+ name: "placeholder version falls back to ETag",
46+ responseBody: `! Title: uBlock filters
47+! Last modified: %timestamp%
48+`,
49+ responseStatus: http.StatusOK,
50+ wantVersion: `etag:"abc123"`,
51+ wantErr: false,
52+ useETag: true,
53+ },
54+ {
55+ name: "no version info returns error",
4556 responseBody: `[Adblock Plus 2.0]\n! Title: No Version\n`,
4657 responseStatus: http.StatusOK,
4758 wantVersion: "",
@@ -64,6 +75,9 @@ func TestFetchRemoteVersion(t *testing.T) {
6475 rangeHeader := r.Header.Get("Range")
6576 assert.Contains(t, rangeHeader, "bytes=0-")
6677
78+ if tt.useETag {
79+ w.Header().Set("ETag", `"abc123"`)
80+ }
6781 w.WriteHeader(tt.responseStatus)
6882 _, _ = w.Write([]byte(tt.responseBody))
6983 }))
@@ -111,6 +125,13 @@ func TestExtractVersion(t *testing.T) {
111125 content: []byte(`! Title: No Version\n! Author: Test`),
112126 want: "",
113127 },
128+ {
129+ name: "placeholder version ignored",
130+ content: []byte(`! Title: uBlock filters
131+! Version: %timestamp%
132+! Expires: 5 days`),
133+ want: "",
134+ },
114135 {
115136 name: "empty content",
116137 content: []byte{},
@@ -18,6 +18,7 @@ func TestFetchRemoteVersion(t *testing.T) {
18 responseStatus int18 responseStatus int
19 wantVersion string19 wantVersion string
20 wantErr bool20 wantErr bool
21+ useETag bool
21 }{22 }{
22 {23 {
23 name: "valid version header",24 name: "valid version header",
@@ -41,7 +42,17 @@ func TestFetchRemoteVersion(t *testing.T) {
41 wantErr: false,42 wantErr: false,
42 },43 },
43 {44 {
44- name: "no version header",45+ name: "placeholder version falls back to ETag",
46+ responseBody: `! Title: uBlock filters
47+! Last modified: %timestamp%
48+`,
49+ responseStatus: http.StatusOK,
50+ wantVersion: `etag:"abc123"`,
51+ wantErr: false,
52+ useETag: true,
53+ },
54+ {
55+ name: "no version info returns error",
45 responseBody: `[Adblock Plus 2.0]\n! Title: No Version\n`,56 responseBody: `[Adblock Plus 2.0]\n! Title: No Version\n`,
46 responseStatus: http.StatusOK,57 responseStatus: http.StatusOK,
47 wantVersion: "",58 wantVersion: "",
@@ -64,6 +75,9 @@ func TestFetchRemoteVersion(t *testing.T) {
64 rangeHeader := r.Header.Get("Range")75 rangeHeader := r.Header.Get("Range")
65 assert.Contains(t, rangeHeader, "bytes=0-")76 assert.Contains(t, rangeHeader, "bytes=0-")
66 77
78+ if tt.useETag {
79+ w.Header().Set("ETag", `"abc123"`)
80+ }
67 w.WriteHeader(tt.responseStatus)81 w.WriteHeader(tt.responseStatus)
68 _, _ = w.Write([]byte(tt.responseBody))82 _, _ = w.Write([]byte(tt.responseBody))
69 }))83 }))
@@ -111,6 +125,13 @@ func TestExtractVersion(t *testing.T) {
111 content: []byte(`! Title: No Version\n! Author: Test`),125 content: []byte(`! Title: No Version\n! Author: Test`),
112 want: "",126 want: "",
113 },127 },
128+ {
129+ name: "placeholder version ignored",
130+ content: []byte(`! Title: uBlock filters
131+! Version: %timestamp%
132+! Expires: 5 days`),
133+ want: "",
134+ },
114 {135 {
115 name: "empty content",136 name: "empty content",
116 content: []byte{},137 content: []byte{},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close