Add a SipUri validator with the Regex extracted from the RFC 3261

This commit is contained in:
Timothée Jaussoin 2026-03-09 11:20:53 +01:00
parent 57381f1454
commit 1907d3b966
3 changed files with 98 additions and 4 deletions

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin\Account;
use App\Account;
use App\CallForwarding;
use App\Http\Controllers\Controller;
use App\Rules\SipUri;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -20,19 +21,19 @@ class CallForwardingController extends Controller
$request->validate([
'always.forward_to' => $forwardTo,
'always.sip_uri' => array_key_exists('enabled', $request->get('always'))
? 'nullable|starts_with:sip:|required_if:always.forward_to,sip_uri'
? ['nullable', new SipUri, 'required_if:always.forward_to,sip_uri']
: 'nullable',
'always.contact_id' => ['required_if:always.forward_to,contact', Rule::in($contactsIds)],
'away.forward_to' => $forwardTo,
'away.sip_uri' => array_key_exists('enabled', $request->get('away'))
? 'nullable|starts_with:sip:|required_if:away.forward_to,sip_uri'
? ['nullable', new SipUri, 'required_if:away.forward_to,sip_uri']
: 'nullable',
'away.contact_id' => ['required_if:away.forward_to,contact', Rule::in($contactsIds)],
'busy.forward_to' => $forwardTo,
'busy.sip_uri' => array_key_exists('enabled', $request->get('busy'))
? 'nullable|starts_with:sip:|required_if:busy.forward_to,sip_uri'
? ['nullable', new SipUri, 'required_if:busy.forward_to,sip_uri']
: 'nullable',
'busy.contact_id' => ['required_if:busy.forward_to,contact', Rule::in($contactsIds)],
]);

View file

@ -6,6 +6,7 @@ use App\Account;
use App\CallForwarding;
use App\Http\Controllers\Controller;
use App\Rules\CallForwardingEnable;
use App\Rules\SipUri;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -26,7 +27,7 @@ class CallForwardingController extends Controller
Rule::unique('call_forwardings', 'type')->where(fn($query) => $query->where('account_id', $accountId))
],
'forward_to' => 'required|in:sip_uri,contact,voicemail',
'sip_uri' => 'nullable|starts_with:sip:|required_if:forward_to,sip_uri',
'sip_uri' => ['nullable', new SipUri, 'required_if:forward_to,sip_uri'],
'enabled' => ['required', 'boolean', new CallForwardingEnable($request, $account)],
'contact_id' => ['required_if:forward_to,contact', Rule::in(resolveUserContacts($account)->pluck('id')->toArray())],
]);

View file

@ -0,0 +1,92 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class SipUri implements ValidationRule
{
/**
* Run the validation rule.
* Validates a SIP/SIPS URI against RFC 3261 §19.1 ABNF grammar.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! preg_match($this->buildPattern(), $value)) {
$fail('The :attribute must be a valid SIP URI.');
}
}
/**
* Build the RFC 3261 §19.1 compliant regex pattern.
* Generated using Claude
*/
private function buildPattern(): string
{
// unreserved = alphanum / mark (mark = - _ . ! ~ * ' ( ))
$unreserved = '[A-Za-z0-9\-_.!~*\'()]';
// escaped = "%" HEXDIG HEXDIG
$escaped = '%[0-9A-Fa-f]{2}';
// user-unreserved = & = + $ , ; ? /
$userUnreserved = '[&=+$,;?\/]';
// user = 1*( unreserved / escaped / user-unreserved )
$user = "(?:{$unreserved}|{$escaped}|{$userUnreserved})+";
// password = *( unreserved / escaped / & = + $ , )
$password = "(?:{$unreserved}|{$escaped}|[&=+$,])*";
// userinfo = user [ ":" password ] "@"
$userinfo = "(?:{$user})(?::{$password})?@";
// domainlabel = alphanum / alphanum *( alphanum / "-" ) alphanum
$domainLabel = '[A-Za-z0-9](?:[A-Za-z0-9\-]*[A-Za-z0-9])?';
// toplabel = ALPHA / ALPHA *( alphanum / "-" ) alphanum
$topLabel = '[A-Za-z](?:[A-Za-z0-9\-]*[A-Za-z0-9])?';
// hostname = *( domainlabel "." ) toplabel [ "." ]
$hostname = "(?:{$domainLabel}\.)*{$topLabel}\.?";
// IPv4address = 1*3DIGIT "." x3
$ipv4 = '\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}';
// IPv6 building blocks (RFC 5954)
$h16 = '[0-9A-Fa-f]{1,4}';
$ls32 = "(?:{$h16}:{$h16}|{$ipv4})";
$ipv6 = "(?:"
. "(?:{$h16}:){6}{$ls32}"
. "|::(?:{$h16}:){5}{$ls32}"
. "|{$h16}?::(?:{$h16}:){4}{$ls32}"
. "|(?:{$h16}:)?{$h16}::(?:{$h16}:){3}{$ls32}"
. "|(?:{$h16}:){0,2}{$h16}::(?:{$h16}:){2}{$ls32}"
. "|(?:{$h16}:){0,3}{$h16}::{$h16}:{$ls32}"
. "|(?:{$h16}:){0,4}{$h16}::{$ls32}"
. "|(?:{$h16}:){0,5}{$h16}::{$h16}"
. "|(?:{$h16}:){0,6}{$h16}::"
. ")";
// IPv6reference = "[" IPv6address "]"
$ipv6ref = "\[{$ipv6}\]";
// host = IPv6reference / IPv4address / hostname (order matters)
$host = "(?:{$ipv6ref}|{$ipv4}|{$hostname})";
// hostport = host [ ":" port ]
$hostport = "{$host}(?::\d+)?";
// uri-parameters: *( ";" pname [ "=" pvalue ] )
$paramChar = "(?:{$unreserved}|{$escaped}|[\\[\\]\/:&+$])";
$uriParams = "(?:;{$paramChar}*(?:={$paramChar}*)?)*";
// headers: "?" hname "=" hvalue *( "&" hname "=" hvalue )
$hnvUnreserved = '[\\[\\]\/?:+$]';
$headerChar = "(?:{$unreserved}|{$escaped}|{$hnvUnreserved})";
$headers = "(?:\\?{$headerChar}+={$headerChar}*(?:&{$headerChar}+={$headerChar}*)*)?";
return '/^sips?:(?:' . $userinfo . ')?' . $hostport . $uriParams . $headers . '$/i';
}
}