fuik 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -1
  3. data/app/assets/images/fuik/icons/46elks.jpg +0 -0
  4. data/app/assets/images/fuik/icons/lettermint.jpg +0 -0
  5. data/app/assets/images/fuik/icons/smtp2go.jpg +0 -0
  6. data/app/assets/javascript/attractivejs-0.12.0.min.js +1 -0
  7. data/app/assets/stylesheets/fuik/application.css +59 -0
  8. data/app/controllers/fuik/events_controller.rb +1 -1
  9. data/app/controllers/fuik/webhooks_controller.rb +20 -3
  10. data/app/helpers/fuik/highlight_helper.rb +1 -1
  11. data/app/jobs/fuik/webhook_processing_job.rb +7 -0
  12. data/app/models/fuik/webhook_event/filterable.rb +26 -0
  13. data/app/models/fuik/webhook_event.rb +2 -0
  14. data/app/views/fuik/events/_filters.html.erb +29 -0
  15. data/app/views/fuik/events/index.html.erb +2 -0
  16. data/app/views/layouts/fuik/application.html.erb +1 -1
  17. data/config/routes.rb +1 -1
  18. data/lib/fuik/engine.rb +3 -0
  19. data/lib/fuik/routing/provider_constraint.rb +34 -0
  20. data/lib/fuik/version.rb +1 -1
  21. data/lib/generators/fuik/provider/templates/github/installation_created.rb.tt +2 -2
  22. data/lib/generators/fuik/provider/templates/github/push.rb.tt +2 -4
  23. data/lib/generators/fuik/provider/templates/github/star_created.rb.tt +2 -2
  24. data/lib/generators/fuik/provider/templates/lettermint/base.rb.tt +32 -0
  25. data/lib/generators/fuik/provider/templates/lettermint/message_failed.rb.tt +16 -0
  26. data/lib/generators/fuik/provider/templates/lettermint/message_hard_bounced.rb.tt +16 -0
  27. data/lib/generators/fuik/provider/templates/lettermint/message_soft_bounced.rb.tt +16 -0
  28. data/lib/generators/fuik/provider/templates/lettermint/message_suppressed.rb.tt +15 -0
  29. data/lib/generators/fuik/provider/templates/loops/base.rb.tt +17 -0
  30. data/lib/generators/fuik/provider/templates/loops/contact_unsubscribed.rb.tt +12 -0
  31. data/lib/generators/fuik/provider/templates/loops/email_hard_bounced.rb.tt +14 -0
  32. data/lib/generators/fuik/provider/templates/mailgun/base.rb.tt +18 -0
  33. data/lib/generators/fuik/provider/templates/mailgun/bounced.rb.tt +14 -0
  34. data/lib/generators/fuik/provider/templates/mailgun/complained.rb.tt +12 -0
  35. data/lib/generators/fuik/provider/templates/mailpace/email_bounced.rb.tt +2 -1
  36. data/lib/generators/fuik/provider/templates/mailpace/email_spam.rb.tt +3 -0
  37. data/lib/generators/fuik/provider/templates/postmark/base.rb.tt +4 -0
  38. data/lib/generators/fuik/provider/templates/postmark/bounce.rb.tt +17 -0
  39. data/lib/generators/fuik/provider/templates/resend/base.rb.tt +11 -0
  40. data/lib/generators/fuik/provider/templates/resend/email_bounced.rb.tt +14 -0
  41. data/lib/generators/fuik/provider/templates/resend/email_complained.rb.tt +12 -0
  42. data/lib/generators/fuik/provider/templates/smtp2go/base.rb.tt +4 -0
  43. data/lib/generators/fuik/provider/templates/smtp2go/bounce.rb.tt +16 -0
  44. data/lib/generators/fuik/provider/templates/smtp2go/reject.rb.tt +14 -0
  45. data/lib/generators/fuik/provider/templates/smtp2go/spam.rb.tt +13 -0
  46. data/lib/generators/fuik/provider/templates/smtp2go/unsubscribe.rb.tt +13 -0
  47. data/lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt +5 -5
  48. data/lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt +2 -1
  49. data/lib/generators/fuik/provider/templates/stripe/customer_subscription_updated.rb.tt +5 -4
  50. data/lib/generators/fuik/provider/templates/stripe/payment_intent_succeeded.rb.tt +5 -4
  51. data/lib/generators/fuik/provider/templates/userlist/base.rb.tt +18 -0
  52. data/lib/generators/fuik/provider/templates/userlist/user_unsubscribed.rb.tt +14 -0
  53. metadata +33 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75982b79f02eab5a1ee77bb7a05e00c9dac8a584f9d17b43b1a3ae9ce8b15f17
4
- data.tar.gz: e446a0526a042e8a0789f4a5bb967ddcd55fd806d0ca79f1c53b1315ac51f455
3
+ metadata.gz: 4a653582dac2c822bb4496a89d0dbd12cd9b96a082bdeed11c209793df27de21
4
+ data.tar.gz: 44070a1620d3d867cc5a400e7f61bbd58bebec6e713407d6da6a65d8d2d2037b
5
5
  SHA512:
6
- metadata.gz: f96fdca885fd963d87cf474d82bb6cc96d4416085f59a80ed70b85d0f0725de1eaf2ad1008400aa0362ae121b5ca5b099b2e44ed25e66b560fa01f8b5649d657
7
- data.tar.gz: 1b86640a5c749590eebab88a3002197a278784f1643e72cf0f8550cc4663933853b9eb4b1949a66d697cddf491e3812305ce92cb276ac3c3f8adc8d906e82270
6
+ metadata.gz: c870f47e09396993506e96851c491d0e715ddf87d964554aba212e83927a2e9370c75b601cce24f8b4a0653b1b8cfe16f3825fb916048fa9828c05e95ab8d49a
7
+ data.tar.gz: 9ce68f8cb2d9aa7af1aa3cfb6a394a1a26cc165ed6dc58320fbfc0b266d219e839d0a29a352164fadce614ff75579dcb8041077d553cdbd45cfe6424d709125e
data/README.md CHANGED
@@ -6,7 +6,6 @@ Fuik (Dutch for fish trap) is a Rails engine that catches and stores webhooks fr
6
6
 
7
7
  <img alt="Fuik admin interface" src="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/docs/webhooks-index.jpg" style="max-width: 100%;">
8
8
 
