11import hmac
22import hashlib
33import json
4- from typing import Union
4+ from typing import Union , Optional
5+
6+ from remnawave .models .webhook import (
7+ WebhookPayloadDto ,
8+ UserDto ,
9+ NodesDto ,
10+ HwidUserDeviceDto ,
11+ LoginAttemptDto ,
12+ UserHwidDeviceEventDto ,
13+ )
14+
15+ class WebhookHeadersDto :
16+ """Helper class for webhook headers"""
17+
18+ def __init__ (self , signature : str , timestamp : str ):
19+ self .signature = signature
20+ self .timestamp = timestamp
21+
22+ @classmethod
23+ def from_headers (cls , headers : dict [str , str ]) -> "WebhookHeadersDto" :
24+ """
25+ Create WebhookHeadersDto from headers dictionary.
26+ Handles case-insensitive header names.
27+ """
28+ signature = None
29+ timestamp = None
30+
31+ for key , value in headers .items ():
32+ lower_key = key .lower ()
33+ if lower_key == "x-remnawave-signature" :
34+ signature = value
35+ elif lower_key == "x-remnawave-timestamp" :
36+ timestamp = value
37+
38+ if not signature or not timestamp :
39+ raise ValueError ("Missing required webhook headers" )
40+
41+ return cls (signature = signature , timestamp = timestamp )
42+
543
644class WebhookUtility :
745 @staticmethod
@@ -29,4 +67,114 @@ def validate_webhook(
2967 hashlib .sha256
3068 ).hexdigest ()
3169
32- return hmac .compare_digest (computed_signature , signature )
70+ return hmac .compare_digest (computed_signature , signature )
71+
72+ @staticmethod
73+ def validate_webhook_with_headers (
74+ body : Union [str , dict ],
75+ headers : Union [dict [str , str ], WebhookHeadersDto ],
76+ webhook_secret : str
77+ ) -> bool :
78+ """
79+ Validates the webhook using headers object.
80+
81+ :param body: The webhook request body.
82+ :param headers: Dictionary with headers or WebhookHeadersDto object.
83+ :param webhook_secret: The secret key used to compute the HMAC.
84+ :return: True if the signature matches, otherwise False.
85+ """
86+ if isinstance (headers , dict ):
87+ headers = WebhookHeadersDto .from_headers (headers )
88+
89+ return WebhookUtility .validate_webhook (body , headers .signature , webhook_secret )
90+
91+ @staticmethod
92+ def parse_webhook (
93+ body : Union [str , dict ],
94+ headers : Union [dict [str , str ], WebhookHeadersDto ],
95+ webhook_secret : str ,
96+ validate : bool = True
97+ ) -> Optional [WebhookPayloadDto ]:
98+ """
99+ Parses and optionally validates the webhook payload.
100+
101+ :param body: The webhook request body.
102+ :param headers: Dictionary with headers or WebhookHeadersDto object.
103+ :param webhook_secret: The secret key used to compute the HMAC.
104+ :param validate: Whether to validate the webhook signature (default: True).
105+ :return: Parsed WebhookPayloadDto or None if validation fails.
106+ """
107+ if validate and not WebhookUtility .validate_webhook_with_headers (body , headers , webhook_secret ):
108+ return None
109+
110+ if isinstance (body , str ):
111+ body = json .loads (body )
112+
113+ return WebhookPayloadDto .from_dict (body )
114+
115+ @staticmethod
116+ def is_user_event (event : str ) -> bool :
117+ """Check if event is a user event."""
118+ return event .startswith ("user." )
119+
120+ @staticmethod
121+ def is_user_hwid_devices_event (event : str ) -> bool :
122+ """Check if event is a user HWID devices event."""
123+ return event .startswith ("user_hwid_devices." )
124+
125+ @staticmethod
126+ def is_node_event (event : str ) -> bool :
127+ """Check if event is a node event."""
128+ return event .startswith ("node." )
129+
130+ @staticmethod
131+ def is_infra_billing_event (event : str ) -> bool :
132+ """Check if event is an infra billing event."""
133+ return event .startswith ("crm.infra_billing" )
134+
135+ @staticmethod
136+ def is_crm_event (event : str ) -> bool :
137+ """Check if event is a CRM event."""
138+ return event .startswith ("crm." )
139+
140+ @staticmethod
141+ def is_service_event (event : str ) -> bool :
142+ """Check if event is a service event."""
143+ return event .startswith ("service." )
144+
145+ @staticmethod
146+ def is_errors_event (event : str ) -> bool :
147+ """Check if event is an errors event."""
148+ return event .startswith ("errors." )
149+
150+ @staticmethod
151+ def get_typed_data (payload : WebhookPayloadDto ) -> Union [UserDto , NodesDto , HwidUserDeviceDto , LoginAttemptDto , UserHwidDeviceEventDto , dict ]:
152+ """
153+ Get typed data from webhook payload based on event type.
154+
155+ :param payload: Parsed webhook payload.
156+ :return: Typed data object.
157+ """
158+ return payload .data
159+
160+ @staticmethod
161+ def extract_user_hwid_event_data (payload : WebhookPayloadDto ) -> Optional [tuple [UserDto , HwidUserDeviceDto ]]:
162+ """
163+ Extract user and HWID device from user_hwid_devices event.
164+
165+ :param payload: Parsed webhook payload.
166+ :return: Tuple of (UserDto, HwidUserDeviceDto) or None if not a HWID event.
167+ """
168+ if not WebhookUtility .is_user_hwid_devices_event (payload .event ):
169+ return None
170+
171+ if isinstance (payload .data , dict ):
172+ user_data = payload .data .get ("user" , {})
173+ hwid_data = payload .data .get ("hwidUserDevice" , {})
174+
175+ return (
176+ UserDto (** user_data ),
177+ HwidUserDeviceDto (** hwid_data )
178+ )
179+
180+ return None
0 commit comments