mirror of
				https://github.com/binwiederhier/ntfy.git
				synced 2025-10-31 13:02:24 +01:00 
			
		
		
		
	Merge pull request #1064 from binwiederhier/templating-3
Message templating
This commit is contained in:
		
						commit
						913b59b5e3
					
				
					 13 changed files with 522 additions and 123 deletions
				
			
		
							
								
								
									
										138
									
								
								docs/publish.md
									
										
									
									
									
								
							
							
						
						
									
										138
									
								
								docs/publish.md
									
										
									
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -1338,6 +1338,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | |||
| 
 | ||||
| ## Not released yet | ||||
| 
 | ||||
| ### ntfy server v2.9.1 (UNRELEASED) | ||||
| 
 | ||||
| **Features:** | ||||
| 
 | ||||
| * [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body (e.g. `curl -gd '{"alert":"Disk space low"}' "ntfy.sh/mytopic?tpl=1&m={{.alert}}"`), which is great for services that let you specify a webhook URL but do not let you change the webhook body (such as GitHub, or Grafana). ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing) | ||||
| 
 | ||||
| ### ntfy Android app v1.16.1 (UNRELEASED) | ||||
| 
 | ||||
| **Features:** | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-template.jpg
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/static/img/android-screenshot-template.jpg
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 122 KiB | 
							
								
								
									
										130
									
								
								docs/static/js/extra.js
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										130
									
								
								docs/static/js/extra.js
									
										
									
									
										vendored
									
									
								
							|  | @ -1,99 +1,103 @@ | |||
| // Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
 | ||||
| 
 | ||||
