this repo has no description
1
fork

Configure Feed

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

feat: Migate /ircLink to /link

Remove the ircLink name as the primary endpoint and switch to /link as that is
more relatable for most folks.

+121 -46
+30 -4
README.md
··· 112 112 - `TUMBLE_EMBED_ASSETS=true` (Options: `true`, `false`. Default: `true`) 113 113 - `TUMBLE_LOGGING_LEVEL=debug` 114 114 - `TUMBLE_REQUEST_TIMEOUT=2s` (Default: `2s`) 115 + - `TUMBLE_CLICK_SIGNING_KEY=your-secret` (Optional, enables signed click tracking) 115 116 116 117 ### Environment Modes (`TUMBLE_MODE`) 117 118 ··· 273 274 274 275 #### Link Submission with Duplicate Detection 275 276 276 - When submitting links via `/irclink/`, the API automatically detects if a URL has been previously posted and provides contextual information: 277 + When submitting links via `/link/`, the API automatically detects if a URL has been previously posted and provides contextual information: 277 278 278 279 - **Behavior**: Links are always added to the database, even if duplicates exist 279 280 - **JSON API** (`Accept: application/json` or `source=api`): ··· 303 304 304 305 For complete API documentation, visit `/docs` on your running instance or see `internal/assets/openapi.json`. 305 306 307 + > **Note:** The legacy `/irclink/` endpoint is still supported for backwards compatibility but `/link/` is preferred. 308 + 306 309 #### Link Deletion 307 310 308 - Links can be deleted via the API using the `DELETE` method on `/irclink/{id}`. This requires authentication using an admin secret. 311 + Links can be deleted via the API using the `DELETE` method on `/link/123` (where `123` is the link ID). This requires authentication using an admin secret. 309 312 310 313 **Configuration:** 311 314 ··· 321 324 322 325 ```bash 323 326 # Using X-Admin-Secret header (recommended) 324 - curl -X DELETE -H "X-Admin-Secret: your-secret" https://your-server/irclink/123 327 + curl -X DELETE -H "X-Admin-Secret: your-secret" https://your-server/link/123 325 328 326 329 # Using query parameter 327 - curl -X DELETE "https://your-server/irclink/123?secret=your-secret" 330 + curl -X DELETE "https://your-server/link/123?secret=your-secret" 328 331 ``` 329 332 330 333 **Responses:** ··· 335 338 - **404 Not Found**: Link does not exist 336 339 337 340 If no `admin_secret` is configured, deletion falls back to localhost-only access for backwards compatibility. 341 + 342 + #### Click Signature Tracking 343 + 344 + Tumble supports signed URLs for verified click tracking. When enabled, links include an HMAC signature that validates clicks came from the rendered page rather than bots or direct URL access. 345 + 346 + **Configuration:** 347 + 348 + Add a `click_signing_key` to your `config.yaml`: 349 + 350 + ```yaml 351 + click_signing_key: "your-random-secret-string" 352 + ``` 353 + 354 + Or set via environment variable: `TUMBLE_CLICK_SIGNING_KEY=your-secret` 355 + 356 + **How it works:** 357 + 358 + - When configured, links render as `/link/123?sig=abc123...` instead of `/link/123` 359 + - The signature is an HMAC-SHA256 hash of the link ID using your secret key 360 + - On redirect, the server validates the signature to distinguish verified clicks from unverified access 361 + - This helps track genuine user engagement vs. crawler/bot traffic 362 + 363 + **Note:** If no `click_signing_key` is configured, links work normally without signatures. This feature is optional and doesn't affect basic functionality. 338 364 339 365 #### Caching 340 366
+4 -2
cmd/tumble/main.go
··· 165 165 mux.HandleFunc("/stats", h.Stats) 166 166 mux.HandleFunc("/search", h.Search) 167 167 mux.HandleFunc("/search.cgi", h.Search) 168 - mux.HandleFunc("/irclink/", h.IRCLinkHandler) // Handles /irclink/?id and posts 168 + mux.HandleFunc("/link/", h.IRCLinkHandler) // Primary endpoint for links 169 + mux.HandleFunc("/irclink/", h.IRCLinkHandler) // Legacy endpoint (backwards compatibility) 169 170 170 171 mux.HandleFunc("/ogpreview", h.OGPreviewHandler) 171 172 mux.HandleFunc("/ogpreview.cgi", h.OGPreviewHandler) ··· 177 178 mux.HandleFunc("/v0/", h.Index) 178 179 mux.HandleFunc("/v0/index.cgi", h.Index) 179 180 mux.HandleFunc("/v0/search.cgi", h.Search) 180 - mux.HandleFunc("/v0/irclink/", h.IRCLinkHandler) 181 + mux.HandleFunc("/v0/link/", h.IRCLinkHandler) // Primary v0 endpoint 182 + mux.HandleFunc("/v0/irclink/", h.IRCLinkHandler) // Legacy v0 endpoint 181 183 mux.HandleFunc("/v0/ogpreview.cgi", h.OGPreviewHandler) 182 184 mux.HandleFunc("/v0/quote/", h.QuoteHandler) 183 185
+42 -26
internal/assets/openapi.json
··· 12 12 } 13 13 ], 14 14 "paths": { 15 - "/irclink/": { 15 + "/link/{id}": { 16 16 "get": { 17 - "summary": "Redirect to an IRC Link", 17 + "summary": "Redirect to a Link", 18 18 "description": "Redirects to the URL associated with the given ID.", 19 19 "parameters": [ 20 20 { 21 21 "name": "id", 22 - "in": "query", 23 - "description": "The ID of the link to redirect to (can also be passed as the entire query string, e.g. /irclink/?123)", 22 + "in": "path", 23 + "description": "The ID of the link to redirect to", 24 24 "required": true, 25 25 "schema": { 26 26 "type": "integer" 27 27 } 28 + }, 29 + { 30 + "name": "sig", 31 + "in": "query", 32 + "description": "Click signature for verified click tracking", 33 + "required": false, 34 + "schema": { 35 + "type": "string" 36 + } 28 37 } 29 38 ], 30 39 "responses": { ··· 39 48 } 40 49 } 41 50 }, 51 + "delete": { 52 + "summary": "Delete a link", 53 + "description": "Deletes a link by ID. Requires admin authentication via X-Admin-Secret header or secret query parameter.", 54 + "parameters": [ 55 + { 56 + "name": "id", 57 + "in": "path", 58 + "description": "The ID of the link to delete", 59 + "required": true, 60 + "schema": { 61 + "type": "integer" 62 + } 63 + } 64 + ], 65 + "responses": { 66 + "200": { 67 + "description": "Link deleted successfully" 68 + }, 69 + "403": { 70 + "description": "Forbidden - invalid or missing admin secret" 71 + }, 72 + "404": { 73 + "description": "Link not found" 74 + } 75 + } 76 + } 77 + }, 78 + "/link/": { 42 79 "post": { 43 - "summary": "Submit a new IRC Link", 80 + "summary": "Submit a new Link", 44 81 "description": "Submits a new link. Links are always added even if duplicates exist. Returns different status codes and contextual information about previous submissions when duplicates are detected.", 45 82 "parameters": [ 46 83 { ··· 194 231 "500": { 195 232 "description": "Server Error" 196 233 } 197 - } 198 - }, 199 - "delete": { 200 - "summary": "Delete an IRC link", 201 - "parameters": [ 202 - { 203 - "name": "id", 204 - "in": "query", 205 - "required": true, 206 - "schema": { 207 - "type": "integer" 208 - } 209 - } 210 - ], 211 - "responses": { 212 - "200": { 213 - "description": "Link deleted successfully" 214 - }, 215 - "404": { 216 - "description": "Link not found" 217 - } 218 234 } 219 235 } 220 236 },
+18 -4
internal/handler/irclink.go
··· 197 197 return 198 198 } 199 199 200 - // Case 2: Redirecting (id param or query string) 201 - idStr := r.URL.Query().Get("id") 200 + // Case 2: Redirecting (id param, path, or query string) 201 + idStr := "" 202 + 203 + // First, try to extract ID from path: /link/123 or /irclink/123 204 + path := strings.TrimSuffix(r.URL.Path, "/") 205 + if idx := strings.LastIndex(path, "/"); idx != -1 { 206 + pathID := path[idx+1:] 207 + if _, err := strconv.Atoi(pathID); err == nil { 208 + idStr = pathID 209 + } 210 + } 211 + 212 + // Fallback to query parameter: ?id=123 202 213 if idStr == "" { 203 - // Fallback to RawQuery if param parsing failed 204 - // Handle formats like /irclink/?12345 or /irclink/?12345&sig=abc123 214 + idStr = r.URL.Query().Get("id") 215 + } 216 + 217 + // Fallback to RawQuery for legacy format: ?123 or ?123&sig=abc 218 + if idStr == "" { 205 219 rawQuery := r.URL.RawQuery 206 220 if idx := strings.Index(rawQuery, "&"); idx != -1 { 207 221 idStr = rawQuery[:idx]
+19 -2
internal/templates/renderer.go
··· 23 23 24 24 // templateFuncs provides helper functions for templates 25 25 var templateFuncs = template.FuncMap{ 26 - // irclinkURL builds a click-tracking URL for IRC links 26 + // linkURL builds a click-tracking URL for links (primary endpoint) 27 + // Uses clean path format: /link/123 or /link/123?sig=abc 28 + "linkURL": func(baseURL string, id int, sig string) string { 29 + if sig != "" { 30 + return fmt.Sprintf("%s/link/%d?sig=%s", baseURL, id, sig) 31 + } 32 + return fmt.Sprintf("%s/link/%d", baseURL, id) 33 + }, 34 + // irclinkURL builds a click-tracking URL for IRC links (legacy, use linkURL instead) 27 35 // If a signature is provided, it's appended for verified click tracking 28 36 "irclinkURL": func(baseURL string, id int, sig string) string { 29 37 if sig != "" { ··· 50 58 51 59 // textTemplateFuncs is the equivalent for text/template (XML) 52 60 var textTemplateFuncs = texttemplate.FuncMap{ 53 - // irclinkURL builds a click-tracking URL for IRC links 61 + // linkURL builds a click-tracking URL for links (primary endpoint) 62 + // Uses clean path format: /link/123 or /link/123?sig=abc 63 + // Note: Uses & for XML-safe output since text/template doesn't auto-escape 64 + "linkURL": func(baseURL string, id int, sig string) string { 65 + if sig != "" { 66 + return fmt.Sprintf("%s/link/%d?sig=%s", baseURL, id, sig) 67 + } 68 + return fmt.Sprintf("%s/link/%d", baseURL, id) 69 + }, 70 + // irclinkURL builds a click-tracking URL for IRC links (legacy, use linkURL instead) 54 71 // If a signature is provided, it's appended for verified click tracking 55 72 // Note: Uses & for XML-safe output since text/template doesn't auto-escape 56 73 "irclinkURL": func(baseURL string, id int, sig string) string {
+6 -6
internal/templates/views/tumble_item_ircLink.html
··· 8 8 {{else if eq .EmbedType "imgur_gallery"}} 9 9 {{/* Imgur gallery card */}} 10 10 {{if .IsBroken}} 11 - <a href="{{irclinkURL .BaseURL .ID .ClickSig}}" target="_blank"><span class="http-error-badge">404</span> <span class="missing-link">{{.URL}}</span></a> 11 + <a href="{{linkURL .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 .ClickSig}}" target="_blank"> 14 + <a href="{{linkURL .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 .ClickSig}}" target="_blank" class="imgur-media-link"> 30 + <a href="{{linkURL .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 .ClickSig}}" target="_blank"><img src="{{.MediaURL}}" alt="{{.Title}}" class="flickr-image" /></a> 50 + <a href="{{linkURL .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 .ClickSig}}" target="_blank"> 54 + <a href="{{linkURL .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 .ClickSig}}" target="_blank">{{if .DisplayTitle}}{{.DisplayTitle}}{{else}}{{.Title}}{{end}}</a> 60 + <a href="{{linkURL .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 .ClickSig}}</link> 3 + <link>{{linkURL .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 .ClickSig}}" target="_blank">{{truncate .Title 30}}</a></div></div> 1 + <div class="sm"><div class="link"><a href="{{linkURL .BaseURL .ID .ClickSig}}" target="_blank">{{truncate .Title 30}}</a></div></div>