diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 54bdaf6b..7ab36c0e 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**222 classes** across **33 capabilities** · **24 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**226 classes** across **33 capabilities** · **25 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -329,6 +329,10 @@ - **Tiger_Secret_Adapter_SecretsManager** `@api` — AWS Secrets Manager backend (the rotation-managed sibling of SSM). · `library/Tiger/Secret/Adapter/SecretsManager.php` - **Tiger_Secret_Adapter_Ssm** `@api` — AWS Systems Manager Parameter Store (SecureString) backend. · `library/Tiger/Secret/Adapter/Ssm.php` - **Tiger_Secret_Writer_Interface** `@api` — the OPTIONAL write side of a secret backend. · `library/Tiger/Secret/Writer/Interface.php` +- **Tiger_Sms** `@api` — provider-agnostic SMS send, the SMS sibling of Tiger_Mail. · `library/Tiger/Sms.php` +- **Tiger_Sms_Adapter_Abstract** `@api` — the base every SMS provider adapter extends. · `library/Tiger/Sms/Adapter/Abstract.php` +- **Tiger_Sms_Adapter_Sns** `@api` — the Amazon SNS SMS adapter (native to an AWS-hosted Tiger). · `library/Tiger/Sms/Adapter/Sns.php` +- **Tiger_Sms_Adapter_Twilio** `@api` — the Twilio SMS adapter (the industry-standard API). · `library/Tiger/Sms/Adapter/Twilio.php` ## Modules (`modules/*` — activatable features) @@ -354,6 +358,7 @@ - **Secrets** (`secret`, app) — Provider-neutral secrets / key-store resolution (Tiger_Secret) — local-encrypted by default, with SSM / Secrets Manager / env / file / Vault adapters selectable by config. · `modules/secret` - **SEO** (`seo`, app) · services: Head, Pages, Schema, Social · `modules/seo` - **Signup** (`signup`, plugin) · services: Signup · `modules/signup` +- **SMS** (`sms`, app) · services: Settings · `modules/sms` - **System** (`system`, plugin) · services: Acl, Dashboard, Logs, Modules, Nav, Settings, Sites, Updates · `modules/system` - **Translations** (`translations`, app) · services: Translation · `modules/translations` diff --git a/library/Tiger/Sms.php b/library/Tiger/Sms.php new file mode 100644 index 00000000..93654ea2 --- /dev/null +++ b/library/Tiger/Sms.php @@ -0,0 +1,163 @@ +false,'error'=>...]` — never a fatal and never an exception into + * the caller. Numbers are normalized to E.164 before sending. Register more adapters with + * Tiger_Sms::register(). The built-in Twilio + SNS adapters self-register on first use. + * + * It powers the platform's SMS needs the same way Tiger_Mail powers email: auth one-time codes / 2FA, + * operational alerts (e.g. the Fleet Observer's sev-1 pages), and consent-gated campaigns. + * + * @api + */ +class Tiger_Sms +{ + /** @var array provider name => adapter class (lowercased keys) */ + protected static $_registry = []; + + /** @var bool whether the built-in adapters have been registered this request */ + protected static $_seeded = false; + + /** + * Register (or replace) a provider name → adapter class. Case-insensitive. + * + * @param string $name the provider name (e.g. "twilio") + * @param string $class the adapter class (extends Tiger_Sms_Adapter_Abstract) + * @return void + */ + public static function register(string $name, string $class): void + { + self::$_registry[strtolower(trim($name))] = $class; + } + + /** Register the first-party adapters once (so the facade works with no module activation). */ + protected static function _seed(): void + { + if (self::$_seeded) { return; } + self::$_seeded = true; + if (!isset(self::$_registry['twilio'])) { self::$_registry['twilio'] = 'Tiger_Sms_Adapter_Twilio'; } + if (!isset(self::$_registry['sns'])) { self::$_registry['sns'] = 'Tiger_Sms_Adapter_Sns'; } + } + + /** The configured provider name (`tiger.sms.provider`), lowercased; '' when unset. */ + public static function provider(): string + { + return strtolower(trim((string) self::_config('sms.provider', ''))); + } + + /** Whether SMS is usable here: a provider is selected AND its adapter reports itself configured. */ + public static function isConfigured(): bool + { + $a = self::_adapter(); + return $a !== null && $a->isConfigured(); + } + + /** The registered provider names (for a settings picker). @return string[] */ + public static function providers(): array + { + self::_seed(); + return array_keys(self::$_registry); + } + + /** + * Send one SMS via the configured provider. The ONE call the platform uses. + * + * @param string $to the destination number (normalized to E.164 here) + * @param string $message the message body + * @param array $opts {from?:string} — override the default sender for this message + * @return array {ok:bool, id?:string, provider?:string, to?:string, error?:string} + */ + public static function send(string $to, string $message, array $opts = []): array + { + $adapter = self::_adapter(); + if ($adapter === null) { + return ['ok' => false, 'error' => 'no SMS provider configured (set tiger.sms.provider)']; + } + $e164 = self::e164($to); + if ($e164 === '') { + return ['ok' => false, 'error' => 'invalid destination number: ' . $to]; + } + if (trim($message) === '') { + return ['ok' => false, 'error' => 'empty message']; + } + $from = (string) ($opts['from'] ?? self::_config('sms.from', '') ?: $adapter->defaultFrom()); + try { + $res = $adapter->send($e164, $message, $from, $opts); + $res['provider'] = self::provider(); + $res['to'] = $e164; + return $res; + } catch (Throwable $e) { + if (class_exists('Tiger_Log')) { try { Tiger_Log::error('sms.send.failed', ['provider' => self::provider(), 'error' => $e->getMessage()]); } catch (Throwable $e2) { /* never fatal */ } } + return ['ok' => false, 'provider' => self::provider(), 'error' => $e->getMessage()]; + } + } + + /** + * Normalize a phone number to E.164 (`+`). Keeps a leading `+`; strips spaces, + * dashes and parentheses; a US-style 10-digit number gets a `+1`. Returns '' when it can't be made + * into a plausible E.164 number (so the caller fails cleanly rather than sending garbage). + * + * @param string $raw the number as entered + * @return string the E.164 number, or '' when invalid + */ + public static function e164(string $raw): string + { + $raw = trim($raw); + $plus = strncmp($raw, '+', 1) === 0; + $digits = preg_replace('/\D+/', '', $raw); + if ($digits === '') { return ''; } + if (!$plus && strlen($digits) === 10) { $digits = '1' . $digits; } // bare US 10-digit → +1 + // E.164: 8–15 digits, leading digit non-zero. + if (strlen($digits) < 8 || strlen($digits) > 15 || $digits[0] === '0') { return ''; } + return '+' . $digits; + } + + /** The configured adapter instance, or null when none is selected / the class is missing. */ + protected static function _adapter(): ?Tiger_Sms_Adapter_Abstract + { + self::_seed(); + $name = self::provider(); + if ($name === '' || !isset(self::$_registry[$name])) { return null; } + $class = self::$_registry[$name]; + if (!class_exists($class)) { return null; } + $a = new $class(self::_adapterConfig($name)); + return $a instanceof Tiger_Sms_Adapter_Abstract ? $a : null; + } + + /** The `tiger.sms.adapters..*` config map for one adapter. */ + protected static function _adapterConfig(string $name): array + { + if (!Zend_Registry::isRegistered('Zend_Config')) { return []; } + $cfg = Zend_Registry::get('Zend_Config'); + $sms = ($cfg instanceof Zend_Config && $cfg->get('tiger')) ? $cfg->get('tiger')->get('sms') : null; + $ad = ($sms instanceof Zend_Config) ? $sms->get('adapters') : null; + $one = ($ad instanceof Zend_Config) ? $ad->get($name) : null; + return ($one instanceof Zend_Config) ? $one->toArray() : []; + } + + /** A `tiger.` config value with a default. */ + protected static function _config(string $dotKey, $default = null) + { + if (!Zend_Registry::isRegistered('Zend_Config')) { return $default; } + $node = Zend_Registry::get('Zend_Config'); + if (!($node instanceof Zend_Config)) { return $default; } + $node = $node->get('tiger'); + foreach (explode('.', $dotKey) as $part) { + if (!($node instanceof Zend_Config)) { return $default; } + $node = $node->get($part); + } + return ($node === null || $node === '') ? $default : $node; + } +} diff --git a/library/Tiger/Sms/Adapter/Abstract.php b/library/Tiger/Sms/Adapter/Abstract.php new file mode 100644 index 00000000..248e668c --- /dev/null +++ b/library/Tiger/Sms/Adapter/Abstract.php @@ -0,0 +1,121 @@ +.*`) and implements + * send(). Secrets are read with {@see _secret} (an encrypted `_enc` is decrypted; a plaintext + * `` is a fallback). `fields()` declares the adapter's settings so the admin SMS screen can render + * the right inputs without the module knowing each provider. Keep the HTTP in {@see _http} so adapters + * stay small and testable. + * + * @api + */ +abstract class Tiger_Sms_Adapter_Abstract +{ + /** @var array the adapter's config (tiger.sms.adapters..*) */ + protected $_config = []; + + /** + * @param array $config the adapter's config map + */ + public function __construct(array $config = []) + { + $this->_config = $config; + } + + /** + * Send one message. Implementations must never throw for an expected failure — return + * `['ok'=>false,'error'=>...]` so the facade can report it cleanly. + * + * @param string $to the destination, already E.164-normalized + * @param string $message the body + * @param string $from the sender (origination number / sender id) + * @param array $opts provider-specific options + * @return array {ok:bool, id?:string, error?:string} + */ + abstract public function send(string $to, string $message, string $from, array $opts = []): array; + + /** Whether this adapter has the minimum config to send (credentials + a sender). @return bool */ + abstract public function isConfigured(): bool; + + /** The adapter's own default sender (`from`), or ''. */ + public function defaultFrom(): string + { + return (string) $this->_cfg('from', ''); + } + + /** A human label for the provider picker (default: the class suffix). */ + public function label(): string + { + $p = strrpos(static::class, '_'); + return $p !== false ? substr(static::class, $p + 1) : static::class; + } + + /** + * The settings fields the admin SMS screen renders for this provider. Each: {key,label,type,secret?}. + * `secret:true` means the value is write-only + stored encrypted (a blank submit keeps the current). + * + * @return array + */ + public function fields(): array + { + return []; + } + + // ----- helpers ---------------------------------------------------------------------------------- + + /** A plain adapter-config value, or $default when unset. */ + protected function _cfg(string $key, $default = '') + { + return array_key_exists($key, $this->_config) && $this->_config[$key] !== '' ? $this->_config[$key] : $default; + } + + /** + * A secret config value — the encrypted `_enc` (decrypted) wins, else a plaintext ``. + * Returns '' when unreadable (e.g. a rotated crypto key), so callers fail closed. + * + * @param string $key the base key name (e.g. 'auth_token' → reads auth_token_enc then auth_token) + * @return string + */ + protected function _secret(string $key): string + { + $enc = (string) $this->_cfg($key . '_enc', ''); + if ($enc !== '' && class_exists('Tiger_Crypto')) { + try { return (string) Tiger_Crypto::decrypt($enc); } catch (Throwable $e) { return ''; } + } + return (string) $this->_cfg($key, ''); + } + + /** + * A small cURL helper. Returns [httpStatus, responseBody]; httpStatus 0 on a transport error. + * + * @param string $method GET | POST + * @param string $url + * @param array $headers raw header lines + * @param string $body request body (form- or query-encoded by the caller) + * @param int $timeout seconds + * @return array [int $status, string $body] + */ + protected function _http(string $method, string $url, array $headers = [], string $body = '', int $timeout = 15): array + { + if (!function_exists('curl_init')) { return [0, 'curl unavailable']; } + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_HTTPHEADER => $headers, + ]); + if ($method === 'POST') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } + $out = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + if ($out === false) { return [0, $err ?: 'transport error']; } + return [$status, (string) $out]; + } +} diff --git a/library/Tiger/Sms/Adapter/Sns.php b/library/Tiger/Sms/Adapter/Sns.php new file mode 100644 index 00000000..5d49df97 --- /dev/null +++ b/library/Tiger/Sms/Adapter/Sns.php @@ -0,0 +1,163 @@ + 'region', 'label' => 'AWS region', 'type' => 'text', 'placeholder' => 'us-east-1'], + ['key' => 'from', 'label' => 'Origination number', 'type' => 'text', 'placeholder' => '+18885551234'], + ['key' => 'key', 'label' => 'Access key (blank = use the instance role)', 'type' => 'text'], + ['key' => 'secret_key', 'label' => 'Secret key', 'type' => 'secret', 'secret' => true], + ]; + } + + public function isConfigured(): bool + { + // Either static keys OR an instance role (we can't cheaply prove the role here, so a region alone + // is treated as "AWS-hosted, will use the role"). The send itself fails cleanly if neither works. + return (string) $this->_cfg('region', '') !== '' || $this->_credentials() !== null; + } + + public function send(string $to, string $message, string $from, array $opts = []): array + { + $region = (string) $this->_cfg('region', 'us-east-1'); + $creds = $this->_credentials(); + if ($creds === null) { return ['ok' => false, 'error' => 'SNS has no credentials (set key/secret_key or attach an instance role)']; } + + $smsType = (string) ($opts['sms_type'] ?? 'Transactional'); // Transactional | Promotional + $params = [ + 'Action' => 'Publish', + 'Version' => self::VERSION, + 'PhoneNumber' => $to, + 'Message' => $message, + ]; + $attrs = [['AWS.SNS.SMS.SMSType', $smsType]]; + if ($from !== '') { + // A leading '+' = an origination number; otherwise a sender id (non-US). + $attrs[] = strncmp($from, '+', 1) === 0 ? ['AWS.MM.SMS.OriginationNumber', $from] : ['AWS.SNS.SMS.SenderID', $from]; + } + foreach ($attrs as $i => [$name, $value]) { + $n = $i + 1; + $params["MessageAttributes.entry.{$n}.Name"] = $name; + $params["MessageAttributes.entry.{$n}.Value.DataType"] = 'String'; + $params["MessageAttributes.entry.{$n}.Value.StringValue"] = $value; + } + + ksort($params); + $body = http_build_query($params, '', '&', PHP_QUERY_RFC3986); + $host = 'sns.' . $region . '.amazonaws.com'; + $headers = $this->_sigV4Headers($host, $region, $body, $creds); + + [$status, $resp] = $this->_http('POST', 'https://' . $host . '/', $headers, $body, 15); + if ($status >= 200 && $status < 300) { + $id = ''; + if (preg_match('#([^<]+)#', $resp, $m)) { $id = $m[1]; } + return ['ok' => true, 'id' => $id]; + } + $err = ''; + if (preg_match('#([^<]+)#', $resp, $c)) { $err = $c[1]; } + if (preg_match('#([^<]+)#', $resp, $mm)) { $err .= ($err !== '' ? ': ' : '') . $mm[1]; } + return ['ok' => false, 'error' => 'SNS error (HTTP ' . $status . ')' . ($err !== '' ? ' ' . $err : '')]; + } + + // ----- credentials ------------------------------------------------------------------------------ + + /** + * Static config credentials, else the EC2 instance role (IMDSv2). @return array{key,secret,token}|null + */ + protected function _credentials(): ?array + { + $key = (string) ($this->_cfg('key', '') ?: ''); + $secret = $this->_secret('secret_key'); + if ($key !== '' && $secret !== '') { + return ['key' => $key, 'secret' => $secret, 'token' => $this->_secret('session_token')]; + } + return $this->_instanceRoleCreds(); + } + + /** Fetch temporary credentials from the EC2 instance role via IMDSv2. Null when not on EC2 / no role. */ + protected function _instanceRoleCreds(): ?array + { + try { + [$st, $token] = $this->_http('PUT', 'http://169.254.169.254/latest/api/token', + ['X-aws-ec2-metadata-token-ttl-seconds: 60'], '', 2); + if ($st !== 200 || $token === '') { return null; } + $h = ['X-aws-ec2-metadata-token: ' . $token]; + [$st2, $role] = $this->_http('GET', 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', $h, '', 2); + if ($st2 !== 200 || trim($role) === '') { return null; } + $role = trim(strtok($role, "\n")); + [$st3, $body] = $this->_http('GET', 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' . rawurlencode($role), $h, '', 2); + $j = json_decode($body, true); + if ($st3 !== 200 || !is_array($j) || empty($j['AccessKeyId'])) { return null; } + return ['key' => (string) $j['AccessKeyId'], 'secret' => (string) $j['SecretAccessKey'], 'token' => (string) ($j['Token'] ?? '')]; + } catch (Throwable $e) { + return null; + } + } + + // ----- SigV4 ------------------------------------------------------------------------------------ + + /** + * The SigV4 header set for a form POST to SNS. Standard four-step SigV4 (canonical request → + * string-to-sign → signing key HMAC chain → signature), signing host/content-type/x-amz-* headers. + * + * @param array{key:string,secret:string,token:string} $creds + * @return string[] curl header lines + */ + protected function _sigV4Headers(string $host, string $region, string $body, array $creds): array + { + $now = gmdate('Ymd\THis\Z'); + $date = substr($now, 0, 8); + $scope = $date . '/' . $region . '/' . self::SERVICE . '/aws4_request'; + $bodyHash = hash('sha256', $body); + $token = (string) ($creds['token'] ?? ''); + + $canonHeaders = "content-type:application/x-www-form-urlencoded\n" + . "host:{$host}\n" + . "x-amz-content-sha256:{$bodyHash}\n" + . "x-amz-date:{$now}\n" + . ($token !== '' ? "x-amz-security-token:{$token}\n" : ''); + $signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date' . ($token !== '' ? ';x-amz-security-token' : ''); + + $canonRequest = "POST\n/\n\n{$canonHeaders}\n{$signedHeaders}\n{$bodyHash}"; + $stringToSign = self::ALGORITHM . "\n{$now}\n{$scope}\n" . hash('sha256', $canonRequest); + + $kDate = hash_hmac('sha256', $date, 'AWS4' . $creds['secret'], true); + $kRegion = hash_hmac('sha256', $region, $kDate, true); + $kService = hash_hmac('sha256', self::SERVICE, $kRegion, true); + $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); + $signature = hash_hmac('sha256', $stringToSign, $kSigning); + + $auth = self::ALGORITHM . " Credential={$creds['key']}/{$scope}, SignedHeaders={$signedHeaders}, Signature={$signature}"; + $headers = [ + 'Authorization: ' . $auth, + 'Content-Type: application/x-www-form-urlencoded', + 'x-amz-content-sha256: ' . $bodyHash, + 'x-amz-date: ' . $now, + ]; + if ($token !== '') { $headers[] = 'x-amz-security-token: ' . $token; } + return $headers; + } +} diff --git a/library/Tiger/Sms/Adapter/Twilio.php b/library/Tiger/Sms/Adapter/Twilio.php new file mode 100644 index 00000000..aca9ea80 --- /dev/null +++ b/library/Tiger/Sms/Adapter/Twilio.php @@ -0,0 +1,59 @@ + 'account_sid', 'label' => 'Account SID', 'type' => 'text', 'placeholder' => 'AC…'], + ['key' => 'auth_token', 'label' => 'Auth Token', 'type' => 'secret', 'secret' => true], + ['key' => 'from', 'label' => 'From number (E.164)', 'type' => 'text', 'placeholder' => '+15551234567'], + ['key' => 'messaging_service_sid', 'label' => 'Messaging Service SID (optional)', 'type' => 'text', 'placeholder' => 'MG…'], + ]; + } + + public function isConfigured(): bool + { + $hasSender = $this->defaultFrom() !== '' || (string) $this->_cfg('messaging_service_sid', '') !== ''; + return (string) $this->_cfg('account_sid', '') !== '' && $this->_secret('auth_token') !== '' && $hasSender; + } + + public function send(string $to, string $message, string $from, array $opts = []): array + { + $sid = (string) $this->_cfg('account_sid', ''); + $token = $this->_secret('auth_token'); + if ($sid === '' || $token === '') { return ['ok' => false, 'error' => 'Twilio is not configured (account_sid / auth_token)']; } + + $params = ['To' => $to, 'Body' => $message]; + $mss = (string) ($opts['messaging_service_sid'] ?? $this->_cfg('messaging_service_sid', '')); + if ($mss !== '') { $params['MessagingServiceSid'] = $mss; } + elseif ($from !== '') { $params['From'] = $from; } + else { return ['ok' => false, 'error' => 'Twilio has no sender (set a From number or a Messaging Service SID)']; } + + [$status, $body] = $this->_http('POST', sprintf(self::API, rawurlencode($sid)), + ['Authorization: Basic ' . base64_encode($sid . ':' . $token), 'Content-Type: application/x-www-form-urlencoded'], + http_build_query($params)); + + $json = json_decode($body, true); + if ($status >= 200 && $status < 300 && is_array($json) && !empty($json['sid'])) { + return ['ok' => true, 'id' => (string) $json['sid']]; + } + $err = is_array($json) ? (string) ($json['message'] ?? $json['error_message'] ?? '') : ''; + return ['ok' => false, 'error' => 'Twilio error (HTTP ' . $status . ')' . ($err !== '' ? ': ' . $err : '')]; + } +} diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index ce1e781d..36101f0b 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.24.0'; + const VERSION = '1.25.0'; } diff --git a/modules/sms/Bootstrap.php b/modules/sms/Bootstrap.php new file mode 100644 index 00000000..04fdcf89 --- /dev/null +++ b/modules/sms/Bootstrap.php @@ -0,0 +1,32 @@ + 'sms', + 'label' => 'SMS', + 'icon' => 'fa-comment-sms', + 'href' => '/sms/admin/settings', + 'resource' => 'Sms_AdminController', + 'order' => 65, + ]); + } +} diff --git a/modules/sms/assets/js/sms.settings.js b/modules/sms/assets/js/sms.settings.js new file mode 100644 index 00000000..0f2add92 --- /dev/null +++ b/modules/sms/assets/js/sms.settings.js @@ -0,0 +1,64 @@ +/* SMS settings — provider field toggle, save + test over /api (house primitives). */ +(function () { + 'use strict'; + var sel = document.getElementById('sms-provider'); + var fb = document.getElementById('sms-feedback'); + if (!sel) { return; } + + function T(k) { return (window.Tiger && Tiger.t) ? Tiger.t(k) : k; } + function notify(msg, type) { if (window.TigerDOM) { TigerDOM.notify(fb, msg, { type: type || 'info' }); } } + + /** Show only the selected provider's credential fields. */ + function toggle() { + var cur = sel.value; + document.querySelectorAll('[data-sms-fields]').forEach(function (el) { + el.hidden = (el.getAttribute('data-sms-fields') !== cur); + }); + } + sel.addEventListener('change', toggle); + toggle(); + + /** Collect the save payload: provider + only the selected provider's fields. */ + function savePayload() { + var fd = new URLSearchParams(); + fd.set('module', 'sms'); fd.set('service', 'settings'); fd.set('method', 'save'); + fd.set('provider', sel.value); + document.querySelectorAll('[data-sms-field="' + sel.value + '"]').forEach(function (inp) { + fd.set(inp.getAttribute('name'), inp.value); + }); + return fd; + } + + function post(fd) { + return fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }) + .then(function (r) { return r.json().catch(function () { return {}; }); }); + } + + document.getElementById('sms-save').addEventListener('click', function () { + var btn = this; + TigerButton.run(btn, function () { return post(savePayload()); }).then(function (res) { + if (res && res.result === 1) { + notify(T('sms.settings.saved'), 'success'); + // a successful save may flip the "test" button's availability — reload keeps it honest + setTimeout(function () { window.location.reload(); }, 700); + return; + } + (res && res.messages || [{ message: T('core.api.error.general'), class: 'error' }]).forEach(function (m) { notify(m.message, m.class); }); + }).catch(function () { notify('Network error — please try again.', 'error'); }); + }); + + var testBtn = document.getElementById('sms-test'); + if (testBtn) { + testBtn.addEventListener('click', function () { + var btn = this; + var fd = new URLSearchParams(); + fd.set('module', 'sms'); fd.set('service', 'settings'); fd.set('method', 'test'); + fd.set('to', (document.getElementById('sms-test-to') || {}).value || ''); + fd.set('message', (document.getElementById('sms-test-msg') || {}).value || ''); + TigerButton.run(btn, function () { return post(fd); }).then(function (res) { + if (res && res.result === 1) { notify(T('sms.settings.test_sent'), 'success'); return; } + (res && res.messages || [{ message: T('sms.settings.error.test'), class: 'error' }]).forEach(function (m) { notify(m.message, m.class); }); + }).catch(function () { notify('Network error — please try again.', 'error'); }); + }); + } +})(); diff --git a/modules/sms/configs/acl.ini b/modules/sms/configs/acl.ini new file mode 100644 index 00000000..7882c082 --- /dev/null +++ b/modules/sms/configs/acl.ini @@ -0,0 +1,22 @@ +; SPDX-License-Identifier: BSD-3-Clause +; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +; +; SMS admin ACL — the settings screen + its /api service, admin+ only (deny-by-default elsewhere). +[production] + +acl.resources.sms_admin_ctrl.resource = "Sms_AdminController" +acl.resources.sms_settings_svc.resource = "Sms_Service_Settings" + +acl.rules.sms_admin_ctrl.role = "admin" +acl.rules.sms_admin_ctrl.resource = "Sms_AdminController" +acl.rules.sms_admin_ctrl.permission = "allow" + +acl.rules.sms_settings_svc.role = "admin" +acl.rules.sms_settings_svc.resource = "Sms_Service_Settings" +acl.rules.sms_settings_svc.permission = "allow" + +[staging : production] + +[testing : production] + +[development : production] diff --git a/modules/sms/controllers/AdminController.php b/modules/sms/controllers/AdminController.php new file mode 100644 index 00000000..4223387c --- /dev/null +++ b/modules/sms/controllers/AdminController.php @@ -0,0 +1,49 @@ +get($g, '', $key); }; + + $providers = []; + foreach (Tiger_Sms::providers() as $name) { + $class = 'Tiger_Sms_Adapter_' . ucfirst($name); + if (!class_exists($class)) { continue; } + $adapter = new $class(); + $fields = []; + foreach ($adapter->fields() as $f) { + $base = 'tiger.sms.adapters.' . $name . '.' . $f['key']; + $f['value'] = empty($f['secret']) ? $get($base) : ''; + $f['set'] = !empty($f['secret']) && $get($base . '_enc') !== ''; + $fields[] = $f; + } + $providers[] = ['name' => $name, 'label' => $adapter->label(), 'fields' => $fields]; + } + + $this->view->title = 'SMS — Tiger Admin'; + $this->view->providers = $providers; + $this->view->provider = Tiger_Sms::provider(); + $this->view->configured = Tiger_Sms::isConfigured(); + $this->view->cryptoReady = class_exists('Tiger_Crypto') && Tiger_Crypto::isConfigured(); + $this->i18n([ + 'sms.settings.saved', 'sms.settings.test_sent', 'sms.settings.error.test', + 'sms.settings.error.to', 'core.api.error.general', + ]); + } +} diff --git a/modules/sms/docs/en/sms.md b/modules/sms/docs/en/sms.md new file mode 100644 index 00000000..94fc9c20 --- /dev/null +++ b/modules/sms/docs/en/sms.md @@ -0,0 +1,47 @@ + +# SMS + +Tiger sends text messages through a provider-agnostic layer — the SMS sibling of the email system. +Pick a provider, add its credentials, and any part of the platform can send an SMS (one-time codes, +2FA, operational alerts) with a single call. + +## Set it up + +**Admin → Settings → SMS.** Choose a provider, enter its credentials, **Save**, then use **Send a +test** to confirm it end to end. + +### Twilio +The industry-standard SMS API. You need: +- **Account SID** and **Auth Token** (Twilio Console → Account). +- Either a **From number** (a number you own in Twilio, in E.164 like `+15551234567`) **or** a + **Messaging Service SID** (a Twilio number pool). + +### Amazon SNS +Native to an AWS-hosted Tiger. You need: +- An **AWS region** (e.g. `us-east-1`). +- An **origination number** as the sender — a 10DLC or toll-free number (required for US A2P SMS). +- **Access/secret keys** — *or leave them blank* on an EC2 instance that has a role with `sns:Publish`, + and Tiger uses the instance role automatically (no keys to manage, like the fleet's email). + +> A brand-new AWS account is in the **SMS sandbox** ($1/month, verified destinations only) until you +> request production access. In the sandbox you can still send to verified test numbers. + +## Numbers + +Destinations are normalized to **E.164** (`+`) automatically — a bare US 10-digit +number becomes `+1…`. An un-parseable number fails cleanly rather than sending. + +## For developers + +```php +$res = Tiger_Sms::send('+14075551234', 'Your code is 123456'); +// ['ok' => true, 'id' => '…', 'provider' => 'twilio', 'to' => '+14075551234'] +``` + +Unconfigured or on a provider error it returns `['ok' => false, 'error' => '…']` — it never throws. +Add another provider by extending `Tiger_Sms_Adapter_Abstract` and `Tiger_Sms::register('name', Class)`. + +## Compliance + +SMS carries strict consent rules (TCPA, US 10DLC registration, opt-in + STOP handling). Transactional +messages (codes, alerts) and marketing differ — only send where you have consent. diff --git a/modules/sms/languages/en/sms.php b/modules/sms/languages/en/sms.php new file mode 100644 index 00000000..953cf91d --- /dev/null +++ b/modules/sms/languages/en/sms.php @@ -0,0 +1,10 @@ + 'SMS settings saved.', + 'sms.settings.test_sent' => 'Test message sent.', + 'sms.settings.error.provider' => 'Unknown SMS provider.', + 'sms.settings.error.to' => 'Enter a destination number for the test.', + 'sms.settings.error.test' => 'The test message could not be sent.', +]; diff --git a/modules/sms/module.json b/modules/sms/module.json new file mode 100644 index 00000000..788a2e25 --- /dev/null +++ b/modules/sms/module.json @@ -0,0 +1,5 @@ +{ + "name": "SMS", + "type": "app", + "category": ["messaging"] +} diff --git a/modules/sms/services/Settings.php b/modules/sms/services/Settings.php new file mode 100644 index 00000000..99c35a92 --- /dev/null +++ b/modules/sms/services/Settings.php @@ -0,0 +1,77 @@ +_enc`; + * a blank secret on save KEEPS the current one (so the screen never shows or wipes a stored token). + * `test` sends through the live Tiger_Sms facade so the operator can prove the config end to end. + */ +class Sms_Service_Settings extends Tiger_Service_Service +{ + /** + * Save the SMS settings: provider, default sender, and the selected provider's own fields. + * + * @param array $params {provider, from, } + * @return void + */ + public function save(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $provider = strtolower(trim((string) ($params['provider'] ?? ''))); + if ($provider !== '' && !in_array($provider, Tiger_Sms::providers(), true)) { + $this->_error('sms.settings.error.provider'); return; + } + + try { + $this->_transaction(function () use ($params, $provider) { + $cfg = new Tiger_Model_Config(); + $scope = Tiger_Model_Config::SCOPE_GLOBAL; + $cfg->set($scope, '', 'tiger.sms.provider', $provider); + if ($provider === '') { return; } + $adapterClass = 'Tiger_Sms_Adapter_' . ucfirst($provider); + $fields = class_exists($adapterClass) ? (new $adapterClass())->fields() : []; + foreach ($fields as $f) { + $key = (string) $f['key']; + $val = (string) ($params[$key] ?? ''); + $base = 'tiger.sms.adapters.' . $provider . '.' . $key; + if (!empty($f['secret'])) { + if ($val === '') { continue; } // blank keeps the stored secret + if (!Tiger_Crypto::isConfigured()) { throw new RuntimeException('encryption key not configured'); } + $cfg->set($scope, '', $base . '_enc', Tiger_Crypto::encrypt($val)); + } else { + $cfg->set($scope, '', $base, $val); + } + } + }); + $this->_success(['configured' => Tiger_Sms::isConfigured()], 'sms.settings.saved'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * Send a test message through the configured provider. + * + * @param array $params {to, message?} + * @return void + */ + public function test(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + $to = (string) ($params['to'] ?? ''); + $msg = trim((string) ($params['message'] ?? '')) ?: 'Tiger SMS test message.'; + if ($to === '') { $this->_error('sms.settings.error.to'); return; } + + $res = Tiger_Sms::send($to, $msg); + if (!empty($res['ok'])) { + $this->_success(['id' => $res['id'] ?? null, 'to' => $res['to'] ?? $to], 'sms.settings.test_sent'); + return; + } + $this->_error(APPLICATION_ENV !== 'production' + ? ('test failed: ' . (string) ($res['error'] ?? 'unknown')) + : 'sms.settings.error.test'); + } +} diff --git a/modules/sms/views/scripts/admin/settings.phtml b/modules/sms/views/scripts/admin/settings.phtml new file mode 100644 index 00000000..2b2f705f --- /dev/null +++ b/modules/sms/views/scripts/admin/settings.phtml @@ -0,0 +1,84 @@ +translate($k); }; +?> +
+
+