| const savedCodeTab = localStorage.getItem('savedTab') | ||||
| const codeTabs = document.querySelectorAll(".tabbed-set > input") | ||||
| const savedCodeTab = localStorage.getItem("savedTab"); | ||||
| const codeTabs = document.querySelectorAll(".tabbed-set > input"); | ||||
| for (const tab of codeTabs) { | ||||
|     tab.addEventListener("click", () => { | ||||
|         const current = document.querySelector(`label[for=${tab.id}]`) | ||||
|         const pos = current.getBoundingClientRect().top | ||||
|         const labelContent = current.innerHTML | ||||
|         const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label') | ||||
|         for (const label of labels) { | ||||
|             if (label.innerHTML === labelContent) { | ||||
|                 document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Preserve scroll position
 | ||||
|         const delta = (current.getBoundingClientRect().top) - pos | ||||
|         window.scrollBy(0, delta) | ||||
| 
 | ||||
|         // Save
 | ||||
|         localStorage.setItem('savedTab', labelContent) | ||||
|     }) | ||||
| 
 | ||||
|     // Select saved tab
 | ||||
|     const current = document.querySelector(`label[for=${tab.id}]`) | ||||
|     const labelContent = current.innerHTML | ||||
|     if (savedCodeTab === labelContent) { | ||||
|         tab.checked = true | ||||
|   tab.addEventListener("click", () => { | ||||
|     const current = document.querySelector(`label[for=${tab.id}]`); | ||||
|     const pos = current.getBoundingClientRect().top; | ||||
|     const labelContent = current.innerHTML; | ||||
|     const labels = document.querySelectorAll(".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label"); | ||||
|     for (const label of labels) { | ||||
|       if (label.innerHTML === labelContent) { | ||||
|         document.querySelector(`input[id=${label.getAttribute("for")}]`).checked = true; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Preserve scroll position
 | ||||
|     const delta = (current.getBoundingClientRect().top) - pos; | ||||
|     window.scrollBy(0, delta); | ||||
| 
 | ||||
|     // Save
 | ||||
|     localStorage.setItem("savedTab", labelContent); | ||||
|   }); | ||||
| 
 | ||||
|   // Select saved tab
 | ||||
|   const current = document.querySelector(`label[for=${tab.id}]`); | ||||
|   const labelContent = current.innerHTML; | ||||
|   if (savedCodeTab === labelContent) { | ||||
|     tab.checked = true; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Lightbox for screenshot
 | ||||
| 
 | ||||
| const lightbox = document.createElement('div'); | ||||
| lightbox.classList.add('lightbox'); | ||||
| const lightbox = document.createElement("div"); | ||||
| lightbox.classList.add("lightbox"); | ||||
| document.body.appendChild(lightbox); | ||||
| 
 | ||||
| const showScreenshotOverlay = (e, el, group, index) => { | ||||
|     lightbox.classList.add('show'); | ||||
|     document.addEventListener('keydown', nextScreenshotKeyboardListener); | ||||
|     return showScreenshot(e, group, index); | ||||
|   lightbox.classList.add("show"); | ||||
|   document.addEventListener("keydown", nextScreenshotKeyboardListener); | ||||
|   return showScreenshot(e, group, index); | ||||
| }; | ||||
| 
 | ||||
| const showScreenshot = (e, group, index) => { | ||||
|     const actualIndex = resolveScreenshotIndex(group, index); | ||||
|     lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML; | ||||
|     lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); }; | ||||
|     currentScreenshotGroup = group; | ||||
|     currentScreenshotIndex = actualIndex; | ||||
|     e.stopPropagation(); | ||||
|     return false; | ||||
|   const actualIndex = resolveScreenshotIndex(group, index); | ||||
|   lightbox.innerHTML = "<div class=\"close-lightbox\"></div>" + screenshots[group][actualIndex].innerHTML; | ||||
|   lightbox.querySelector("img").onclick = (e) => { | ||||
|     return showScreenshot(e, group, actualIndex + 1); | ||||
|   }; | ||||
|   currentScreenshotGroup = group; | ||||
|   currentScreenshotIndex = actualIndex; | ||||
|   e.stopPropagation(); | ||||
|   return false; | ||||
| }; | ||||
| 
 | ||||
| const nextScreenshot = (e) => { | ||||
|     return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1); | ||||
|   return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1); | ||||
| }; | ||||
| 
 | ||||
| const previousScreenshot = (e) => { | ||||
|     return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1); | ||||
|   return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1); | ||||
| }; | ||||
| 
 | ||||
| const resolveScreenshotIndex = (group, index) => { | ||||
|     if (index < 0) { | ||||
|         return screenshots[group].length - 1; | ||||
|     } else if (index > screenshots[group].length - 1) { | ||||
|         return 0; | ||||
|     } | ||||
|     return index; | ||||
|   if (index < 0) { | ||||
|     return screenshots[group].length - 1; | ||||
|   } else if (index > screenshots[group].length - 1) { | ||||
|     return 0; | ||||
|   } | ||||
|   return index; | ||||
| }; | ||||
| 
 | ||||
| const hideScreenshotOverlay = (e) => { | ||||
|     lightbox.classList.remove('show'); | ||||
|     document.removeEventListener('keydown', nextScreenshotKeyboardListener); | ||||
|   lightbox.classList.remove("show"); | ||||
|   document.removeEventListener("keydown", nextScreenshotKeyboardListener); | ||||
| }; | ||||
| 
 | ||||
| const nextScreenshotKeyboardListener = (e) => { | ||||
|     switch (e.keyCode) { | ||||
|         case 37: | ||||
|             previousScreenshot(e); | ||||
|             break; | ||||
|         case 39: | ||||
|             nextScreenshot(e); | ||||
|             break; | ||||
|     } | ||||
|   switch (e.keyCode) { | ||||
|     case 37: | ||||
|       previousScreenshot(e); | ||||
|       break; | ||||
|     case 39: | ||||
|       nextScreenshot(e); | ||||
|       break; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| let currentScreenshotGroup = ''; | ||||
| let currentScreenshotGroup = ""; | ||||
| let currentScreenshotIndex = 0; | ||||
| let screenshots = {}; | ||||
| Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => { | ||||
|     const group = sg.id; | ||||
|     screenshots[group] = [...sg.querySelectorAll('a')]; | ||||
|     screenshots[group].forEach((el, index) => { | ||||
|         el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); }; | ||||
|     }); | ||||
| Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => { | ||||
|   const group = sg.id; | ||||
|   screenshots[group] = [...sg.querySelectorAll("a")]; | ||||
|   screenshots[group].forEach((el, index) => { | ||||
|     el.onclick = (e) => { | ||||
|       return showScreenshotOverlay(e, el, group, index); | ||||
|     }; | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| lightbox.onclick = hideScreenshotOverlay; | ||||
|  |  | |||
							
								
								
									
										22
									
								
								go.sum
									
										
									
									
									
								
							
							
						
						
									
										22
									
								
								go.sum
									
										
									
									
									
								
							|  | @ -1,24 +1,16 @@ | |||
| cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= | ||||
| cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM= | ||||
| cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4= | ||||
| cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU= | ||||
| cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE= | ||||
| cloud.google.com/go/compute v1.25.1 h1:ZRpHJedLtTpKgr3RV1Fx23NuaAEN1Zfx9hw1u4aJdjU= | ||||
| cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls= | ||||
| cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= | ||||
| cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= | ||||
| cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8= | ||||
| cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk= | ||||
| cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= | ||||
| cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= | ||||
| cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM= | ||||
| cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA= | ||||
| cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg= | ||||
| cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s= | ||||
| cloud.google.com/go/longrunning v0.5.6 h1:xAe8+0YaWoCKr9t1+aWe+OeQgN/iJK1fEgZSXmjuEaE= | ||||
| cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA= | ||||
| cloud.google.com/go/storage v1.39.0 h1:brbjUa4hbDHhpQf48tjqMaXEV+f1OGoaTmQau9tmCsA= | ||||
| cloud.google.com/go/storage v1.39.0/go.mod h1:OAEj/WZwUYjA3YHQ10/YcN9ttGuEpLwvaoyBXIPikEk= | ||||
| cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY= | ||||
| cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o= | ||||
| firebase.google.com/go/v4 v4.13.0 h1:meFz9nvDNh/FDyrEykoAzSfComcQbmnQSjoHrePRqeI= | ||||
|  | @ -41,8 +33,6 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj | |||
| github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||||
| github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= | ||||
| github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= | ||||
| github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | ||||
|  | @ -108,8 +98,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |||
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||
| github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= | ||||
| github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= | ||||
| github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA= | ||||
| github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc= | ||||
| github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= | ||||
| github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= | ||||
| github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= | ||||
|  | @ -157,8 +145,6 @@ github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ5 | |||
| github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= | ||||
| github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= | ||||
| github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= | ||||
| github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= | ||||
| github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= | ||||
| github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= | ||||
| github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= | ||||
| github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= | ||||
|  | @ -254,8 +240,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T | |||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= | ||||
| golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= | ||||
| google.golang.org/api v0.168.0 h1:MBRe+Ki4mMN93jhDDbpuRLjRddooArz4FeSObvUMmjY= | ||||
| google.golang.org/api v0.168.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg= | ||||
| google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48= | ||||
| google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8= | ||||
| google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||||
|  | @ -267,16 +251,10 @@ google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7 | |||
| google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= | ||||
| google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= | ||||
| google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= | ||||
| google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 h1:Fe8QycXyEd9mJgnwB9kmw00WgB43eQ/xYO5C6gceybQ= | ||||
| google.golang.org/genproto v0.0.0-20240304212257-790db918fca8/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI= | ||||
| google.golang.org/genproto v0.0.0-20240318140521-94a12d6c2237 h1:PgNlNSx2Nq2/j4juYzQBG0/Zdr+WP4z5N01Vk4VYBCY= | ||||
| google.golang.org/genproto v0.0.0-20240318140521-94a12d6c2237/go.mod h1:9sVD8c25Af3p0rGs7S7LLsxWKFiJt/65LdSyqXBkX/Y= | ||||
| google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ= | ||||
| google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= | ||||
| google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= | ||||
| google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= | ||||
| google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= | ||||
|  |  | |||
|  | @ -117,6 +117,10 @@ var ( | |||
| 	errHTTPBadRequestWebPushSubscriptionInvalid      = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil} | ||||
| 	errHTTPBadRequestWebPushEndpointUnknown          = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil} | ||||
| 	errHTTPBadRequestWebPushTopicCountTooHigh        = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil} | ||||
| 	errHTTPBadRequestTemplatedMessageTooLarge        = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "", nil} | ||||
| 	errHTTPBadRequestTemplatedMessageNotJSON         = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "", nil} | ||||
| 	errHTTPBadRequestTemplateInvalid                 = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "", nil} | ||||
| 	errHTTPBadRequestTemplateExecutionFailed         = &errHTTP{40044, http.StatusBadRequest, "invalid request: template execution failed", "", nil} | ||||
| 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} | ||||
| 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} | ||||
| 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} | ||||
|  |  | |||
							
								
								
									
										101
									
								
								server/server.go
									
										
									
									
									
								
							
							
						
						
									
										101
									
								
								server/server.go
									
										
									
									
									
								
							|  | @ -23,6 +23,7 @@ import ( | |||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"text/template" | ||||
| 	"time" | ||||
| 	"unicode/utf8" | ||||
| 
 | ||||
|  | @ -123,15 +124,16 @@ var ( | |||
| 
 | ||||
| const ( | ||||
| 	firebaseControlTopic     = "~control"                // See Android if changed | ||||
| 	firebasePollTopic        = "~poll"                   // See iOS if changed | ||||
| 	firebasePollTopic        = "~poll"                   // See iOS if changed (DISABLED for now) | ||||
| 	emptyMessageBody         = "triggered"               // Used if message body is empty | ||||
| 	newMessageBody           = "New message"             // Used in poll requests as generic message | ||||
| 	defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment | ||||
| 	encodingBase64           = "base64"                  // Used mainly for binary UnifiedPush messages | ||||
| 	jsonBodyBytesLimit       = 16384                     // Max number of bytes for a JSON request body | ||||
| 	jsonBodyBytesLimit       = 32768                     // Max number of bytes for a request bodys (unless MessageLimit is higher) | ||||
| 	unifiedPushTopicPrefix   = "up"                      // Temporarily, we rate limit all "up*" topics based on the subscriber | ||||
| 	unifiedPushTopicLength   = 14                        // Length of UnifiedPush topics, including the "up" part | ||||
| 	messagesHistoryMax       = 10                        // Number of message count values to keep in memory | ||||
| 	templateMaxExecutionTime = 100 * time.Millisecond | ||||
| ) | ||||
| 
 | ||||
| // WebSocket constants | ||||
|  | @ -673,7 +675,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) | |||
| 	//   - avoid abuse (e.g. 1 uploader, 1k downloaders) | ||||
| 	//   - and also uses the higher bandwidth limits of a paying user | ||||
| 	m, err := s.messageCache.Message(messageID) | ||||
| 	if err == errMessageNotFound { | ||||
| 	if errors.Is(err, errMessageNotFound) { | ||||
| 		if s.config.CacheBatchTimeout > 0 { | ||||
| 			// Strange edge case: If we immediately after upload request the file (the web app does this for images), | ||||
| 			// and messages are persisted asynchronously, retry fetching from the database | ||||
|  | @ -738,7 +740,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e | |||
| 		return nil, err | ||||
| 	} | ||||
| 	m := newDefaultMessage(t.ID, "") | ||||
| 	cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m) | ||||
| 	cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m) | ||||
| 	if e != nil { | ||||
| 		return nil, e.With(t) | ||||
| 	} | ||||
|  | @ -769,7 +771,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e | |||
| 	if cache { | ||||
| 		m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() | ||||
| 	} | ||||
| 	if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { | ||||
| 	if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if m.Message == "" { | ||||
|  | @ -872,7 +874,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) { | |||
| 	logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") | ||||
| 	if err := s.firebaseClient.Send(v, m); err != nil { | ||||
| 		minc(metricFirebasePublishedFailure) | ||||
| 		if err == errFirebaseTemporarilyBanned { | ||||
| 		if errors.Is(err, errFirebaseTemporarilyBanned) { | ||||
| 			logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error()) | ||||
| 		} else { | ||||
| 			logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error()) | ||||
|  | @ -924,7 +926,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) { | ||||
| func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) { | ||||
| 	cache = readBoolParam(r, true, "x-cache", "cache") | ||||
| 	firebase = readBoolParam(r, true, "x-firebase", "firebase") | ||||
| 	m.Title = readParam(r, "x-title", "title", "t") | ||||
|  | @ -940,7 +942,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi | |||
| 	} | ||||
| 	if attach != "" { | ||||
| 		if !urlRegex.MatchString(attach) { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid | ||||
| 		} | ||||
| 		m.Attachment.URL = attach | ||||
| 		if m.Attachment.Name == "" { | ||||
|  | @ -958,19 +960,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi | |||
| 	} | ||||
| 	if icon != "" { | ||||
| 		if !urlRegex.MatchString(icon) { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestIconURLInvalid | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid | ||||
| 		} | ||||
| 		m.Icon = icon | ||||
| 	} | ||||
| 	email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") | ||||
| 	if s.smtpSender == nil && email != "" { | ||||
| 		return false, false, "", "", false, errHTTPBadRequestEmailDisabled | ||||
| 		return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled | ||||
| 	} | ||||
| 	call = readParam(r, "x-call", "call") | ||||
| 	if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { | ||||
| 		return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled | ||||
| 		return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled | ||||
| 	} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { | ||||
| 		return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid | ||||
| 		return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid | ||||
| 	} | ||||
| 	messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") | ||||
| 	if messageStr != "" { | ||||
|  | @ -979,27 +981,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi | |||
| 	var e error | ||||
| 	m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) | ||||
| 	if e != nil { | ||||
| 		return false, false, "", "", false, errHTTPBadRequestPriorityInvalid | ||||
| 		return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid | ||||
| 	} | ||||
| 	m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") | ||||
| 	delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") | ||||
| 	if delayStr != "" { | ||||
| 		if !cache { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestDelayNoCache | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache | ||||
| 		} | ||||
| 		if email != "" { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) | ||||
| 		} | ||||
| 		if call != "" { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) | ||||
| 		} | ||||
| 		delay, err := util.ParseFutureTime(delayStr, time.Now()) | ||||
| 		if err != nil { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestDelayCannotParse | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse | ||||
| 		} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestDelayTooSmall | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall | ||||
| 		} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestDelayTooLarge | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge | ||||
| 		} | ||||
| 		m.Time = delay.Unix() | ||||
| 	} | ||||
|  | @ -1007,13 +1009,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi | |||
| 	if actionsStr != "" { | ||||
| 		m.Actions, e = parseActions(actionsStr) | ||||
| 		if e != nil { | ||||
| 			return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) | ||||
| 			return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) | ||||
| 		} | ||||
| 	} | ||||
| 	contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") | ||||
| 	if markdown || strings.ToLower(contentType) == "text/markdown" { | ||||
| 		m.ContentType = "text/markdown" | ||||
| 	} | ||||
| 	template = readBoolParam(r, false, "x-template", "template", "tpl") | ||||
| 	unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! | ||||
| 	if unifiedpush { | ||||
| 		firebase = false | ||||
|  | @ -1025,7 +1028,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi | |||
| 		cache = false | ||||
| 		email = "" | ||||
| 	} | ||||
| 	return cache, firebase, email, call, unifiedpush, nil | ||||
| 	return cache, firebase, email, call, template, unifiedpush, nil | ||||
| } | ||||
| 
 | ||||
| // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. | ||||
|  | @ -1033,16 +1036,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi | |||
| //  1. curl -X POST -H "Poll: 1234" ntfy.sh/... | ||||
| //     If a message is flagged as poll request, the body does not matter and is discarded | ||||
| //  2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1" | ||||
| //     If body is binary, encode as base64, if not do not encode | ||||
| //     If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim | ||||
| //  3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic | ||||
| //     Body must be a message, because we attached an external URL | ||||
| //  4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic | ||||
| //     Body must be attachment, because we passed a filename | ||||
| //  5. curl -T file.txt ntfy.sh/mytopic | ||||
| //     If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message | ||||
| //  5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic | ||||
| //     If templating is enabled, read up to 32k and treat message body as JSON | ||||
| //  6. curl -T file.txt ntfy.sh/mytopic | ||||
| //     If file.txt is > message limit, treat it as an attachment | ||||
| func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error { | ||||
| //     If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message | ||||
| //  7. curl -T file.txt ntfy.sh/mytopic | ||||
| //     In all other cases, mostly if file.txt is > message limit, treat it as an attachment | ||||
| func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error { | ||||
| 	if m.Event == pollRequestEvent { // Case 1 | ||||
| 		return s.handleBodyDiscard(body) | ||||
| 	} else if unifiedpush { | ||||
|  | @ -1051,10 +1056,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body | |||
| 		return s.handleBodyAsTextMessage(m, body) // Case 3 | ||||
| 	} else if m.Attachment != nil && m.Attachment.Name != "" { | ||||
| 		return s.handleBodyAsAttachment(r, v, m, body) // Case 4 | ||||
| 	} else if template { | ||||
| 		return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5 | ||||
| 	} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { | ||||
| 		return s.handleBodyAsTextMessage(m, body) // Case 5 | ||||
| 		return s.handleBodyAsTextMessage(m, body) // Case 6 | ||||
| 	} | ||||
| 	return s.handleBodyAsAttachment(r, v, m, body) // Case 6 | ||||
| 	return s.handleBodyAsAttachment(r, v, m, body) // Case 7 | ||||
| } | ||||
| 
 | ||||
| func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error { | ||||
|  | @ -1086,6 +1093,42 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error { | ||||
| 	body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} else if body.LimitReached { | ||||
| 		return errHTTPEntityTooLargeJSONBody | ||||
| 	} | ||||
| 	peekedBody := strings.TrimSpace(string(body.PeekedBytes)) | ||||
| 	if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if len(m.Message) > s.config.MessageSizeLimit { | ||||
| 		return errHTTPBadRequestTemplatedMessageTooLarge | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func replaceTemplate(tpl string, source string) (string, error) { | ||||
| 	var data any | ||||
| 	if err := json.Unmarshal([]byte(source), &data); err != nil { | ||||
| 		return "", errHTTPBadRequestTemplatedMessageNotJSON | ||||
| 	} | ||||
| 	t, err := template.New("").Parse(tpl) | ||||
| 	if err != nil { | ||||
| 		return "", errHTTPBadRequestTemplateInvalid | ||||
| 	} | ||||
| 	var buf bytes.Buffer | ||||
| 	if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { | ||||
| 		return "", errHTTPBadRequestTemplateExecutionFailed | ||||
| 	} | ||||
| 	return buf.String(), nil | ||||
| } | ||||
| 
 | ||||
| func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { | ||||
| 	if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { | ||||
| 		return errHTTPBadRequestAttachmentsDisallowed.With(m) | ||||
|  | @ -1128,7 +1171,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, | |||
| 		util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining), | ||||
| 	} | ||||
| 	m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...) | ||||
| 	if err == util.ErrLimitReached { | ||||
| 	if errors.Is(err, util.ErrLimitReached) { | ||||
| 		return errHTTPEntityTooLargeAttachment.With(m) | ||||
| 	} else if err != nil { | ||||
| 		return err | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| package server | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"heckel.io/ntfy/v2/user" | ||||
| 	"net/http" | ||||
| ) | ||||
|  | @ -45,7 +46,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit | |||
| 		return errHTTPBadRequest.Wrap("username invalid, or password missing") | ||||
| 	} | ||||
| 	u, err := s.userManager.User(req.Username) | ||||
| 	if err != nil && err != user.ErrUserNotFound { | ||||
| 	if err != nil && !errors.Is(err, user.ErrUserNotFound) { | ||||
| 		return err | ||||
| 	} else if u != nil { | ||||
| 		return errHTTPConflictUserExists | ||||
|  | @ -53,7 +54,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit | |||
| 	var tier *user.Tier | ||||
| 	if req.Tier != "" { | ||||
| 		tier, err = s.userManager.Tier(req.Tier) | ||||
| 		if err == user.ErrTierNotFound { | ||||
| 		if errors.Is(err, user.ErrTierNotFound) { | ||||
| 			return errHTTPBadRequestTierInvalid | ||||
| 		} else if err != nil { | ||||
| 			return err | ||||
|  | @ -76,7 +77,7 @@ func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *vi | |||
| 		return err | ||||
| 	} | ||||
| 	u, err := s.userManager.User(req.Username) | ||||
| 	if err == user.ErrUserNotFound { | ||||
| 	if errors.Is(err, user.ErrUserNotFound) { | ||||
| 		return errHTTPBadRequestUserNotFound | ||||
| 	} else if err != nil { | ||||
| 		return err | ||||
|  | @ -98,7 +99,7 @@ func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *vi | |||
| 		return err | ||||
| 	} | ||||
| 	_, err = s.userManager.User(req.Username) | ||||
| 	if err == user.ErrUserNotFound { | ||||
| 	if errors.Is(err, user.ErrUserNotFound) { | ||||
| 		return errHTTPBadRequestUserNotFound | ||||
| 	} else if err != nil { | ||||
| 		return err | ||||
|  |  | |||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -2,6 +2,7 @@ package server | |||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"heckel.io/ntfy/v2/util" | ||||
| 	"io" | ||||
|  | @ -104,9 +105,9 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr { | |||
| 
 | ||||
| func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) { | ||||
| 	obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty) | ||||
| 	if err == util.ErrUnmarshalJSON { | ||||
| 	if errors.Is(err, util.ErrUnmarshalJSON) { | ||||
| 		return nil, errHTTPBadRequestJSONInvalid | ||||
| 	} else if err == util.ErrTooLargeJSON { | ||||
| 	} else if errors.Is(err, util.ErrTooLargeJSON) { | ||||
| 		return nil, errHTTPEntityTooLargeJSONBody | ||||
| 	} else if err != nil { | ||||
| 		return nil, err | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) { | |||
| 
 | ||||
| // StartServerWithConfig starts a server.Server with a random port and waits for the server to be up | ||||
| func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) { | ||||
| 	port := 10000 + rand.Intn(20000) | ||||
| 	port := 10000 + rand.Intn(30000) | ||||
| 	conf.ListenHTTP = fmt.Sprintf(":%d", port) | ||||
| 	conf.AttachmentCacheDir = t.TempDir() | ||||
| 	conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ package util | |||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"strings" | ||||
| ) | ||||
|  | @ -26,7 +27,7 @@ func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) { | |||
| 	} | ||||
| 	peeked := make([]byte, limit) | ||||
| 	read, err := io.ReadFull(underlying, peeked) | ||||
| 	if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { | ||||
| 	if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && err != io.EOF { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &PeekedReadCloser{ | ||||
|  | @ -44,7 +45,7 @@ func (r *PeekedReadCloser) Read(p []byte) (n int, err error) { | |||
| 		return 0, io.EOF | ||||
| 	} | ||||
| 	n, err = r.peeked.Read(p) | ||||
| 	if err == io.EOF { | ||||
| 	if errors.Is(err, io.EOF) { | ||||
| 		return r.underlying.Read(p) | ||||
| 	} else if err != nil { | ||||
| 		return 0, err | ||||
|  |  | |||
							
								
								
									
										34
									
								
								util/timeout_writer.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								util/timeout_writer.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| package util | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| // ErrWriteTimeout is returned when a write timed out | ||||
| var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation") | ||||
| 
 | ||||
| // TimeoutWriter wraps an io.Writer that will time out after the given timeout | ||||
| type TimeoutWriter struct { | ||||
| 	writer  io.Writer | ||||
| 	timeout time.Duration | ||||
| 	start   time.Time | ||||
| } | ||||
| 
 | ||||
| // NewTimeoutWriter creates a new TimeoutWriter | ||||
| func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter { | ||||
| 	return &TimeoutWriter{ | ||||
| 		writer:  w, | ||||
| 		timeout: timeout, | ||||
| 		start:   time.Now(), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // Write implements the io.Writer interface, failing if called after the timeout period from creation. | ||||
| func (tw *TimeoutWriter) Write(p []byte) (n int, err error) { | ||||
| 	if time.Since(tw.start) > tw.timeout { | ||||
| 		return 0, errors.New("write operation failed due to timeout since creation") | ||||
| 	} | ||||
| 	return tw.writer.Write(p) | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Philipp C. Heckel
						Philipp C. Heckel