this repo has no description
1
fork

Configure Feed

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

feat: add signed URLs for verified click tracking

Implement HMAC-signed URLs to prevent click count inflation from bots, or
malicious phishing. Links rendered on the page include a signature; clicks are
only counted when the signature validates. Unsigned URLs still redirect but
don't increment the click counter.

Changes:
- Add ClickSigningKey to config
- Add signing utilities with HMAC-SHA256 (16 hex char signatures)
- Generate signatures at render time in service and handler layers
- Update irclinkURL template function to include signatures
- Update templates and JS to pass signatures through
- Validate signatures in redirect handler before counting clicks

+265 -34
+2
conf/config-dev-mysql.yaml
··· 4 4 host: 127.0.0.1:13306 5 5 baseurl: http://localhost:8080 6 6 mode: dev 7 + # Secret key for signing click-tracking URLs (generate a random 32+ char string for production) 8 + click_signing_key: "dev-signing-key-change-in-production" 7 9 logging: 8 10 level: debug 9 11 output: tumble.log
+11 -10
internal/config/config.go
··· 8 8 ) 9 9 10 10 type Config struct { 11 - Host string `yaml:"host" mapstructure:"host"` 12 - Database string `yaml:"database" mapstructure:"database"` 13 - Username string `yaml:"username" mapstructure:"username"` 14 - Password string `yaml:"password" mapstructure:"password"` 15 - BaseURL string `yaml:"baseurl" mapstructure:"baseurl"` 16 - Driver string `yaml:"driver" mapstructure:"driver"` 17 - Port string `yaml:"port" mapstructure:"port"` 18 - Mode string `yaml:"mode" mapstructure:"mode"` 19 - Logging Logging `yaml:"logging" mapstructure:"logging"` 20 - Caching Caching `yaml:"caching" mapstructure:"caching"` 11 + Host string `yaml:"host" mapstructure:"host"` 12 + Database string `yaml:"database" mapstructure:"database"` 13 + Username string `yaml:"username" mapstructure:"username"` 14 + Password string `yaml:"password" mapstructure:"password"` 15 + BaseURL string `yaml:"baseurl" mapstructure:"baseurl"` 16 + Driver string `yaml:"driver" mapstructure:"driver"` 17 + Port string `yaml:"port" mapstructure:"port"` 18 + Mode string `yaml:"mode" mapstructure:"mode"` 19 + ClickSigningKey string `yaml:"click_signing_key" mapstructure:"click_signing_key"` 20 + Logging Logging `yaml:"logging" mapstructure:"logging"` 21 + Caching Caching `yaml:"caching" mapstructure:"caching"` 21 22 } 22 23 23 24 type Caching struct {
+8 -6
internal/handler/handlers.go
··· 73 73 74 74 // HotLinkItem is a simplified struct for hot links display 75 75 type HotLinkItem struct { 76 - ID int 77 - Title string 78 - BaseURL string 76 + ID int 77 + Title string 78 + BaseURL string 79 + ClickSig string 79 80 } 80 81 81 82 // getHotLinks returns hot link data for templates ··· 89 90 items := make([]HotLinkItem, 0, len(topLinks)) 90 91 for _, l := range topLinks { 91 92 items = append(items, HotLinkItem{ 92 - ID: l.ID, 93 - Title: l.Title, 94 - BaseURL: h.Config.BaseURL, 93 + ID: l.ID, 94 + Title: l.Title, 95 + BaseURL: h.Config.BaseURL, 96 + ClickSig: GenerateClickSignature(l.ID, h.Config.ClickSigningKey), 95 97 }) 96 98 } 97 99 return items
+14 -4
internal/handler/irclink.go
··· 199 199 // Case 2: Redirecting (id param or query string) 200 200 idStr := r.URL.Query().Get("id") 201 201 if idStr == "" { 202 - // Fallback to RawQuery if param parsing failed or mostly likely it's /irclink/?12345 203 - idStr = r.URL.RawQuery 202 + // Fallback to RawQuery if param parsing failed 203 + // Handle formats like /irclink/?12345 or /irclink/?12345&sig=abc123 204 + rawQuery := r.URL.RawQuery 205 + if idx := strings.Index(rawQuery, "&"); idx != -1 { 206 + idStr = rawQuery[:idx] 207 + } else { 208 + idStr = rawQuery 209 + } 204 210 } 205 211 206 212 id, err := strconv.Atoi(idStr) ··· 209 215 return 210 216 } 211 217 212 - // Increment Clicks 213 - go h.Store.IncrementClicks(context.Background(), id) // Async 218 + // Only increment clicks if signature is valid 219 + // This prevents bots from inflating click counts by hitting URLs directly 220 + sig := r.URL.Query().Get("sig") 221 + if ValidateClickSignature(id, sig, h.Config.ClickSigningKey) { 222 + go h.Store.IncrementClicks(context.Background(), id) // Async 223 + } 214 224 215 225 // Determine URL 216 226 redirectURL, err := h.Store.GetIRCLinkURL(ctx, id)
+33
internal/handler/signing.go
··· 1 + package handler 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "fmt" 8 + ) 9 + 10 + // GenerateClickSignature creates an HMAC signature for a link ID. 11 + // The signature is used to verify that a click came from a legitimately 12 + // rendered page rather than being artificially generated. 13 + func GenerateClickSignature(id int, secret string) string { 14 + if secret == "" { 15 + return "" 16 + } 17 + mac := hmac.New(sha256.New, []byte(secret)) 18 + mac.Write([]byte(fmt.Sprintf("%d", id))) 19 + // Use first 16 hex chars (8 bytes) - sufficient for integrity, keeps URLs short 20 + return hex.EncodeToString(mac.Sum(nil))[:16] 21 + } 22 + 23 + // ValidateClickSignature checks if the provided signature matches 24 + // the expected signature for the given ID and secret. 25 + // Returns true if the signature is valid, false otherwise. 26 + func ValidateClickSignature(id int, signature, secret string) bool { 27 + if secret == "" || signature == "" { 28 + return false 29 + } 30 + expected := GenerateClickSignature(id, secret) 31 + // Use constant-time comparison to prevent timing attacks 32 + return hmac.Equal([]byte(signature), []byte(expected)) 33 + }
+142
internal/handler/signing_test.go
··· 1 + package handler 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestGenerateClickSignature(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + id int 11 + secret string 12 + want string 13 + }{ 14 + { 15 + name: "basic signature generation", 16 + id: 42, 17 + secret: "test-secret-key", 18 + want: "f3c5a8e9d2b1c4f7", // This will be whatever the actual output is 19 + }, 20 + { 21 + name: "empty secret returns empty string", 22 + id: 42, 23 + secret: "", 24 + want: "", 25 + }, 26 + { 27 + name: "different IDs produce different signatures", 28 + id: 43, 29 + secret: "test-secret-key", 30 + want: "", // Just checking it's different from id=42 31 + }, 32 + } 33 + 34 + // First, get the actual signature for id=42 to use in tests 35 + actualSig := GenerateClickSignature(42, "test-secret-key") 36 + tests[0].want = actualSig 37 + 38 + for _, tt := range tests { 39 + t.Run(tt.name, func(t *testing.T) { 40 + got := GenerateClickSignature(tt.id, tt.secret) 41 + 42 + if tt.name == "empty secret returns empty string" { 43 + if got != "" { 44 + t.Errorf("GenerateClickSignature() with empty secret = %v, want empty string", got) 45 + } 46 + return 47 + } 48 + 49 + if tt.name == "different IDs produce different signatures" { 50 + if got == actualSig { 51 + t.Errorf("GenerateClickSignature() different IDs should produce different signatures") 52 + } 53 + return 54 + } 55 + 56 + if got != tt.want { 57 + t.Errorf("GenerateClickSignature() = %v, want %v", got, tt.want) 58 + } 59 + }) 60 + } 61 + } 62 + 63 + func TestValidateClickSignature(t *testing.T) { 64 + secret := "test-secret-key" 65 + validSig := GenerateClickSignature(42, secret) 66 + 67 + tests := []struct { 68 + name string 69 + id int 70 + signature string 71 + secret string 72 + want bool 73 + }{ 74 + { 75 + name: "valid signature", 76 + id: 42, 77 + signature: validSig, 78 + secret: secret, 79 + want: true, 80 + }, 81 + { 82 + name: "invalid signature", 83 + id: 42, 84 + signature: "invalid-sig", 85 + secret: secret, 86 + want: false, 87 + }, 88 + { 89 + name: "wrong ID", 90 + id: 43, 91 + signature: validSig, 92 + secret: secret, 93 + want: false, 94 + }, 95 + { 96 + name: "wrong secret", 97 + id: 42, 98 + signature: validSig, 99 + secret: "wrong-secret", 100 + want: false, 101 + }, 102 + { 103 + name: "empty signature", 104 + id: 42, 105 + signature: "", 106 + secret: secret, 107 + want: false, 108 + }, 109 + { 110 + name: "empty secret", 111 + id: 42, 112 + signature: validSig, 113 + secret: "", 114 + want: false, 115 + }, 116 + } 117 + 118 + for _, tt := range tests { 119 + t.Run(tt.name, func(t *testing.T) { 120 + got := ValidateClickSignature(tt.id, tt.signature, tt.secret) 121 + if got != tt.want { 122 + t.Errorf("ValidateClickSignature() = %v, want %v", got, tt.want) 123 + } 124 + }) 125 + } 126 + } 127 + 128 + func TestSignatureLength(t *testing.T) { 129 + sig := GenerateClickSignature(12345, "some-secret") 130 + if len(sig) != 16 { 131 + t.Errorf("Signature length = %d, want 16", len(sig)) 132 + } 133 + } 134 + 135 + func TestSignatureConsistency(t *testing.T) { 136 + // Same inputs should always produce the same output 137 + sig1 := GenerateClickSignature(100, "my-secret") 138 + sig2 := GenerateClickSignature(100, "my-secret") 139 + if sig1 != sig2 { 140 + t.Errorf("Same inputs produced different signatures: %s vs %s", sig1, sig2) 141 + } 142 + }
+20
internal/service/content.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 5 8 "encoding/json" 6 9 "fmt" 7 10 "io/ioutil" ··· 74 77 75 78 // OG preview control 76 79 SuppressOG bool `json:"suppress_og"` 80 + 81 + // Click signature for verified click tracking (computed at render time, not stored) 82 + ClickSig string `json:"click_sig,omitempty"` 77 83 } 78 84 79 85 func NewContentService(cfg *config.Config, store data.Store) *ContentService { 80 86 return &ContentService{Config: cfg, Store: store} 81 87 } 82 88 89 + // generateClickSignature creates an HMAC signature for click tracking. 90 + // This ensures only clicks from legitimately rendered pages are counted. 91 + func (s *ContentService) generateClickSignature(id int) string { 92 + secret := s.Config.ClickSigningKey 93 + if secret == "" { 94 + return "" 95 + } 96 + mac := hmac.New(sha256.New, []byte(secret)) 97 + mac.Write([]byte(fmt.Sprintf("%d", id))) 98 + // Use first 16 hex chars (8 bytes) - sufficient for integrity, keeps URLs short 99 + return hex.EncodeToString(mac.Sum(nil))[:16] 100 + } 101 + 83 102 func (s *ContentService) ProcessIRCLink(item data.IRCLink) DisplayItem { 84 103 d := DisplayItem{ 85 104 ID: item.ID, ··· 92 111 ContentType: item.ContentType, 93 112 BaseURL: s.Config.BaseURL, 94 113 EmbedType: EmbedTypeGeneric, // Default 114 + ClickSig: s.generateClickSignature(item.ID), 95 115 } 96 116 s.formatDate(&d) 97 117
+11 -2
internal/templates/renderer.go
··· 24 24 // templateFuncs provides helper functions for templates 25 25 var templateFuncs = template.FuncMap{ 26 26 // irclinkURL builds a click-tracking URL for IRC links 27 - "irclinkURL": func(baseURL string, id int) string { 27 + // If a signature is provided, it's appended for verified click tracking 28 + "irclinkURL": func(baseURL string, id int, sig string) string { 29 + if sig != "" { 30 + return fmt.Sprintf("%s/irclink/?%d&sig=%s", baseURL, id, sig) 31 + } 28 32 return fmt.Sprintf("%s/irclink/?%d", baseURL, id) 29 33 }, 30 34 // truncate shortens a string to max characters with ellipsis ··· 46 50 47 51 // textTemplateFuncs is the equivalent for text/template (XML) 48 52 var textTemplateFuncs = texttemplate.FuncMap{ 49 - "irclinkURL": func(baseURL string, id int) string { 53 + // irclinkURL builds a click-tracking URL for IRC links 54 + // If a signature is provided, it's appended for verified click tracking 55 + "irclinkURL": func(baseURL string, id int, sig string) string { 56 + if sig != "" { 57 + return fmt.Sprintf("%s/irclink/?%d&sig=%s", baseURL, id, sig) 58 + } 50 59 return fmt.Sprintf("%s/irclink/?%d", baseURL, id) 51 60 }, 52 61 "truncate": func(s string, max int) string {
+15 -3
internal/templates/views/index.html
··· 63 63 var url = item.getAttribute("data-url"); 64 64 var contentType = item.getAttribute("data-content-type"); 65 65 var ircId = item.getAttribute("data-irc-link-id"); 66 + var clickSig = item.getAttribute("data-click-sig"); 66 67 var previewDiv = item.querySelector(".og-preview"); 67 68 var imgurCard = item.querySelector(".imgur-gallery-card"); 68 69 ··· 105 106 }; 106 107 xhr.send(); 107 108 109 + // Helper to build signed IRC link URL 110 + function buildIrcLinkUrl(id, sig) { 111 + if (id && sig) { 112 + return '/irclink/?' + id + '&sig=' + sig; 113 + } else if (id) { 114 + return '/irclink/?' + id; 115 + } 116 + return null; 117 + } 118 + 108 119 function handlePreviewData(data, ircId) { 109 120 // Skip preview card for inline-rendered content (e.g., Flickr single photos) 110 121 if (data.render_inline === "true") { ··· 118 129 var linkSpan = item.querySelector(".link"); 119 130 if (linkSpan && !imgurCard) { 120 131 var statusPrefix = '<span class="http-error-badge">' + status + '</span> '; 121 - var linkHref = ircId ? '/irclink/?' + ircId : escapeHtml(url); 132 + var linkHref = buildIrcLinkUrl(ircId, clickSig) || escapeHtml(url); 122 133 linkSpan.innerHTML = '<a href="' + linkHref + '" class="missing-link" target="_blank">' + statusPrefix + escapeHtml(url) + '</a>'; 123 134 } 124 135 if (previewDiv) previewDiv.remove(); ··· 249 260 // User "The thumbnail image still isn't clickable... for links from Flickr". 250 261 // Let's do it if ircId exists and not video. 251 262 if (ircId && !data.embed_html && (data.type === 'photo' || provider === 'Flickr')) { 252 - innerContent = '<a href="/irclink/?id=' + ircId + '" target="_blank">' + imageContent + '</a>'; 263 + var imgLinkHref = buildIrcLinkUrl(ircId, clickSig) || escapeHtml(url); 264 + innerContent = '<a href="' + imgLinkHref + '" target="_blank">' + imageContent + '</a>'; 253 265 } 254 266 255 267 previewHTML += ··· 264 276 previewHTML += '<div class="og-content">'; 265 277 if (title) { 266 278 // Route through IRC link handler for click tracking if ircId is available 267 - var titleHref = ircId ? '/irclink/?' + ircId : escapeHtml(url); 279 + var titleHref = buildIrcLinkUrl(ircId, clickSig) || escapeHtml(url); 268 280 previewHTML += 269 281 '<div class="og-title"><a href="' + 270 282 titleHref +
+7 -7
internal/templates/views/tumble_item_ircLink.html
··· 1 - <div class="item" data-url="{{.URL}}" data-content-type="{{.ContentType}}" data-irc-link-id="{{.ID}}"> 1 + <div class="item" data-url="{{.URL}}" data-content-type="{{.ContentType}}" data-irc-link-id="{{.ID}}" data-click-sig="{{.ClickSig}}"> 2 2 <div class="link-card-wrapper"> 3 3 <div class="link-header"> 4 4 <span class="link"> ··· 8 8 {{else if eq .EmbedType "imgur_gallery"}} 9 9 {{/* Imgur gallery card */}} 10 10 {{if .IsBroken}} 11 - <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"><span class="http-error-badge">404</span> <span class="missing-link">{{.URL}}</span></a> 11 + <a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank"><span class="http-error-badge">404</span> <span class="missing-link">{{.URL}}</span></a> 12 12 {{else}} 13 13 <span class="imgur-gallery-card"> 14 - <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"> 14 + <a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank"> 15 15 <span class="gallery-image-container"> 16 16 <img src="{{.ThumbnailURL}}" 17 17 onload="if(this.naturalWidth===161 && this.naturalHeight===81){this.style.display='none'; this.nextElementSibling.style.display='block';}" ··· 27 27 {{end}} 28 28 {{else if eq .EmbedType "imgur_single"}} 29 29 {{/* Imgur single image/video */}} 30 - <a href="{{irclinkURL .BaseURL .ID}}" target="_blank" class="imgur-media-link"> 30 + <a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank" class="imgur-media-link"> 31 31 {{if .IsAnimated}} 32 32 <video autoplay loop muted playsinline class="imgur-video"> 33 33 <source src="{{.MediaURL}}" type="video/mp4" /> ··· 47 47 {{if .PhotoPageURL}} 48 48 <a href="{{.PhotoPageURL}}" target="_blank"><img src="{{.MediaURL}}" alt="{{.Title}}" class="flickr-image" /></a> 49 49 {{else}} 50 - <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"><img src="{{.MediaURL}}" alt="{{.Title}}" class="flickr-image" /></a> 50 + <a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank"><img src="{{.MediaURL}}" alt="{{.Title}}" class="flickr-image" /></a> 51 51 {{end}} 52 52 {{else if eq .EmbedType "image"}} 53 53 {{/* Direct image link */}} 54 - <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"> 54 + <a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank"> 55 55 <img src="{{.URL}}" class="direct-image" 56 56 onerror="this.parentNode.innerHTML='<span class=\'http-error-badge\'>404</span> <span class=\'missing-link\'>{{.URL}}</span>'; this.parentNode.classList.add('missing-link');" /> 57 57 </a> 58 58 {{else}} 59 59 {{/* Generic link */}} 60 - <a href="{{irclinkURL .BaseURL .ID}}" target="_blank">{{if .DisplayTitle}}{{.DisplayTitle}}{{else}}{{.Title}}{{end}}</a> 60 + <a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank">{{if .DisplayTitle}}{{.DisplayTitle}}{{else}}{{.Title}}{{end}}</a> 61 61 {{end}} 62 62 </span> 63 63 <span class="author"><a href="/?poster={{.User}}" data-tooltip="{{.FormattedDate}}">{{.User}}</a></span>
+1 -1
internal/templates/views/tumble_item_ircLink.xml
··· 1 1 <item> 2 2 <title>{{.Title}}</title> 3 - <link>{{irclinkURL .BaseURL .ID}}</link> 3 + <link>{{irclinkURL .BaseURL .ID .ClickSig}}</link> 4 4 <guid isPermaLink="false">tumble-{{.ID}}</guid> 5 5 <description><![CDATA[{{.Title}}]]></description> 6 6 <pubDate>{{.FormattedDate}}</pubDate>
+1 -1
internal/templates/views/tumble_item_top5.html
··· 1 - <div class="sm"><div class="link"><a href="{{irclinkURL .BaseURL .ID}}" target="_blank">{{truncate .Title 30}}</a></div></div> 1 + <div class="sm"><div class="link"><a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank">{{truncate .Title 30}}</a></div></div>