9
-
10
9
  **Sponsored By [Rails Designer](https://railsdesigner.com/)**
11
10
 
12
11
  <a href="https://railsdesigner.com/" target="_blank">
@@ -60,6 +59,13 @@ Visit `/webhooks` to see all received webhooks. Click any event to view all the
60
59
 
61
60
  ⚠️ The `/webhooks` path is by default not protected. Easiest is to set `Fuik::Engine.config.events_controller_parent` to a controller that requires authentication.
62
61
 
62
+ ### Dashboard features
63
+
64
+ * **Copy payload as JSON**: click a button, payload is in your clipboard
65
+ * **Download payload as JSON file**: keep it for testing, debugging or throw it at your LLM agent, bot or colleague
66
+ * **Add `.json` to any URL**: get the raw payload without the UI
67
+ * **Click any key to get the Ruby accessor path**: click `product_id` (as seen in the screenshot above) and get `payload["line_items"][0]["product_id"]` (say what? 🤯)
68
+
63
69
 
64
70
  ### Add business logic
65
71
 
@@ -124,6 +130,24 @@ end
124
130
  If `Provider::Base.verify!` exists, Fuik calls it automatically. Invalid signatures return 401 without storing the webhook.
125
131
 
126
132
 
133
+ ### Provider allowlist
134
+
135
+ By default:
136
+ - **Development/test**: all providers are allowed
137
+ - **Production/staging**: only providers in `app/webhooks/` are allowed
138
+
139
+ Configure with `Fuik::Engine.config.providers_allowed`:
140
+ ```ruby
141
+ # Allow all (including production)
142
+ Fuik::Engine.config.providers_allowed = :all
143
+
144
+ # Explicit allowlist (overrides directory scan)
145
+ Fuik::Engine.config.providers_allowed = %w[stripe github shopify]
146
+ ```
147
+
148
+ Unknown providers return `404 Not Found`.
149
+
150
+
127
151
  ### Pre-packaged providers
128
152
 
129
153
  Fuik includes ready-to-use [templates for common providers](https://github.com/Rails-Designer/fuik/tree/main/lib/generators/fuik/provider/templates).
@@ -0,0 +1 @@
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Attractive=e()}(this,(function(){"use strict";class t{static enabled=!1;static prefix="🧲 ";static log(...t){this.enabled&&console.log(this.prefix,...t)}static warn(...t){this.enabled&&console.warn(this.prefix,...t)}static error(...t){this.enabled&&console.error(this.prefix,...t)}static throw(t){throw new Error(`${this.prefix}${t}`)}}class e{#t;constructor(e){t.log("Events with actions:",Object.keys(e)),this.#t=e}process(t,{on:e,using:s}){e&&this.#e(e.dataset.action).some((r=>!1===this.#s(r,{for:t,on:e,using:s})))}#e(t){return t.split(" ").filter((t=>t))}#s(e,{for:s,on:r,using:i}){if(t.log("Process action for",s.type,"on",r,"…"),e.includes(":")){const[t,r]=e.split(":");if(s.type!==r)return;e=t}else if(e.includes("->")){const[t,r]=e.split("->");if((t.includes("@")?t.split("@")[1]:t)!==s.type)return;e=r}else if(s.type!==i)return;return t.log("…","processed action for",s.type,"on",r),this.#r(e,{on:r,for:s})}#r(e,{on:s,for:r}){t.log("Execute action",e,"on",s,"…");const i=e.split("#"),[n,a,o]=i,c=this.#t[n]?n:a??e;if("function"!=typeof this.#t[c])return;const l=this.#t[n]?i.slice(1).join("#"):o??null,u=this.#t[c](s,{value:l,target:s.dataset.target,targets:s.dataset.targets});return!1===u&&r&&r.preventDefault(),t.log("…","executed action",e,"on",s),u}}var s=new class{identify({by:t}){return t.split(" ").filter((t=>t.includes("->"))).map((t=>t.split("->")[0]))}getDefault({from:t}){const e=t.tagName.toLowerCase(),s="input"===e,r=s?t.type||"text":null;return s?this.#i.input[r]||this.#i.input.default:this.#i[e]||this.#i.default}#i={a:"click",button:"click",input:{checkbox:"change",radio:"change",submit:"click",button:"click",reset:"click",default:"input"},select:"change",textarea:"input",form:"submit",default:"click"}};class r{#n;#a;#o;constructor(t,e=null){this.#n=t,this.#a=e}start(t){if(window.MutationObserver)return this.#o=new MutationObserver((e=>{const s=new Set,r=new Set;e.forEach((e=>this.#c(e,{for:t,elements:{added:s,removed:r}}))),s.forEach((t=>this.#n(t))),this.#a&&r.forEach((t=>this.#a(t)))})),this.#o.observe(document.documentElement,{childList:!0,subtree:!0}),this}stop(){return this.#o&&this.#o.disconnect(),this}#c(t,{for:e,elements:{added:s,removed:r}}){"childList"===t.type&&(t.addedNodes.forEach((t=>{this.#l(t,{for:e,and:s})})),t.removedNodes.forEach((t=>{this.#l(t,{for:e,and:r})})))}#l(t,{for:e,and:s}){t.nodeType===Node.ELEMENT_NODE&&(t.matches&&t.matches(e)&&s.add(t),t.querySelectorAll&&t.querySelectorAll(e).forEach((t=>s.add(t))))}}var i=new class{#u={mounted:(t,e)=>{e()},now:(t,e)=>{e()},whenVisible:(t,e)=>{const s=new IntersectionObserver((t=>{t.forEach((t=>{t.isIntersecting&&(e(),s.disconnect())}))}));s.observe(t)}};setup({for:t,on:e,trigger:s}){const r=this.#u[t];return!!r&&(r(e,s),!0)}};class n{constructor(t,e={}){if(!t)throw new Error("Current element is required");this.currentElement=t,this.target=e.target,this.targetsSelector=e.targets,this.options=e}get targets(){if(this.targetsSelector)return Array.from(document.querySelectorAll(this.targetsSelector));if(this.target){const t=document.getElementById(this.target);return t?[t]:[]}return[this.currentElement]}cycledValue(t,e){const s=Array.isArray(e)?e:e.split(",").map((t=>t.trim())),r=s.indexOf(t);return s[(r+1)%s.length]}}class a extends n{constructor(t,e={}){super(t,e);const[s,r]=e.value.split("=");this.attribute=s,this.value=r}toggle(){this.attribute&&this.targets.forEach((t=>t.hasAttribute(this.attribute)?t.removeAttribute(this.attribute):t.setAttribute(this.attribute,this.value||"")))}cycle(){this.value&&this.targets.forEach((t=>this.#h(t)))}add(){this.attribute&&this.targets.forEach((t=>t.setAttribute(this.attribute,this.value||"")))}remove(){this.attribute&&this.targets.forEach((t=>t.removeAttribute(this.attribute)))}#h(t){const e=this.cycledValue(t.getAttribute(this.attribute),this.value);t.setAttribute(this.attribute,e)}}const o=t=>(e,s={})=>new a(e,s)[t]();var c={toggleAttribute:o("toggle"),cycleAttribute:o("cycle"),addAttribute:o("add"),removeAttribute:o("remove")};const l=t=>t?.split(",").map((t=>t.trim())).filter(Boolean)??[];class u extends n{constructor(t,e={}){super(t,e),this.value=l(e.value)}toggle(){this.value&&this.targets.forEach((t=>this.#d({forEach:t})))}cycle(){this.value&&0!==this.value.length&&this.targets.forEach((t=>this.#f(t)))}add(){this.value&&this.targets.forEach((t=>t.classList.add(...this.value)))}remove(){this.value&&this.targets.forEach((t=>t.classList.remove(...this.value)))}#d({forEach:t}){this.value.forEach((e=>t.classList.toggle(e)))}#f(t){const e=this.value.find((e=>t.classList.contains(e)))||"",s=this.cycledValue(e,this.value);t.classList.remove(...this.value),t.classList.add(s)}}const h=t=>(e,s={})=>new u(e,s)[t]();var d={toggleClass:h("toggle"),cycleClass:h("cycle"),addClass:h("add"),removeClass:h("remove")};let f;const g=(t,e)=>{clearTimeout(f),f=setTimeout(t,e)};class v extends n{constructor(t,e={}){super(t,e),this.value=e.value}async copy(){const t=this.value||(this.targets[0]?.value??this.targets[0]?.textContent);if(void 0!==t)try{await navigator.clipboard.writeText(t),this.#g(!0)}catch(t){this.#g(!1)}}#g(t){const e=parseInt(this.currentElement.dataset.copyDelay);this.targets.forEach((e=>e.setAttribute(this.#v,t))),e&&g((()=>this.targets.forEach((t=>t.removeAttribute(this.#v)))),e)}get#v(){return"data-copy-success"}}var m,p={copy:(m="copy",(t,e={})=>new v(t,e)[m]())};class b extends n{confirm(){const t=this.currentElement.dataset.confirmMessage||"Are you sure?",e=window.confirm(t);return this.#g(e),e}#g(t){this.currentElement.setAttribute("data-confirm-success",t);const e=this.currentElement.dataset.confirmDuration;e&&g((()=>this.currentElement.removeAttribute("data-confirm-success")),parseInt(e))}}var E={confirm:(t=>(e,s={})=>new b(e,s)[t]())("confirm")};class w extends n{constructor(t,e={}){super(t,e);const[s,r]=e.value.split("=");this.attribute=s,this.value=r}toggle(){this.attribute&&this.targets.forEach((t=>{this.attribute in t.dataset?delete t.dataset[this.attribute]:t.dataset[this.attribute]=this.value||""}))}cycle(){this.value&&this.targets.forEach((t=>this.#m(t)))}add(){this.attribute&&this.targets.forEach((t=>{t.dataset[this.attribute]=this.value||""}))}remove(){this.attribute&&this.targets.forEach((t=>delete t.dataset[this.attribute]))}#m(t){const e=this.cycledValue(t.dataset[this.attribute],this.value);t.dataset[this.attribute]=e}}const y=t=>(e,s={})=>new w(e,s)[t]();var A={toggleDataAttribute:y("toggle"),cycleDataAttribute:y("cycle"),addDataAttribute:y("add"),removeDataAttribute:y("remove")};class L extends n{open(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.show()))}openModal(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.showModal()))}close(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.close()))}}const T=t=>(e,s={})=>{new L(e,s)[t]()};var x={open:T("open"),openModal:T("openModal"),close:T("close")};class M extends n{add(){const t=this.targets[0];if(!t)return;const e=this.currentElement.dataset.addSource;if(!e)return;const s=document.querySelector(e);if(!s)return;const r=this.#p(s),i=this.currentElement.dataset.addAt||"beforeend";t.insertAdjacentElement(i,r)}remove(){const t=parseInt(this.currentElement.dataset.removeDelay),e=()=>{this.targets.forEach((t=>t.remove()))};t?setTimeout(e,t):e()}#p(t){return"TEMPLATE"===t.tagName?t.content.cloneNode(!0).firstElementChild:t.cloneNode(!0)}}const S=t=>(e,s={})=>new M(e,s)[t]();var k={add:S("add"),remove:S("remove")};class N extends n{requestSubmit(){const t=parseInt(this.currentElement.dataset.submitDelay)||0,e=()=>this.targets.forEach((t=>t instanceof HTMLFormElement&&t.requestSubmit()));t?g(e,t):e()}reset(){this.targets.forEach((t=>t instanceof HTMLFormElement&&t.reset()))}}const F=t=>(e,s={})=>{new N(e,s)[t]()};var D={submit:F("requestSubmit"),reset:F("reset")};class q extends n{constructor(t,e={}){super(t,e),this.classNames=l(e.value),this.justOnce="intersect-once"===e.actionName}start(){if(0===this.classNames.length)return;const t=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting?this.#b(t,e.target):this.#E()}))}));t.observe(this.currentElement)}#b(t,e){this.targets.forEach((t=>t.classList.add(...this.classNames))),this.justOnce&&t.unobserve(e)}#E(){this.justOnce||this.targets.forEach((t=>t.classList.remove(...this.classNames)))}}const C=t=>(e,s={})=>new q(e,{...s,actionName:t}).start();var O={"intersect-once":C("intersect-once"),"intersect-toggle":C("intersect-toggle")};class I extends n{reload(){this.targets.forEach((t=>{this.#w(t)?t.reload():window.location.reload()}))}#w(t){return"TURBO-FRAME"===t.tagName&&"function"==typeof t.reload}}const H=t=>(e,s={})=>new I(e,s)[t]();var V={reload:H("reload"),refresh:H("reload")};class j{static get(){const t=document.querySelector('meta[name="csrf-token"]')?.content;return t||null}}class $ extends n{constructor(t,e={}){super(t,e),this.value=e.value}async get(){if(this.value){this.#y({with:this.value})&&console.warn(`Cross-origin request to: ${this.value}. Missing the correct CORS headers.`),this.#g("busy");try{const t=await fetch(this.value,{method:"GET"});if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);const e=await t.text();return this.targets.forEach((t=>{t.innerHTML=e})),this.#g("success"),t}catch(t){throw console.error("GET request failed:",t),this.#g("error"),t}}else console.warn("No URL provided in the action value")}post(){return this.#A("POST")}patch(){return this.#A("PATCH")}put(){return this.#A("PUT")}#y({with:t}){try{return new URL(t,window.location.href).origin!==window.location.origin}catch{return console.error("Invalid URL:",t),!1}}#g(t){const e=this.currentElement.dataset.requestDuration;this.targets.forEach((e=>{"busy"===t?(e.setAttribute("data-request-busy","true"),e.removeAttribute("data-request-success")):(e.removeAttribute("data-request-busy"),e.setAttribute("data-request-success","success"===t))})),e&&"busy"!==t&&g((()=>{this.targets.forEach((t=>t.removeAttribute("data-request-success")))}),parseInt(e))}#A(t){if(this.value)return this.#g("busy"),fetch(this.value,{method:t,headers:{"Content-Type":"application/json","X-CSRF-Token":j.get()},body:JSON.stringify(this.#L)}).then((t=>{if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);return this.#g("success"),t})).catch((e=>{throw console.error(`${t} request failed:`,e),this.#g("error"),e}));console.warn("No URL provided in the action value")}get#L(){const t={},e=this.targets[0];if(e instanceof HTMLFormElement){const s=new FormData(e);for(const[e,r]of s.entries())t[e]=r;return t}if(this.#T){const e=this.currentElement.name,s=this.currentElement.value;t[e]=s}return t}get#T(){return this.currentElement instanceof HTMLInputElement||this.currentElement instanceof HTMLSelectElement||this.currentElement instanceof HTMLTextAreaElement&&this.currentElement.name}}const R=t=>(e,s={})=>new $(e,s)[t]();var P={get:R("get"),post:R("post"),patch:R("patch"),put:R("put")};class U extends n{constructor(t,e={}){super(t,e);const s=e.value;this.behavior=["auto","instant","smooth"].includes(s)?s:"auto"}scroll(){this.targets[0]?.scrollIntoView({behavior:this.behavior})}}var z={scrollTo:(t=>(e,s={})=>new U(e,s)[t]())("scroll")};const B={attribute:c,class:d,clipboard:p,confirm:E,dataAttribute:A,dialog:x,element:k,form:D,intersection:O,reload:V,request:P,scrollTo:z},G=(t=[])=>0===t.length?Object.values(B).reduce(((t,e)=>({...t,...e})),{}):B.reduce(((e,s)=>t[s]?{...e,...t[s]}:(console.warn(`Action “${s}” not found`),e)),{});var _=G([]);var J=new class{#x={};#M=new WeakMap;#S=new Map;#k;#N=new Map;#F;static get debug(){return t.enabled}static set debug(e){t.enabled=e}constructor(){this.#k=new e(_),this.#F=new r((t=>this.#n(t)),(t=>this.#a(t)))}activate(e={}){const{on:s=document,debug:r=!1}=e;return t.enabled=r,t.log("Initializing…"),this.element=s,this.#F.start("[data-action]"),this.element.querySelectorAll("[data-action]").forEach((t=>this.#n(t))),t.log("…initialized"),this}withActions(s=[]){return t.log("Initializing with actions",s),this.#k=new e(G(s)),this}#n(t){const e=t.dataset.action;if(!e)return;const r=e.split(" ");new Set(e.includes("->")?s.identify({by:e}):[s.getDefault({from:t})]).forEach((t=>this.#D({for:t}))),r.filter((t=>t.includes("@"))).forEach((e=>{const[s]=e.split("->"),[r,i]=s.split("@"),n="window"===r?window:document;this.#q(i,n,t)}));const n=r.filter((t=>t.includes(":"))).map((t=>t.split(":")[1]));n.forEach((e=>{i.setup({for:e,on:t,trigger:()=>this.#k.process({type:e},{on:t,using:e})})}))}#a(t){const e=this.#M.get(t);e&&(e.forEach((e=>{const s=this.#N.get(e);if(s&&(s.delete(t),0===s.size)){const[t,s]=e.split(":"),r=this.#S.get(e);("window"===t?window:document).removeEventListener(s,r),this.#S.delete(e),this.#N.delete(e)}})),this.#M.delete(t))}#D({for:e}){this.#x[e]||(this.element.addEventListener(e,(t=>this.#C(t))),t.log("Added event listener for",e,"to",this.element),this.#x[e]=!0)}#q(t,e,s){const r=`${e===window?"window":"document"}:${t}`;if(this.#M.has(s)||this.#M.set(s,new Set),this.#M.get(s).add(r),this.#N.has(r)||this.#N.set(r,new Set),this.#N.get(r).add(s),!this.#S.has(r)){const s=e=>{const s=this.#N.get(r);s&&s.forEach((s=>{this.#k.process(e,{on:s,using:t})}))};e.addEventListener(t,s),this.#S.set(r,s)}}#C(t){const e=t.target.closest("[data-action]");if(!e)return;const r=s.getDefault({from:e});this.#k.process(t,{on:e,using:r})}};const W=(t=document)=>{window.Attractive&&window.Attractive._initialized||J.activate({element:t})};return"undefined"!=typeof document&&("loading"===document.readyState?document.addEventListener("DOMContentLoaded",(()=>W())):W(),document.addEventListener("turbo:load",(()=>W()))),"undefined"!=typeof window&&(window.Attractive=J),J}));
@@ -101,6 +101,7 @@
101
101
  @layer components {
102
102
  .events {
103
103
  list-style: none;
104
+ margin-block-start: 1rem;
104
105
 
105
106
  li {
106
107
  --icon-width: 1.125rem;
@@ -163,6 +164,64 @@
163
164
  &[data-status="failed"]::before { color: oklch(60% .2 25); }
164
165
  }
165
166
 
167
+ .filters {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 2rem;
171
+
172
+ fieldset {
173
+ border: 0;
174
+
175
+ legend {
176
+ font-size: .875rem;
177
+ font-weight: 400;
178
+ color: var(--color-text-muted);
179
+ }
180
+
181
+ input[type="radio"] {
182
+ position: absolute;
183
+ clip: rect(0, 0, 0, 0);
184
+ }
185
+
186
+ .pills {
187
+ display: inline-flex;
188
+ background: var(--color-bg);
189
+ border: 1px solid var(--color-border);
190
+ border-radius: .3rem;
191
+ overflow: clip;
192
+
193
+ .pill {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ padding: .25rem .75rem;
197
+ font-size: .875rem;
198
+ font-weight: 400;
199
+ border-inline-start: 1px solid var(--color-border);
200
+ cursor: pointer;
201
+ transition: all ease-in-out 100ms;
202
+
203
+ &:first-child { border-inline-start: 0; }
204
+
205
+ &:has(input:checked),
206
+ &:hover:not(:has(input:checked)) {
207
+ background-color: var(--color-bg-hover);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ select {
214
+ padding: .4rem .75rem;
215
+ font-size: .875rem;
216
+ background: var(--color-bg);
217
+ border: 1px solid var(--color-border);
218
+ border-radius: .25rem;
219
+ cursor: pointer;
220
+ }
221
+
222
+ [type=submit] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
223
+ }
224
+
166
225
  button,
167
226
  .copy-button {
168
227
  padding: .125rem;
@@ -5,7 +5,7 @@ module Fuik
5
5
  layout "fuik/application"
6
6
 
7
7
  def index
8
- @webhook_events = WebhookEvent.order(created_at: :desc)
8
+ @webhook_events = WebhookEvent.filtered(params).order(created_at: :desc)
9
9
  end
10
10
 
11
11
  def show
@@ -13,11 +13,11 @@ module Fuik
13
13
  provider: params[:provider],
14
14
  event_id: event_id,
15
15
  event_type: event_type,
16
- body: request.raw_post,
16
+ body: json_body,
17
17
  headers: headers
18
18
  )
19
19
 
20
- process!(webhook_event)
20
+ process_later!(webhook_event)
21
21
 
22
22
  head :ok
23
23
  rescue Fuik::InvalidSignature
@@ -30,6 +30,12 @@ module Fuik
30
30
 
31
31
  private
32
32
 
33
+ def json_body
34
+ return request.raw_post unless url_encoded_body?
35
+
36
+ Rack::Utils.parse_nested_query(request.raw_post).to_json
37
+ end
38
+
33
39
  def verify_signature!
34
40
  return unless should_verify?
35
41
 
@@ -47,7 +53,7 @@ module Fuik
47
53
  @payload ||= begin
48
54
  return {} if request.raw_post.blank?
49
55
 
50
- JSON.parse(request.raw_post)
56
+ JSON.parse(json_body)
51
57
  rescue JSON::ParserError
52
58
  {}
53
59
  end
@@ -60,6 +66,17 @@ module Fuik
60
66
  event_class.new(webhook_event).process!
61
67
  end
62
68
 
69
+ def process_later!(webhook_event)
70
+ event_class = event_class_for(webhook_event.provider, webhook_event.event_type)
71
+ return unless event_class
72
+
73
+ WebhookProcessingJob.perform_later(event_class.name, webhook_event)
74
+ end
75
+
76
+ def url_encoded_body?
77
+ request.media_type == "application/x-www-form-urlencoded"
78
+ end
79
+
63
80
  def event_class_for(provider, event_type)
64
81
  "#{provider.camelize}::#{event_type.tr("./:-[]", "_").camelize}".safe_constantize
65
82
  end
@@ -31,7 +31,7 @@ module Fuik
31
31
 
32
32
  object.each_with_index.map do |(key, value), index|
33
33
  key_path = current_path + [key]
34
- path_string = key_path.map { "[\"#{it}\"]" }.join
34
+ path_string = key_path.map { it.is_a?(String) ? "[\"#{it}\"]" : "[#{it}]" }.join
35
35
 
36
36
  comma = (index == object.size - 1) ? "" : '<span class="json-punctuation">,</span>'
37
37
 
@@ -0,0 +1,7 @@
1
+ module Fuik
2
+ class WebhookProcessingJob < ApplicationJob
3
+ def perform(event_class_name, webhook_event)
4
+ Object.const_get(event_class_name).new(webhook_event).process!
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ class WebhookEvent
5
+ module Filterable
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def filtered(params)
10
+ events = all
11
+ events = events.where(status: params[:status]) if params[:status].present?
12
+ events = events.where(provider: params[:provider]) if params[:provider].present?
13
+ events
14
+ end
15
+
16
+ def options_for_select
17
+ by_provider_name.pluck(:provider).map { [it.humanize, it] }
18
+ end
19
+ end
20
+
21
+ included do
22
+ scope :by_provider_name, -> { distinct.order(:provider) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -4,6 +4,8 @@ module Fuik
4
4
  class WebhookEvent < ApplicationRecord
5
5
  self.table_name = "fuik_webhook_events"
6
6
 
7
+ include Filterable
8
+
7
9
  enum :status, %w[pending processed failed].index_by(&:itself), default: "pending"
8
10
 
9
11
  validates :provider, presence: true
@@ -0,0 +1,29 @@
1
+ <%= form_tag root_path, method: :get, id: :filters do %>
2
+ <div class="filters">
3
+ <fieldset>
4
+ <legend>Provider</legend>
5
+
6
+ <%= select_tag :provider, options_for_select(Fuik::WebhookEvent.options_for_select, params[:provider]), prompt: "All", data: {action: "form#submit", target: "filters"} %>
7
+ </fieldset>
8
+
9
+ <fieldset>
10
+ <legend>Status</legend>
11
+
12
+ <span class="pills">
13
+ <%= label_tag nil, class: "pill" do %>
14
+ <%= radio_button_tag :status, "", params[:status].blank? || params[:status].empty?, data: {action: "form#submit", target: "filters"} %>
15
+ All
16
+ <% end %>
17
+
18
+ <% Fuik::WebhookEvent.statuses.keys.each do |status| %>
19
+ <%= label_tag nil, class: "pill" do %>
20
+ <%= radio_button_tag :status, status, params[:status] == status, data: {action: "form#submit", target: "filters"} %>
21
+ <%= status.capitalize %>
22
+ <% end %>
23
+ <% end %>
24
+ </span>
25
+ </fieldset>
26
+
27
+ <%= submit_tag "Filter" %>
28
+ </div>
29
+ <% end %>
@@ -1,5 +1,7 @@
1
1
  <h1>Webhooks</h1>
2
2
 
3
+ <%= render "filters" %>
4
+
3
5
  <ul class="events">
4
6
  <% @webhook_events.each do |event| %>
5
7
  <li>
@@ -8,7 +8,7 @@
8
8
  <%= yield :head %>
9
9
 
10
10
  <%= stylesheet_link_tag "fuik/application", media: "all" %>
11
- <script defer src="https://cdn.jsdelivr.net/npm/attractivejs@0.12.0"></script>
11
+ <script src="<%= asset_path("attractivejs-0.12.0.min.js") %>"></script>
12
12
  </head>
13
13
  <body>
14
14
  <main>
data/config/routes.rb CHANGED
@@ -5,5 +5,5 @@ Fuik::Engine.routes.draw do
5
5
  resources :events, only: %w[show]
6
6
  resources :downloads, only: %w[create]
7
7
 
8
- post ":provider", to: "webhooks#create"
8
+ post ":provider", to: "webhooks#create", constraints: Fuik::Routing::ProviderConstraint.new
9
9
  end
data/lib/fuik/engine.rb CHANGED
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fuik/routing/provider_constraint"
4
+
3
5
  module Fuik
4
6
  class Engine < ::Rails::Engine
5
7
  isolate_namespace Fuik
6
8
 
7
9
  config.webhooks_controller_parent = "ActionController::Base"
8
10
  config.events_controller_parent = "ActionController::Base"
11
+ config.providers_allowed = Rails.env.local?
9
12
 
10
13
  config.to_prepare do
11
14
  ActiveSupport.on_load(:action_view) do
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ module Routing
5
+ class ProviderConstraint
6
+ def matches?(request)
7
+ return true if allow_all?
8
+ return explicit_allowlist.include?(request.params[:provider]) if explicit_allowlist?
9
+
10
+ return Rails.env.local? if providers_allowed.nil?
11
+ return Rails.env.local? if providers_allowed == true
12
+
13
+ scanned_allowlist.include?(request.params[:provider])
14
+ end
15
+
16
+ private
17
+
18
+ def allow_all? = Fuik::Engine.config.providers_allowed.in?([:all, "all"])
19
+
20
+ def explicit_allowlist? = Fuik::Engine.config.providers_allowed.is_a?(Array)
21
+
22
+ def explicit_allowlist = Fuik::Engine.config.providers_allowed.to_set
23
+
24
+ def providers_allowed = Fuik::Engine.config.providers_allowed
25
+
26
+ def scanned_allowlist
27
+ @scanned_allowlist ||= Dir["#{Rails.root}/app/webhooks/*"]
28
+ .select { File.directory?(it) }
29
+ .map { File.basename(it) }
30
+ .to_set
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/fuik/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Fuik
2
- VERSION = "0.10.1"
2
+ VERSION = "0.12.0"
3
3
  end
@@ -1,8 +1,8 @@
1
1
  module <%= provider_module_name %>
2
2
  class InstallationCreated < Base
3
3
  def process!
4
- installation_id = payload.dig("installation", "id")
5
- account = payload.dig("installation", "account", "login")
4
+ # payload["installation"]["id"] # GitHub app installation ID
5
+ # payload["installation"]["account"] # user/org that installed
6
6
 
7
7
  # TODO: Add business logic
8
8
 
@@ -1,10 +1,8 @@
1
1
  module <%= provider_module_name %>
2
2
  class Push < Base
3
3
  def process!
4
- # This fires when a pull request is merged (default branch push)
5
-
6
- repository = payload.dig("repository", "full_name")
7
- ref = payload["ref"]
4
+ # payload["ref"] # git ref (e.g., "refs/heads/main")
5
+ # payload["repository"]["full_name"] # "owner/repo"
8
6
 
9
7
  # TODO: Add business logic
10
8
 
@@ -1,8 +1,8 @@
1
1
  module <%= provider_module_name %>
2
2
  class StarCreated < Base
3
3
  def process!
4
- repository = payload.dig("repository", "full_name")
5
- stargazer = payload.dig("sender", "login")
4
+ # payload["repository"]["full_name"] # "owner/repo"
5
+ # payload["sender"]["login"] # user who starred
6
6
 
7
7
  # TODO: Add business logic
8
8
 
@@ -0,0 +1,32 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:lettermint, :signing_secret)
5
+ signature_header = request.headers["X-Lettermint-Signature"] || ""
6
+
7
+ elements = {}
8
+ signature_header.split(",").each do |element|
9
+ key, value = element.split("=", 2)
10
+
11
+ elements[key] = value if key && value
12
+ end
13
+
14
+ timestamp = elements["t"]
15
+ signature = elements["v1"]
16
+
17
+ raise Fuik::InvalidSignature if timestamp.blank? || signature.blank?
18
+
19
+ now = Time.current.to_i
20
+ raise Fuik::InvalidSignature if (now - timestamp.to_i).abs > TOLERANCE
21
+
22
+ signed_payload = "#{timestamp}.#{request.raw_post}"
23
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
24
+
25
+ raise Fuik::InvalidSignature unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
26
+ end
27
+
28
+ private
29
+
30
+ TOLERANCE = 5.minutes
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class MessageFailed < Base
3
+ def process!
4
+ # payload.data.recipient # email address that failed
5
+ # payload.data.reason # failure reason (e.g., "A network error occurred.")
6
+ # payload.data.response.status_code # SMTP status code
7
+ # payload.data.subject # email subject
8
+ # payload.data.message_id # message UUID
9
+ # payload.data.tag # message tag (or nil)
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class MessageHardBounced < Base
3
+ def process!
4
+ # payload.data.recipient # email address that hard bounced
5
+ # payload.data.response.enhanced_status_code # SMTP enhanced status code (e.g., "5.1.1")
6
+ # payload.data.response.content # SMTP response text (e.g., "User unknown")
7
+ # payload.data.subject # email subject
8
+ # payload.data.message_id # message UUID
9
+ # payload.data.tag # message tag (or nil)
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class MessageSoftBounced < Base
3
+ def process!
4
+ # payload.data.recipient # email address that soft bounced
5
+ # payload.data.response.enhanced_status_code # SMTP enhanced status code (e.g., "4.2.2")
6
+ # payload.data.response.content # SMTP response text (e.g., "Mailbox full")
7
+ # payload.data.subject # email subject
8
+ # payload.data.message_id # message UUID
9
+ # payload.data.tag # message tag (or nil)
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ module <%= provider_module_name %>
2
+ class MessageSuppressed < Base
3
+ def process!
4
+ # payload.data.recipient # email address that was suppressed
5
+ # payload.data.reason # suppression reason (e.g., "hard_bounce", "spam_complaint")
6
+ # payload.data.subject # email subject
7
+ # payload.data.message_id # message UUID
8
+ # payload.data.tag # message tag (or nil)
9
+
10
+ # TODO: Add business logic
11
+
12
+ @webhook_event.processed!
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:loops, :signing_secret)
5
+ secret_bytes = Base64.strict_decode64(secret.split("_")[1])
6
+
7
+ webhook_id = request.headers["webhook-id"]
8
+ timestamp = request.headers["webhook-timestamp"]
9
+ signature = request.headers["webhook-signature"]
10
+
11
+ signed_content = "#{webhook_id}.#{timestamp}.#{request.raw_post}"
12
+ expected_signature = OpenSSL::HMAC.digest("SHA256", secret_bytes, signed_content)
13
+
14
+ raise Fuik::InvalidSignature unless signature.split(" ").any? { |sig| sig.include?(",#{expected_signature}") }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ module <%= provider_module_name %>
2
+ class ContactUnsubscribed < Base
3
+ def process!
4
+ # payload["contactIdentity"]["email"] # user email
5
+ # payload["contactIdentity"]["id"] # Loops contact ID
6
+
7
+ # TODO: Add business logic
8
+
9
+ @webhook_event.processed!
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ module <%= provider_module_name %>
2
+ class EmailHardBounced < Base
3
+ def process!
4
+ # payload["contact"]["email"] # recipient email
5
+ # payload["email"]["id"] # Loops email ID
6
+ # payload["email"]["emailMessageId"] # Postmark message ID
7
+ # payload["email"]["subject"] # email subject
8
+
9
+ # TODO: Add business logic
10
+
11
+ @webhook_event.processed!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:mailgun, :signing_key)
5
+ signature_base64 = request.headers["Signature"]
6
+ timestamp = request.headers["Timestamp"]
7
+ token = request.headers["Token"]
8
+
9
+ signature = Base64.strict_decode64(signature_base64)
10
+ data = "#{timestamp}#{token}"
11
+
12
+ raise Fuik::InvalidSignature unless ActiveSupport::SecurityUtils.secure_compare(
13
+ OpenSSL::HMAC.digest("SHA256", secret, data),
14
+ signature
15
+ )
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ module <%= provider_module_name %>
2
+ class Bounced < Base
3
+ def process!
4
+ # payload["recipient"] # email address that bounced
5
+ # payload["code"] # bounce code (e.g., "MAILBOX_FULL")
6
+ # payload["error"] # SMTP error response
7
+ # payload["reason"] # human-readable reason
8
+
9
+ # TODO: Add business logic
10
+
11
+ @webhook_event.processed!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module <%= provider_module_name %>
2
+ class Complained < Base
3
+ def process!
4
+ # payload["recipient"] # email address that marked as spam
5
+ # payload["timestamp"] # when the complaint occurred
6
+
7
+ # TODO: Add business logic
8
+
9
+ @webhook_event.processed!
10
+ end
11
+ end
12
+ end
@@ -1,7 +1,8 @@
1
1
  module <%= provider_module_name %>
2
2
  class EmailBounced < Base
3
3
  def process!
4
- bounce_type = payload["bounce_type"]
4
+ # payload["to"] # recipient email
5
+ # payload["bounce_type"] # e.g., "hard", "soft"
5
6
 
6
7
  # TODO: Add business logic
7
8
 
@@ -1,6 +1,9 @@
1
1
  module <%= provider_module_name %>
2
2
  class EmailSpam < Base
3
3
  def process!
4
+ # payload["to"] # recipient email
5
+ # payload["timestamp"] # when marked as spam
6
+
4
7
  # TODO: Add business logic
5
8
 
6
9
  @webhook_event.processed!
@@ -0,0 +1,4 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ end
4
+ end
@@ -0,0 +1,17 @@
1
+ module <%= provider_module_name %>
2
+ class Bounce < Base
3
+ def process!
4
+ # payload["Email"] # email address that bounced
5
+ # payload["Type"] # bounce type (e.g., "HardBounce", "SoftBounce")
6
+ # payload["BouncedAt"] # ISO 8601 timestamp
7
+ # payload["Inactive"] # boolean - address is deactivated
8
+ # payload["CanActivate"] # boolean - can be reactivated
9
+ # payload["MessageID"] # Postmark message ID
10
+ # payload["Description"] # human-readable description
11
+
12
+ # TODO: Add business logic
13
+
14
+ @webhook_event.processed!
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:resend, :signing_secret)
5
+ signature = request.headers["Resend-Signature"]
6
+ expected_signature = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
7
+
8
+ raise Fuik::InvalidSignature unless Rack::Utils.secure_compare(signature, expected_signature)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ module <%= provider_module_name %>
2
+ class EmailBounced < Base
3
+ def process!
4
+ # payload["data"]["emails"][0]["to"] # recipient email
5
+ # payload["data"]["bounce"]["classification"] # bounce type
6
+ # payload["data"]["bounce"]["subtype"] # bounce subtype
7
+ # payload["ts"] # timestamp
8
+
9
+ # TODO: Add business logic
10
+
11
+ @webhook_event.processed!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module <%= provider_module_name %>
2
+ class EmailComplained < Base
3
+ def process!
4
+ # payload["data"]["email"]["to"] # recipient email
5
+ # payload["ts"] # timestamp
6
+
7
+ # TODO: Add business logic
8
+
9
+ @webhook_event.processed!
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class Bounce < Base
3
+ def process!
4
+ # payload.rcpt # email address that bounced
5
+ # payload.bounce # "hard" or "soft"
6
+ # payload.host # recipient server
7
+ # payload.message # SMTP error message
8
+ # payload.subject # email subject
9
+ # payload.email_id # email identifier
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ module <%= provider_module_name %>
2
+ class Reject < Base
3
+ def process!
4
+ # payload.rcpt # email address that was rejected
5
+ # payload.message # rejection reason
6
+ # payload.subject # email subject
7
+ # payload.email_id # email identifier
8
+
9
+ # TODO: Add business logic
10
+
11
+ @webhook_event.processed!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ module <%= provider_module_name %>
2
+ class Spam < Base
3
+ def process!
4
+ # payload.rcpt # email address that complained
5
+ # payload.subject # email subject
6
+ # payload.email_id # email identifier
7
+
8
+ # TODO: Add business logic
9
+
10
+ @webhook_event.processed!
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module <%= provider_module_name %>
2
+ class Unsubscribe < Base
3
+ def process!
4
+ # payload.rcpt # email address that unsubscribed
5
+ # payload.subject # email subject
6
+ # payload.email_id # email identifier
7
+
8
+ # TODO: Add business logic
9
+
10
+ @webhook_event.processed!
11
+ end
12
+ end
13
+ end
@@ -1,14 +1,14 @@
1
1
  module Stripe
2
2
  class CheckoutSessionCompleted < Base
3
3
  def process!
4
+ # payload["data"]["object"]["id"] # session ID
5
+ # payload["data"]["object"]["customer"] # customer ID
6
+ # payload["data"]["object"]["client_reference_id"] # your internal reference
7
+ # payload["data"]["object"]["amount_total"] # amount in cents
8
+
4
9
  # TODO: Add business logic
5
- # session = Stripe::Checkout::Session.retrieve(session_id) # this assumes the Stripe gem is available
6
10
 
7
11
  @webhook_event.processed!
8
12
  end
9
-
10
- private
11
-
12
- def session_id = payload.dig("data", "object", "id")
13
13
  end
14
14
  end
@@ -1,7 +1,8 @@
1
1
  module Stripe
2
2
  class CustomerSubscriptionDeleted < Base
3
3
  def process!
4
- subscription_id = payload.dig("data", "object", "id")
4
+ # payload["data"]["object"]["id"] # subscription ID
5
+ # payload["data"]["object"]["customer"] # customer ID
5
6
 
6
7
  # TODO: Add business logic
7
8
 
@@ -1,13 +1,14 @@
1
1
  module Stripe
2
2
  class CustomerSubscriptionUpdated < Base
3
3
  def process!
4
+ # payload["data"]["object"]["id"] # subscription ID
5
+ # payload["data"]["object"]["customer"] # customer ID
6
+ # payload["data"]["object"]["status"] # e.g., "active", "past_due"
7
+ # payload["data"]["object"]["price"] # price object
8
+
4
9
  # TODO: Add business logic
5
10
 
6
11
  @webhook_event.processed!
7
12
  end
8
-
9
- private
10
-
11
- def subscription_id = payload.dig("data", "object", "id")
12
13
  end
13
14
  end
@@ -1,13 +1,14 @@
1
1
  module Stripe
2
2
  class PaymentIntentSucceeded < Base
3
3
  def process!
4
+ # payload["data"]["object"]["id"] # payment intent ID
5
+ # payload["data"]["object"]["amount"] # amount in cents
6
+ # payload["data"]["object"]["customer"] # customer ID
7
+ # payload["data"]["object"]["currency"] # e.g., "usd"
8
+
4
9
  # TODO: Add business logic
5
10
 
6
11
  @webhook_event.processed!
7
12
  end
8
-
9
- private
10
-
11
- def payment_intent_id = payload["data"]["object"]["id"]
12
13
  end
13
14
  end
@@ -0,0 +1,18 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:userlist, :webhook_secret)
5
+ header = request.headers["Userlist-Signature"]
6
+ body = request.raw_post
7
+
8
+ parts = header.split(",")
9
+ timestamp = parts[0].split("=")[1]
10
+ signature = parts[1].split("=")[1]
11
+
12
+ signed_payload = "#{timestamp}.#{body}"
13
+ expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
14
+
15
+ raise Fuik::InvalidSignature unless Rack::Utils.secure_compare(signature, expected_signature)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ module <%= provider_module_name %>
2
+ class UserUnsubscribed < Base
3
+ def process!
4
+ # payload["user"]["id"] # Userlist user ID
5
+ # payload["user"]["identifier"] # your internal user ID
6
+ # payload["user"]["email"] # user email
7
+ # payload["user"]["properties"] # custom user properties
8
+
9
+ # TODO: Add business logic
10
+
11
+ @webhook_event.processed!
12
+ end
13
+ end
14
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fuik
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer
@@ -35,6 +35,7 @@ files:
35
35
  - MIT-LICENSE
36
36
  - README.md
37
37
  - Rakefile
38
+ - app/assets/images/fuik/icons/46elks.jpg
38
39
  - app/assets/images/fuik/icons/adyen.jpg
39
40
  - app/assets/images/fuik/icons/anthropic.jpg
40
41
  - app/assets/images/fuik/icons/apple.jpg
@@ -47,6 +48,7 @@ files:
47
48
  - app/assets/images/fuik/icons/gitlab.jpg
48
49
  - app/assets/images/fuik/icons/google.jpg
49
50
  - app/assets/images/fuik/icons/gumroad.jpg
51
+ - app/assets/images/fuik/icons/lettermint.jpg
50
52
  - app/assets/images/fuik/icons/linkedin.jpg
51
53
  - app/assets/images/fuik/icons/loops.jpg
52
54
  - app/assets/images/fuik/icons/mailgun.jpg
@@ -58,12 +60,14 @@ files:
58
60
  - app/assets/images/fuik/icons/resend.jpg
59
61
  - app/assets/images/fuik/icons/shopify.jpg
60
62
  - app/assets/images/fuik/icons/slack.jpg
63
+ - app/assets/images/fuik/icons/smtp2go.jpg
61
64
  - app/assets/images/fuik/icons/stripe.jpg
62
65
  - app/assets/images/fuik/icons/telegram.jpg
63
66
  - app/assets/images/fuik/icons/twitter.jpg
64
67
  - app/assets/images/fuik/icons/userlist.jpg
65
68
  - app/assets/images/fuik/icons/webhook.svg
66
69
  - app/assets/images/fuik/icons/zoom.jpg
70
+ - app/assets/javascript/attractivejs-0.12.0.min.js
67
71
  - app/assets/stylesheets/fuik/application.css
68
72
  - app/controllers/concerns/fuik/event_type.rb
69
73
  - app/controllers/fuik/application_controller.rb
@@ -73,10 +77,13 @@ files:
73
77
  - app/helpers/fuik/highlight_helper.rb
74
78
  - app/helpers/fuik/icon_helper.rb
75
79
  - app/jobs/fuik/application_job.rb
80
+ - app/jobs/fuik/webhook_processing_job.rb
76
81
  - app/models/fuik/application_record.rb
77
82
  - app/models/fuik/event.rb
78
83
  - app/models/fuik/webhook_event.rb
84
+ - app/models/fuik/webhook_event/filterable.rb
79
85
  - app/views/fuik/events/_copy_button.html.erb
86
+ - app/views/fuik/events/_filters.html.erb
80
87
  - app/views/fuik/events/index.html.erb
81
88
  - app/views/fuik/events/show.html.erb
82
89
  - app/views/layouts/fuik/application.html.erb
@@ -85,6 +92,7 @@ files:
85
92
  - lib/fuik.rb
86
93
  - lib/fuik/dot_access.rb
87
94
  - lib/fuik/engine.rb
95
+ - lib/fuik/routing/provider_constraint.rb
88
96
  - lib/fuik/version.rb
89
97
  - lib/generators/fuik/install/install_generator.rb
90
98
  - lib/generators/fuik/provider/provider_generator.rb
@@ -95,14 +103,37 @@ files:
95
103
  - lib/generators/fuik/provider/templates/github/installation_created.rb.tt
96
104
  - lib/generators/fuik/provider/templates/github/push.rb.tt
97
105
  - lib/generators/fuik/provider/templates/github/star_created.rb.tt
106
+ - lib/generators/fuik/provider/templates/lettermint/base.rb.tt
107
+ - lib/generators/fuik/provider/templates/lettermint/message_failed.rb.tt
108
+ - lib/generators/fuik/provider/templates/lettermint/message_hard_bounced.rb.tt
109
+ - lib/generators/fuik/provider/templates/lettermint/message_soft_bounced.rb.tt
110
+ - lib/generators/fuik/provider/templates/lettermint/message_suppressed.rb.tt
111
+ - lib/generators/fuik/provider/templates/loops/base.rb.tt
112
+ - lib/generators/fuik/provider/templates/loops/contact_unsubscribed.rb.tt
113
+ - lib/generators/fuik/provider/templates/loops/email_hard_bounced.rb.tt
114
+ - lib/generators/fuik/provider/templates/mailgun/base.rb.tt
115
+ - lib/generators/fuik/provider/templates/mailgun/bounced.rb.tt
116
+ - lib/generators/fuik/provider/templates/mailgun/complained.rb.tt
98
117
  - lib/generators/fuik/provider/templates/mailpace/base.rb.tt
99
118
  - lib/generators/fuik/provider/templates/mailpace/email_bounced.rb.tt
100
119
  - lib/generators/fuik/provider/templates/mailpace/email_spam.rb.tt
120
+ - lib/generators/fuik/provider/templates/postmark/base.rb.tt
121
+ - lib/generators/fuik/provider/templates/postmark/bounce.rb.tt
122
+ - lib/generators/fuik/provider/templates/resend/base.rb.tt
123
+ - lib/generators/fuik/provider/templates/resend/email_bounced.rb.tt
124
+ - lib/generators/fuik/provider/templates/resend/email_complained.rb.tt
125
+ - lib/generators/fuik/provider/templates/smtp2go/base.rb.tt
126
+ - lib/generators/fuik/provider/templates/smtp2go/bounce.rb.tt
127
+ - lib/generators/fuik/provider/templates/smtp2go/reject.rb.tt
128
+ - lib/generators/fuik/provider/templates/smtp2go/spam.rb.tt
129
+ - lib/generators/fuik/provider/templates/smtp2go/unsubscribe.rb.tt
101
130
  - lib/generators/fuik/provider/templates/stripe/base.rb.tt
102
131
  - lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt
103
132
  - lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt
104
133
  - lib/generators/fuik/provider/templates/stripe/customer_subscription_updated.rb.tt
105
134
  - lib/generators/fuik/provider/templates/stripe/payment_intent_succeeded.rb.tt
135
+ - lib/generators/fuik/provider/templates/userlist/base.rb.tt
136
+ - lib/generators/fuik/provider/templates/userlist/user_unsubscribed.rb.tt
106
137
  homepage: https://railsdesigner.com/fuik/
107
138
  licenses:
108
139
  - MIT
@@ -123,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
123
154
  - !ruby/object:Gem::Version
124
155
  version: '0'
125
156
  requirements: []
126
- rubygems_version: 4.0.8
157
+ rubygems_version: 4.0.14
127
158
  specification_version: 4
128
159
  summary: A fish trap for webhooks
129
160
  test_files: []