SMS

+

Send text messages through a provider — one-time codes, 2FA, and operational alerts.

+
+
+ +
+
+ +
+ +cryptoReady): ?> +
No encryption key is configured, so provider secrets can't be stored safely. Run tiger install:secrets first.
+ + +
+
+
+
+
Provider
+
+
+ + +
+ providers as $p): ?> +
provider === $p['name'] ? '' : ' hidden' ?>> +
+
escape($p['label']) ?> credentials
+ +
+ + + + + + +
+ +
+ +
+
+
+ +
+
+
Send a test
+
+

Sends through the saved provider. Save first.

+
+ + +
+
+ + +
+ +
+
+
+
+
+pageScript($this->asset('js/sms.settings.js', 'sms')); ?> diff --git a/tests/Unit/Sms/SmsTest.php b/tests/Unit/Sms/SmsTest.php new file mode 100644 index 00000000..ecebf7f3 --- /dev/null +++ b/tests/Unit/Sms/SmsTest.php @@ -0,0 +1,176 @@ + true, 'id' => 'stub-1', 'from_used' => $from, 'msg' => $message]; + } +} + +/** The provider-agnostic SMS facade + the Twilio/SNS adapters (request shaping, response parsing, + * config detection) — all network-free (the HTTP seam is overridden). */ +#[CoversClass(Tiger_Sms::class)] +#[CoversClass(Tiger_Sms_Adapter_Twilio::class)] +#[CoversClass(Tiger_Sms_Adapter_Sns::class)] +final class SmsTest extends UnitTestCase +{ + #[Test] + public function e164_normalizes_and_rejects_junk(): void + { + $this->assertSame('+14075551234', Tiger_Sms::e164('(407) 555-1234')); // bare US 10-digit → +1 + $this->assertSame('+14075551234', Tiger_Sms::e164('+1 407-555-1234')); + $this->assertSame('+447911123456', Tiger_Sms::e164('+44 7911 123456')); + $this->assertSame('', Tiger_Sms::e164('12')); // too short + $this->assertSame('', Tiger_Sms::e164('words only')); // no digits + $this->assertSame('', Tiger_Sms::e164('+0123456789')); // E.164 can't lead with 0 + } + + #[Test] + public function twilio_config_detection(): void + { + $this->assertFalse((new Tiger_Sms_Adapter_Twilio([]))->isConfigured()); + $this->assertFalse((new Tiger_Sms_Adapter_Twilio(['account_sid' => 'ACx', 'auth_token' => 't']))->isConfigured(), 'no sender'); + $this->assertTrue((new Tiger_Sms_Adapter_Twilio(['account_sid' => 'ACx', 'auth_token' => 't', 'from' => '+15551230000']))->isConfigured()); + $this->assertTrue((new Tiger_Sms_Adapter_Twilio(['account_sid' => 'ACx', 'auth_token' => 't', 'messaging_service_sid' => 'MGx']))->isConfigured()); + } + + #[Test] + public function twilio_send_shapes_request_and_parses_sid(): void + { + $a = new class(['account_sid' => 'ACx', 'auth_token' => 'tok', 'from' => '+15551230000']) extends Tiger_Sms_Adapter_Twilio { + public $url = ''; public $body = ''; public $auth = ''; + protected function _http(string $method, string $url, array $headers = [], string $body = '', int $timeout = 15): array + { + $this->url = $url; $this->body = $body; $this->auth = implode("\n", $headers); + return [201, json_encode(['sid' => 'SM123', 'status' => 'queued'])]; + } + }; + $r = $a->send('+14075551234', 'hi there', '+15551230000'); + $this->assertTrue($r['ok']); + $this->assertSame('SM123', $r['id']); + $this->assertStringContainsString('Accounts/ACx/Messages.json', $a->url); + $this->assertStringContainsString('To=%2B14075551234', $a->body); + $this->assertStringContainsString('Authorization: Basic ' . base64_encode('ACx:tok'), $a->auth); + } + + #[Test] + public function twilio_send_reports_api_error(): void + { + $a = new class(['account_sid' => 'ACx', 'auth_token' => 'tok', 'from' => '+1555']) extends Tiger_Sms_Adapter_Twilio { + protected function _http(string $method, string $url, array $headers = [], string $body = '', int $timeout = 15): array + { + return [400, json_encode(['message' => 'The From number is not a valid phone number', 'code' => 21212])]; + } + }; + $r = $a->send('+14075551234', 'hi', '+1555'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('not a valid phone number', $r['error']); + } + + #[Test] + public function facade_fails_cleanly_with_no_provider(): void + { + $r = Tiger_Sms::send('+14075551234', 'hi'); // nothing configured in a unit run + $this->assertFalse($r['ok']); + $this->assertStringContainsString('no SMS provider', $r['error']); + $this->assertContains('twilio', Tiger_Sms::providers()); // the built-ins self-seed + $this->assertContains('sns', Tiger_Sms::providers()); + } + + #[Test] + public function facade_routes_normalizes_and_guards_with_a_configured_provider(): void + { + Tiger_Sms::register('stub', StubSmsAdapter::class); + $prev = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => ['sms' => [ + 'provider' => 'stub', + 'adapters' => ['stub' => ['from' => '+15550001111']], + ]]])); + try { + $this->assertSame('stub', Tiger_Sms::provider()); + $this->assertTrue(Tiger_Sms::isConfigured()); + + $ok = Tiger_Sms::send('(407) 555-1234', 'hello'); + $this->assertTrue($ok['ok']); + $this->assertSame('stub', $ok['provider']); + $this->assertSame('+14075551234', $ok['to']); // normalized on the way through + $this->assertSame('+15550001111', $ok['from_used']); // resolved from the adapter's config + + $this->assertFalse(Tiger_Sms::send('nope', 'hi')['ok']); // invalid number + $this->assertFalse(Tiger_Sms::send('+14075551234', '')['ok']); // empty message + } finally { + if ($prev !== null) { Zend_Registry::set('Zend_Config', $prev); } + else { Zend_Registry::getInstance()->offsetUnset('Zend_Config'); } + } + } + + #[Test] + public function twilio_uses_messaging_service_sid_when_no_from(): void + { + $a = new class(['account_sid' => 'ACx', 'auth_token' => 't', 'messaging_service_sid' => 'MGx']) extends Tiger_Sms_Adapter_Twilio { + public $body = ''; + protected function _http(string $method, string $url, array $headers = [], string $body = '', int $timeout = 15): array + { + $this->body = $body; + return [201, json_encode(['sid' => 'SMx'])]; + } + }; + $r = $a->send('+14075551234', 'hi', ''); // no From → must fall back to the Messaging Service SID + $this->assertTrue($r['ok']); + $this->assertStringContainsString('MessagingServiceSid=MGx', $a->body); + $this->assertSame('Twilio', $a->label()); + } + + #[Test] + public function sns_without_credentials_or_ec2_role_fails_cleanly(): void + { + $a = new class(['region' => 'us-east-1']) extends Tiger_Sms_Adapter_Sns { // no keys + protected function _http(string $method, string $url, array $headers = [], string $body = '', int $timeout = 15): array + { + return [0, 'unreachable']; // simulate: not on EC2 (no IMDS) + } + }; + $r = $a->send('+14075551234', 'hi', '+18885551234'); + $this->assertFalse($r['ok']); + $this->assertStringContainsString('no credentials', $r['error']); + $this->assertNotEmpty($a->fields()); + $this->assertSame('Amazon SNS', $a->label()); + } + + #[Test] + public function sns_send_signs_and_parses_message_id(): void + { + $a = new class(['region' => 'us-east-1', 'key' => 'AKIATEST', 'secret_key' => 'secret', 'from' => '+18885551234']) extends Tiger_Sms_Adapter_Sns { + public $headers = []; public $body = ''; + protected function _http(string $method, string $url, array $headers = [], string $body = '', int $timeout = 15): array + { + $this->headers = $headers; $this->body = $body; + return [200, 'abc-123']; + } + }; + $r = $a->send('+14075551234', 'hello', '+18885551234'); + $this->assertTrue($r['ok']); + $this->assertSame('abc-123', $r['id']); + $auth = implode("\n", $a->headers); + $this->assertStringContainsString('AWS4-HMAC-SHA256 Credential=AKIATEST/', $auth); + $this->assertStringContainsString('us-east-1/sns/aws4_request', $auth); + $this->assertStringContainsString('PhoneNumber=%2B14075551234', $a->body); + } +}