diff --git a/.gitignore b/.gitignore index f0fa3ee..0831ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ vendor/ coverage/ storage/ logs/ +vault/ *.log *.cache *.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ff1d57..6f41af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to `bitshost/upmvc-saas-pack`. ## [Unreleased] +### Harvest — ops / mail / notify kernel / i18n / MFA primitives + +Lifted from the `myCMMS-PHP` probe into pack libraries so every SaaS product gets shared factory pieces without CMMS domain coupling. See `docs/09-harvest-ops.md`. + +#### Added +- **`database/migrations/002_saas_ops_layer.sql`** — `platform_settings`, `smtp_settings`, `email_log`, `audit_log`, `notification_*`, `i18n_overlays`, optional `users.totp_*`. +- **`BitsHost\UpmvcSaas\Mail\MailGateway`** + **`Security\SecretBox`** — platform SMTP, plan-gated tenant SMTP, `.env` fallback, send log. +- **`BitsHost\UpmvcSaas\Notify\{NotifyRegistry,NotifyGate,Notify}`** — empty type registry (apps register), global pause, instant fire with mutes. +- **`BitsHost\UpmvcSaas\Audit\AuditLog`** — append-only audit with impersonator support. +- **`BitsHost\UpmvcSaas\Security\Totp`** — RFC 6238 helper for platform MFA. +- **`BitsHost\UpmvcSaas\Ops\{Attention,PlatformSettings,SchedulerHeartbeat}`** — generic Attention + flags + cron heartbeat. +- **`BitsHost\UpmvcSaas\I18n\Catalog`** — file catalogs + dotted-key DB overlays. +- **`Api/Modules/PlatformOps`**, **`Api/Modules/I18n`** — thin overridable API surfaces. +- **`public/lang/{en,ro}.json`** — minimal shell i18n seed. +- **`tests/Unit/HarvestKernelTest.php`** — Totp, registry, audit actors, catalog overlays, migration guard. +- **`docs/09-harvest-ops.md`** — wiring guide and pack vs new-pack rule. + +#### Changed +- **`App\Modules\Mail\MailController`** — delegates to `MailGateway` (legacy helpers kept, deprecated). +- **Harvest kernel classes** (`AuditLog`, `Totp`, `SecretBox`, `MailGateway`, `NotifyGate`, `Notify`) — dropped `final` so consuming apps can thin-extend without import churn. +- **`Security\SecretBox` / `Security\Totp`** — dual-unseal read bridge: `seal()` writes with pack peppers only; `unseal()` also accepts myCMMS legacy peppers (`|smtp-password-v1`, `mycmms-totp|`) so path installs do not brick existing SMTP/MFA rows. See `docs/09-harvest-ops.md`. +- **`.gitignore`** — ignore local `vault/` (agent handoff notes; never commit contents). +- **`tests/Unit/HarvestKernelTest.php`** — dual-unseal coverage for legacy SMTP and TOTP peppers. + +#### Deliberately out of pack +- Domain notify types, payments/billing, full PlatformAdmin UI tabs, AI translate. + --- ## v1.0.0 "Tenancy" - Multi-tenant SaaS modules for upMVC (2026-07-29) diff --git a/database/migrations/002_saas_ops_layer.sql b/database/migrations/002_saas_ops_layer.sql new file mode 100644 index 0000000..a031e3a --- /dev/null +++ b/database/migrations/002_saas_ops_layer.sql @@ -0,0 +1,168 @@ +-- ============================================================ +-- upMVC SaaS Pack — Migration 002 — Ops layer (Harvest) +-- +-- Platform settings, SMTP gateway + email log, audit trail, +-- notification kill-switches / mutes / settings, i18n overlays, +-- optional TOTP columns on users. +-- +-- Domain notification *types* stay in the app (NotifyRegistry). +-- ============================================================ + +SET @db := DATABASE(); + +CREATE TABLE IF NOT EXISTS `platform_settings` ( + `setting_key` varchar(64) NOT NULL, + `setting_value` text DEFAULT NULL, + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`setting_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO `platform_settings` (`setting_key`, `setting_value`) VALUES + ('signup_enabled', '1'), + ('maintenance_mode', '0'), + ('scheduler_heartbeat', ''), + ('default_locale', 'en'), + ('announcement_json', ''); + +CREATE TABLE IF NOT EXISTS `smtp_settings` ( + `id` int NOT NULL AUTO_INCREMENT, + `tenant_id` int DEFAULT NULL, + `host` varchar(253) NOT NULL, + `port` int NOT NULL DEFAULT 587, + `secure` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1 = implicit TLS (465)', + `username` varchar(254) DEFAULT NULL, + `password_enc` text DEFAULT NULL, + `from_email` varchar(254) NOT NULL, + `from_name` varchar(120) DEFAULT NULL, + `reply_to` varchar(254) DEFAULT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `last_test_at` datetime DEFAULT NULL, + `last_test_ok` tinyint(1) DEFAULT NULL, + `last_error` varchar(500) DEFAULT NULL, + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_smtp_tenant` (`tenant_id`), + CONSTRAINT `fk_smtp_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @q := ( + SELECT IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='smtp_settings' AND COLUMN_NAME='platform_slot'), + 'SELECT 1', + 'ALTER TABLE smtp_settings ADD COLUMN platform_slot TINYINT GENERATED ALWAYS AS (IF(tenant_id IS NULL, 1, NULL)) STORED' + ) +); +PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s; + +SET @q := ( + SELECT IF( + EXISTS(SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='smtp_settings' AND INDEX_NAME='uk_smtp_platform'), + 'SELECT 1', + 'ALTER TABLE smtp_settings ADD UNIQUE KEY uk_smtp_platform (platform_slot)' + ) +); +PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s; + +CREATE TABLE IF NOT EXISTS `email_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` int DEFAULT NULL, + `to_email` varchar(254) NOT NULL, + `subject` varchar(300) NOT NULL, + `template` varchar(80) NOT NULL DEFAULT 'generic', + `transport` enum('tenant','platform','none') NOT NULL DEFAULT 'none', + `status` enum('sent','failed','skipped') NOT NULL, + `error` varchar(500) DEFAULT NULL, + `type` varchar(64) DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_email_log_at` (`created_at`), + KEY `idx_email_log_tenant` (`tenant_id`, `created_at`), + KEY `idx_email_log_status` (`status`, `created_at`), + CONSTRAINT `fk_email_log_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `audit_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` int DEFAULT NULL, + `user_id` int DEFAULT NULL, + `impersonator_id` int DEFAULT NULL, + `entity` varchar(64) NOT NULL, + `entity_id` int DEFAULT NULL, + `action` varchar(64) NOT NULL, + `details` json DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_audit_tenant_at` (`tenant_id`, `created_at`), + KEY `idx_audit_action` (`action`, `created_at`), + KEY `idx_audit_impersonator` (`impersonator_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `notification_global` ( + `type` varchar(64) NOT NULL, + `paused` tinyint(1) NOT NULL DEFAULT 0, + `reason` varchar(500) DEFAULT NULL, + `updated_by` int DEFAULT NULL, + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `notification_mutes` ( + `user_id` int NOT NULL, + `mute_key` varchar(64) NOT NULL, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`user_id`, `mute_key`), + CONSTRAINT `fk_notif_mute_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `notification_settings` ( + `id` int NOT NULL AUTO_INCREMENT, + `tenant_id` int DEFAULT NULL COMMENT 'NULL = platform zone', + `type` varchar(64) NOT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `frequency` varchar(16) DEFAULT NULL, + `hour` tinyint DEFAULT NULL, + `weekday` tinyint DEFAULT NULL, + `roles` json DEFAULT NULL, + `only_new` tinyint(1) NOT NULL DEFAULT 1, + `params` json DEFAULT NULL, + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_notif_settings` (`tenant_id`, `type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `i18n_overlays` ( + `locale` varchar(10) NOT NULL, + `msg_key` varchar(191) NOT NULL, + `msg_value` text NOT NULL, + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`locale`, `msg_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- TOTP (optional MFA) on users +SET @q := ( + SELECT IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled'), + 'SELECT 1', + 'ALTER TABLE users ADD COLUMN totp_enabled TINYINT(1) NOT NULL DEFAULT 0' + ) +); +PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s; + +SET @q := ( + SELECT IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret_enc'), + 'SELECT 1', + 'ALTER TABLE users ADD COLUMN totp_secret_enc VARCHAR(255) NULL DEFAULT NULL' + ) +); +PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s; + +SET @q := ( + SELECT IF( + EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_last_step'), + 'SELECT 1', + 'ALTER TABLE users ADD COLUMN totp_last_step BIGINT NULL DEFAULT NULL' + ) +); +PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/docs/09-harvest-ops.md b/docs/09-harvest-ops.md new file mode 100644 index 0000000..8797246 --- /dev/null +++ b/docs/09-harvest-ops.md @@ -0,0 +1,66 @@ +# 09 — Harvest: mail, notify kernel, ops, i18n, MFA + +This layer was proven in `myCMMS-PHP` and harvested into the pack so every SaaS product gets the same factory pieces without CMMS domain coupling. + +## What ships + +| Piece | Namespace / path | Role | +|---|---|---| +| Mail gateway | `BitsHost\UpmvcSaas\Mail\MailGateway` | Platform SMTP + plan-gated tenant SMTP + `.env` fallback + `email_log` | +| Secret seal | `BitsHost\UpmvcSaas\Security\SecretBox` | AES-GCM for SMTP passwords (dual-unseal; see below) | +| Notify registry | `BitsHost\UpmvcSaas\Notify\NotifyRegistry` | Apps **register** types; pack stays empty | +| Notify gate | `BitsHost\UpmvcSaas\Notify\NotifyGate` | Global pause / kill switch | +| Notify fire | `BitsHost\UpmvcSaas\Notify\Notify` | Instant send with mute + MailGateway | +| Audit | `BitsHost\UpmvcSaas\Audit\AuditLog` | Append-only trail + impersonator | +| TOTP | `BitsHost\UpmvcSaas\Security\Totp` | Platform MFA helper (dual-unseal; see below) | +| Attention | `BitsHost\UpmvcSaas\Ops\Attention` | SMTP / email fails / scheduler / flags | +| Settings | `BitsHost\UpmvcSaas\Ops\PlatformSettings` | Key/value platform flags | +| Heartbeat | `BitsHost\UpmvcSaas\Ops\SchedulerHeartbeat` | Cron liveness | +| i18n catalog | `BitsHost\UpmvcSaas\I18n\Catalog` | File + DB overlays | +| Migration | `002_saas_ops_layer.sql` | Tables for the above | +| API | `Api/Modules/PlatformOps`, `Api/Modules/I18n` | Thin routes apps can override | +| Shell i18n seed | `public/lang/{en,ro}.json` | Minimal chrome keys | + +## App wiring + +```php +use BitsHost\UpmvcSaas\Notify\NotifyRegistry; +use BitsHost\UpmvcSaas\Notify\Notify; + +// boot / service provider +NotifyRegistry::registerMany([ + ['key' => 'invoice_paid', 'zone' => 'tenant', 'kind' => 'instant', 'enabled' => true], +]); + +// domain code +Notify::fire($tenantId, 'invoice_paid', [['id' => 1, 'email' => $to]], function (array $u) { + return ['subject' => 'Paid', 'html' => '

Thanks

']; +}); +``` + +Override `App\Modules\Api\Modules\PlatformOps` in the consuming app to add domain Attention items (plan limits, trials, CMMS metrics, …). + +Harvest kernel classes are **not** `final` — products may thin-extend (e.g. `App\Cmms\MailGateway extends BitsHost\…`) to keep call-site namespaces stable. + +## Seal vs unseal (dual-unseal continuity) + +When secrets moved from myCMMS into the pack, only the **pepper string** changed. Existing DB rows still use CMMS peppers. + +| | Write (`seal`) | Read (`unseal`) | +|---|---|---| +| **SecretBox** | `{APP_KEY\|JWT}\|saas-secret-v1` only | also `|smtp-password-v1` (+ historical insecure-dev candidates) | +| **Totp** | `upmvc-saas-totp\|{JWT_SECRET}` only | also `mycmms-totp\|{JWT_SECRET}` | + +Dual-unseal is a **read bridge**, not dual-write. After a successful unseal + re-save, the next `seal()` stores the pack pepper only. Keep legacy peppers until every known consumer has re-saved; drop them only in a deliberate major with a migration plan. + +## Deliberately not in this harvest + +- Domain notify types (WO/PM/stock, …) +- Payments / billing (optional future pack) +- Full PlatformAdmin UI tabs (apps override shells) +- AI translate (after overlays are used in production) + +## Rule of thumb + +If car-rental and ContaPlus both need it before domain logic → saas pack. +If it pulls a heavy vendor SDK or is vertical-only → new pack or app module. diff --git a/public/lang/en.json b/public/lang/en.json new file mode 100644 index 0000000..606bb75 --- /dev/null +++ b/public/lang/en.json @@ -0,0 +1,22 @@ +{ + "common": { + "loading": "Loading…", + "save": "Save", + "cancel": "Cancel", + "name": "Name", + "email": "Email", + "username": "Username", + "status": "Status", + "language": "Language", + "saved": "Saved", + "failed_load": "Failed to load data.", + "sign_out": "Sign out", + "done": "Done" + }, + "platform": { + "ops_tab": "Ops", + "attention_title": "Needs attention", + "attention_clear": "Nothing needs attention.", + "languages_tab": "Languages" + } +} diff --git a/public/lang/ro.json b/public/lang/ro.json new file mode 100644 index 0000000..7c25f22 --- /dev/null +++ b/public/lang/ro.json @@ -0,0 +1,22 @@ +{ + "common": { + "loading": "Se încarcă…", + "save": "Salvează", + "cancel": "Anulează", + "name": "Nume", + "email": "Email", + "username": "Utilizator", + "status": "Status", + "language": "Limbă", + "saved": "Salvat", + "failed_load": "Nu s-au putut încărca datele.", + "sign_out": "Deconectare", + "done": "Gata" + }, + "platform": { + "ops_tab": "Ops", + "attention_title": "Necesită atenție", + "attention_clear": "Nimic de rezolvat.", + "languages_tab": "Limbi" + } +} diff --git a/src/Modules/Api/Modules/I18n/Controller.php b/src/Modules/Api/Modules/I18n/Controller.php new file mode 100644 index 0000000..f275c17 --- /dev/null +++ b/src/Modules/Api/Modules/I18n/Controller.php @@ -0,0 +1,71 @@ + */ + private const LOCALES = ['en', 'ro']; + + /** GET /api/i18n */ + public function index(): never + { + $this->success([ + 'locales' => self::LOCALES, + 'default' => PlatformSettings::get('default_locale', 'en') ?: 'en', + 'source' => 'file+overlays', + ]); + } + + /** GET /api/i18n/{locale} */ + public function show(): never + { + $locale = strtolower(trim((string) ($_GET['locale'] ?? ''))); + try { + $loaded = null; + $last = null; + foreach ($this->langDirs() as $dir) { + try { + $loaded = Catalog::load($locale, $dir, self::LOCALES); + break; + } catch (\Throwable $e) { + $last = $e; + } + } + if ($loaded === null) { + throw $last ?? new \RuntimeException('Language file not found'); + } + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage(), 404); + } catch (\Throwable $e) { + $this->error($e->getMessage(), 404); + } + + $this->success(array_merge($loaded, [ + 'locales' => self::LOCALES, + 'default' => PlatformSettings::get('default_locale', 'en') ?: 'en', + ])); + } + + /** @return list */ + private function langDirs(): array + { + $dirs = []; + if (defined('SITEPATH')) { + $dirs[] = rtrim((string) SITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'lang'; + } + // Pack root: …/src/Modules/Api/Modules/I18n → 5 levels up + $packRoot = dirname(__DIR__, 5); + $dirs[] = $packRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang'; + // When pack lives under vendor/, prefer the consuming app's public/lang + $vendorParent = dirname($packRoot, 3); // …/vendor/bitshost/upmvc-saas-pack → project + if (is_dir($vendorParent . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang')) { + array_unshift($dirs, $vendorParent . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang'); + } + return array_values(array_unique(array_filter($dirs, 'is_dir'))); + } +} diff --git a/src/Modules/Api/Modules/I18n/Routes/Routes.php b/src/Modules/Api/Modules/I18n/Routes/Routes.php new file mode 100644 index 0000000..38494d0 --- /dev/null +++ b/src/Modules/Api/Modules/I18n/Routes/Routes.php @@ -0,0 +1,15 @@ +addRoute('/api/i18n', Controller::class, 'index', $public, ['GET']); + $router->addParamRoute('/api/i18n/{locale}', Controller::class, 'show', $public, [], ['GET']); + } +} diff --git a/src/Modules/Api/Modules/PlatformOps/Controller.php b/src/Modules/Api/Modules/PlatformOps/Controller.php new file mode 100644 index 0000000..e5062ba --- /dev/null +++ b/src/Modules/Api/Modules/PlatformOps/Controller.php @@ -0,0 +1,123 @@ +user['role'] ?? '') !== 'platform_admin') { + $this->error('Platform admin access required', 403); + } + } + + /** GET /api/admin/ops/attention */ + public function attention(): never + { + $this->requirePlatformAdmin(); + $this->success(['attention' => Attention::collect()]); + } + + /** GET /api/admin/ops/flags */ + public function getFlags(): never + { + $this->requirePlatformAdmin(); + $this->success(['flags' => PlatformSettings::flags()]); + } + + /** POST /api/admin/ops/flags/save */ + public function saveFlags(): never + { + $this->requirePlatformAdmin(); + $body = json_decode(file_get_contents('php://input'), true) ?? []; + $this->success(['flags' => PlatformSettings::saveFlags($body)], 'Flags saved'); + } + + /** POST /api/admin/ops/scheduler/beat */ + public function schedulerBeat(): never + { + $this->requirePlatformAdmin(); + $this->success(['heartbeat' => SchedulerHeartbeat::beat(), 'health' => SchedulerHeartbeat::health()]); + } + + /** GET /api/admin/ops/mfa */ + public function mfaStatus(): never + { + $this->requirePlatformAdmin(); + $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0); + $this->success((new Model())->mfaStatus($uid)); + } + + /** POST /api/admin/ops/mfa/setup */ + public function mfaSetup(): never + { + $this->requirePlatformAdmin(); + $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0); + try { + $this->success((new Model())->mfaSetup($uid)); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage(), 400); + } + } + + /** POST /api/admin/ops/mfa/enable */ + public function mfaEnable(): never + { + $this->requirePlatformAdmin(); + $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0); + $body = $this->requireFields(['code']); + try { + (new Model())->mfaEnable($uid, (string) $body['code']); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage(), 400); + } + $this->success(['enabled' => true], 'MFA enabled'); + } + + /** POST /api/admin/ops/mfa/disable */ + public function mfaDisable(): never + { + $this->requirePlatformAdmin(); + $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0); + $body = $this->requireFields(['password', 'code']); + try { + (new Model())->mfaDisable($uid, (string) $body['password'], (string) $body['code']); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage(), 400); + } + $this->success(['enabled' => false], 'MFA disabled'); + } + + /** GET /api/admin/ops/smtp — platform SMTP public view */ + public function getSmtp(): never + { + $this->requirePlatformAdmin(); + $gw = new MailGateway(); + $this->success([ + 'smtp' => $gw->getPublicPlatform(), + 'route' => $gw->describeRoute(null), + ]); + } + + /** POST /api/admin/ops/smtp/save */ + public function saveSmtp(): never + { + $this->requirePlatformAdmin(); + $body = json_decode(file_get_contents('php://input'), true) ?? []; + try { + $smtp = (new MailGateway())->savePlatform($body); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage(), 400); + } + $this->success(['smtp' => $smtp], 'SMTP saved'); + } +} diff --git a/src/Modules/Api/Modules/PlatformOps/Model.php b/src/Modules/Api/Modules/PlatformOps/Model.php new file mode 100644 index 0000000..78632de --- /dev/null +++ b/src/Modules/Api/Modules/PlatformOps/Model.php @@ -0,0 +1,105 @@ +findAdminMfa($userId); + return [ + 'enabled' => !empty($row['totp_enabled']), + 'pending_setup' => empty($row['totp_enabled']) && !empty($row['totp_secret_enc']), + ]; + } + + public function mfaSetup(int $userId): array + { + $row = $this->findAdminMfa($userId); + if (!$row) { + throw new \InvalidArgumentException('User not found'); + } + if (!empty($row['totp_enabled'])) { + throw new \InvalidArgumentException('Sign-in codes are already on. Turn them off first to move to a new device.'); + } + $secret = Totp::newSecret(); + $this->conn->prepare( + "UPDATE users SET totp_secret_enc = :s, totp_last_step = NULL + WHERE id = :id AND role = 'platform_admin'" + )->execute([':s' => Totp::seal($secret), ':id' => $userId]); + $account = (string) ($row['email'] ?: $row['username']); + return [ + 'secret' => $secret, + 'uri' => Totp::otpauthUri($secret, $account, 'upMVC SaaS'), + ]; + } + + public function mfaEnable(int $userId, string $code): void + { + $row = $this->findAdminMfa($userId); + if (!$row) { + throw new \InvalidArgumentException('User not found'); + } + if (!empty($row['totp_enabled'])) { + return; + } + if (empty($row['totp_secret_enc'])) { + throw new \InvalidArgumentException('Start the setup again'); + } + $secret = Totp::unseal((string) $row['totp_secret_enc']); + if ($secret === null) { + throw new \InvalidArgumentException('Start the setup again'); + } + $last = $row['totp_last_step'] !== null ? (int) $row['totp_last_step'] : null; + $step = Totp::verify($secret, $code, $last); + if ($step === null) { + throw new \InvalidArgumentException('That code is not valid'); + } + $this->conn->prepare( + 'UPDATE users SET totp_enabled = 1, totp_last_step = :step WHERE id = :id' + )->execute([':step' => $step, ':id' => $userId]); + AuditLog::write(null, $userId, 'user', $userId, 'mfa_enabled', []); + } + + public function mfaDisable(int $userId, string $password, string $code = ''): void + { + $row = $this->findAdminMfa($userId); + if (!$row) { + throw new \InvalidArgumentException('User not found'); + } + if (empty($row['totp_enabled'])) { + return; + } + if (!password_verify($password, (string) $row['password'])) { + throw new \InvalidArgumentException('Current password is incorrect'); + } + $secret = Totp::unseal((string) ($row['totp_secret_enc'] ?? '')); + $last = $row['totp_last_step'] !== null ? (int) $row['totp_last_step'] : null; + if ($secret === null || Totp::verify($secret, $code, $last) === null) { + throw new \InvalidArgumentException('That code is not valid'); + } + $this->conn->prepare( + 'UPDATE users SET totp_enabled = 0, totp_secret_enc = NULL, totp_last_step = NULL WHERE id = :id' + )->execute([':id' => $userId]); + AuditLog::write(null, $userId, 'user', $userId, 'mfa_disabled', []); + } + + /** @return array|false */ + public function findAdminMfa(int $userId): array|false + { + try { + $stmt = $this->conn->prepare( + "SELECT id, username, email, password, totp_enabled, totp_secret_enc, totp_last_step + FROM users WHERE id = :id AND role = 'platform_admin' LIMIT 1" + ); + $stmt->execute([':id' => $userId]); + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: false; + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Modules/Api/Modules/PlatformOps/Routes/Routes.php b/src/Modules/Api/Modules/PlatformOps/Routes/Routes.php new file mode 100644 index 0000000..4decf3d --- /dev/null +++ b/src/Modules/Api/Modules/PlatformOps/Routes/Routes.php @@ -0,0 +1,23 @@ +addRoute('/api/admin/ops/attention', Controller::class, 'attention', $auth, ['GET']); + $router->addRoute('/api/admin/ops/flags', Controller::class, 'getFlags', $auth, ['GET']); + $router->addRoute('/api/admin/ops/flags/save', Controller::class, 'saveFlags', $auth, ['POST']); + $router->addRoute('/api/admin/ops/scheduler/beat', Controller::class, 'schedulerBeat', $auth, ['POST']); + $router->addRoute('/api/admin/ops/mfa', Controller::class, 'mfaStatus', $auth, ['GET']); + $router->addRoute('/api/admin/ops/mfa/setup', Controller::class, 'mfaSetup', $auth, ['POST']); + $router->addRoute('/api/admin/ops/mfa/enable', Controller::class, 'mfaEnable', $auth, ['POST']); + $router->addRoute('/api/admin/ops/mfa/disable', Controller::class, 'mfaDisable', $auth, ['POST']); + $router->addRoute('/api/admin/ops/smtp', Controller::class, 'getSmtp', $auth, ['GET']); + $router->addRoute('/api/admin/ops/smtp/save', Controller::class, 'saveSmtp', $auth, ['POST']); + } +} diff --git a/src/Modules/Mail/MailController.php b/src/Modules/Mail/MailController.php index 60c50da..3bcc283 100644 --- a/src/Modules/Mail/MailController.php +++ b/src/Modules/Mail/MailController.php @@ -2,58 +2,48 @@ namespace App\Modules\Mail; -use PHPMailer\PHPMailer\PHPMailer; -use PHPMailer\PHPMailer\Exception; +use BitsHost\UpmvcSaas\Mail\MailGateway; +/** + * Thin mail helpers — prefer MailGateway for product sends (logged + routed). + * Kept for backward-compatible module discovery. + */ class MailController { - private function createMailer(): PHPMailer - { - $mail = new PHPMailer(true); - $mail->CharSet = 'UTF-8'; - $mail->isSMTP(); - $mail->Host = $_ENV['MAIL_HOST'] ?? ''; - $mail->SMTPAuth = true; - $mail->Username = $_ENV['MAIL_USERNAME'] ?? ''; - $mail->Password = $_ENV['MAIL_PASSWORD'] ?? ''; - $mail->SMTPSecure = $_ENV['MAIL_ENCRYPTION'] ?? 'ssl'; - $mail->Port = (int) ($_ENV['MAIL_PORT'] ?? 465); - $mail->isHTML(true); - return $mail; + /** + * @return array{status:string,transport?:string,error?:string,engine?:string} + */ + public function send( + string $to, + string $subject, + string $html, + string $template = 'generic', + ?int $tenantId = null, + ?string $type = null + ): array { + return (new MailGateway())->send($to, $subject, $html, $template, $tenantId, $type); } + /** @deprecated Use send() / MailGateway — kept for older call sites */ public function sendMailByPHPMailer($to, $from, $subject, $message): bool { - $mail = $this->createMailer(); - $mail->setFrom($from); - $mail->addAddress($to); - $mail->addReplyTo($from); - $mail->Subject = $subject; - $mail->Body = $message; - - if (!$mail->send()) { - echo json_encode(['error' => 'Mailer Error: ' . $mail->ErrorInfo]); - exit; - } - - return true; + $result = (new MailGateway())->send( + (string) $to, + (string) $subject, + (string) $message, + 'legacy', + null, + null, + null, + true + ); + return ($result['status'] ?? '') === 'sent'; } + /** @deprecated Use MailGateway */ public function sendMailByPHPMailerAddAttachment($to, $from, $subject, $message, $attachmentroute): bool { - $mail = $this->createMailer(); - $mail->setFrom($from); - $mail->addAddress($to); - $mail->addReplyTo($from); - $mail->Subject = $subject; - $mail->Body = $message; - $mail->addAttachment($attachmentroute); - - if (!$mail->send()) { - echo json_encode(['error' => 'Mailer Error: ' . $mail->ErrorInfo]); - exit; - } - - return true; + // Attachments: use PHPMailer directly in the app for now; gateway is HTML body focused. + return $this->sendMailByPHPMailer($to, $from, $subject, $message); } } diff --git a/src/Saas/Audit/AuditLog.php b/src/Saas/Audit/AuditLog.php new file mode 100644 index 0000000..93efe46 --- /dev/null +++ b/src/Saas/Audit/AuditLog.php @@ -0,0 +1,78 @@ + $user JWT / session user claims + * @return array{userId:?int,impersonatorId:?int} + */ + public static function actorsFromUser(array $user): array + { + $uid = (int) ($user['sub'] ?? $user['id'] ?? 0); + $imp = 0; + if (!empty($user['impersonated']) || !empty($user['impersonator_id'])) { + $imp = (int) ($user['impersonator_id'] ?? 0); + } + return [ + 'userId' => $uid > 0 ? $uid : null, + 'impersonatorId' => $imp > 0 ? $imp : null, + ]; + } + + /** + * @param array $details + */ + public static function write( + ?int $tenantId, + ?int $userId, + string $entity, + ?int $entityId, + string $action, + array $details = [], + ?int $impersonatorId = null + ): void { + if ($entity === '' || $action === '') { + return; + } + $tid = ($tenantId !== null && $tenantId > 0) ? $tenantId : null; + try { + $pdo = (new Database())->getConnection(); + $stmt = $pdo->prepare( + 'INSERT INTO audit_log (tenant_id, user_id, impersonator_id, entity, entity_id, action, details) + VALUES (:tid, :uid, :imp, :entity, :entity_id, :action, :details)' + ); + $stmt->execute([ + ':tid' => $tid, + ':uid' => $userId ?: null, + ':imp' => $impersonatorId ?: null, + ':entity' => mb_substr($entity, 0, 64), + ':entity_id' => $entityId, + ':action' => mb_substr($action, 0, 64), + ':details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } catch (\Throwable) { + // Never break the primary action for audit failures + } + } + + /** Convenience: stamp impersonator from JWT claims when present. */ + public static function writeAs( + array $user, + ?int $tenantId, + string $entity, + ?int $entityId, + string $action, + array $details = [] + ): void { + $a = self::actorsFromUser($user); + self::write($tenantId, $a['userId'], $entity, $entityId, $action, $details, $a['impersonatorId']); + } +} diff --git a/src/Saas/I18n/Catalog.php b/src/Saas/I18n/Catalog.php new file mode 100644 index 0000000..3b38544 --- /dev/null +++ b/src/Saas/I18n/Catalog.php @@ -0,0 +1,88 @@ + $catalog + * @param array $overlays + * @return array + */ + public static function applyOverlays(array $catalog, array $overlays): array + { + foreach ($overlays as $path => $value) { + $parts = explode('.', (string) $path); + if ($parts === [] || $parts[0] === '') { + continue; + } + $ref = &$catalog; + $last = count($parts) - 1; + foreach ($parts as $i => $p) { + if ($i === $last) { + $ref[$p] = $value; + } else { + if (!isset($ref[$p]) || !is_array($ref[$p])) { + $ref[$p] = []; + } + $ref = &$ref[$p]; + } + } + unset($ref); + } + return $catalog; + } + + /** @return array */ + public static function overlayMap(string $locale): array + { + try { + $pdo = (new Database())->getConnection(); + $stmt = $pdo->prepare( + 'SELECT msg_key, msg_value FROM i18n_overlays WHERE locale = :l' + ); + $stmt->execute([':l' => $locale]); + $map = []; + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { + $map[(string) $row['msg_key']] = (string) $row['msg_value']; + } + return $map; + } catch (\Throwable) { + return []; + } + } + + /** + * @param list $locales + * @return array{locale:string,source:string,overlays:int,catalog:array,version:string} + */ + public static function load(string $locale, string $langDir, array $locales = ['en', 'ro'], bool $mergeOverlays = true): array + { + $locale = strtolower(trim($locale)); + if (!in_array($locale, $locales, true)) { + throw new \InvalidArgumentException('Unsupported locale'); + } + $path = rtrim($langDir, '/\\') . DIRECTORY_SEPARATOR . $locale . '.json'; + if (!is_readable($path)) { + throw new \RuntimeException('Language file not found'); + } + $catalog = json_decode((string) file_get_contents($path), true); + if (!is_array($catalog)) { + throw new \RuntimeException('Invalid language catalog'); + } + $overlays = $mergeOverlays ? self::overlayMap($locale) : []; + if ($overlays !== []) { + $catalog = self::applyOverlays($catalog, $overlays); + } + return [ + 'locale' => $locale, + 'source' => $overlays === [] ? 'file' : 'file+overlays', + 'overlays' => count($overlays), + 'catalog' => $catalog, + 'version' => (string) filemtime($path) . ':' . count($overlays), + ]; + } +} diff --git a/src/Saas/Mail/MailGateway.php b/src/Saas/Mail/MailGateway.php new file mode 100644 index 0000000..b56fcbc --- /dev/null +++ b/src/Saas/Mail/MailGateway.php @@ -0,0 +1,649 @@ +resolveConfig($tenantId) !== null; + } + + public function isNetworkConfigured(): bool + { + return $this->resolveConfig(null, false) !== null; + } + + /** + * Active delivery route for UI / diagnostics. + * + * @return array{engine:string,mode:string,allowed_custom:bool,network:bool,from_email:?string,from_name:?string} + */ + public function describeRoute(?int $tenantId = null): array + { + $allowed = $tenantId ? $this->tenantHasCustomSmtp($tenantId) : false; + $cfg = $this->resolveConfig($tenantId); + $network = $this->isNetworkConfigured(); + $mode = 'none'; + if ($cfg) { + $mode = ($cfg['transport'] ?? '') === 'tenant' ? 'tenant' : 'platform'; + if (($cfg['transport'] ?? '') === 'platform' && !empty($cfg['from_env'])) { + $mode = 'env'; + } + } + return [ + 'engine' => 'phpmailer', + 'mode' => $mode, + 'allowed_custom' => $allowed, + 'network' => $network, + 'from_email' => $cfg['from_email'] ?? null, + 'from_name' => $cfg['from_name'] ?? null, + ]; + } + + /** + * @return array{status:string,transport?:string,error?:string,engine?:string} + */ + public function send( + string $to, + string $subject, + string $html, + string $template = 'generic', + ?int $tenantId = null, + ?string $type = null, + ?string $text = null, + bool $forcePlatform = false + ): array { + $to = trim($to); + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { + return $this->finish($tenantId, $to, $subject, $template, $type, 'none', 'failed', 'Invalid recipient'); + } + if (self::isTestAddress($to)) { + return $this->finish($tenantId, $to, $subject, $template, $type, 'none', 'skipped', 'Reserved test address'); + } + + $cfg = $this->resolveConfig($forcePlatform ? null : $tenantId, !$forcePlatform); + if ($cfg === null) { + return $this->finish($tenantId, $to, $subject, $template, $type, 'none', 'skipped', 'No SMTP server configured'); + } + + try { + $mail = $this->buildMailer($cfg); + $mail->setFrom($cfg['from_email'], $cfg['from_name'] ?: 'App'); + $mail->addAddress($to); + if (!empty($cfg['reply_to'])) { + $mail->addReplyTo($cfg['reply_to']); + } + $mail->Subject = $subject; + $mail->Body = $html; + $mail->AltBody = $text ?? strip_tags($html); + $mail->send(); + $out = $this->finish($tenantId, $to, $subject, $template, $type, $cfg['transport'], 'sent', null); + $out['engine'] = 'phpmailer'; + return $out; + } catch (\Throwable $e) { + $out = $this->finish( + $tenantId, + $to, + $subject, + $template, + $type, + $cfg['transport'] ?? 'none', + 'failed', + mb_substr($e->getMessage(), 0, 500) + ); + $out['engine'] = 'phpmailer'; + return $out; + } + } + + /** + * Test SMTP: uses form override when host present, else saved row (platform or tenant). + * @param array|null $override + * @return array{ok:bool,to:string,error?:string} + */ + public function test(?array $override, string $to, bool $updateSavedMeta = true, ?int $tenantId = null): array + { + $to = trim($to); + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'to' => $to, 'error' => 'Invalid test recipient']; + } + if ($tenantId && !$this->tenantHasCustomSmtp($tenantId)) { + return ['ok' => false, 'to' => $to, 'error' => 'Custom SMTP is not included in your plan']; + } + + $saved = $tenantId ? $this->tenantRow($tenantId) : $this->platformRow(); + $transport = $tenantId ? 'tenant' : 'platform'; + try { + $cfg = $override && !empty($override['host']) + ? $this->normalizeInput($override, $saved) + : $this->rowToConfig($saved); + if ($cfg === null) { + return ['ok' => false, 'to' => $to, 'error' => 'Enter SMTP settings first']; + } + $cfg['transport'] = $transport; + + $mail = $this->buildMailer($cfg); + $mail->smtpConnect(); + $mail->smtpClose(); + + if (!self::isTestAddress($to)) { + $mail = $this->buildMailer($cfg); + $mail->setFrom($cfg['from_email'], $cfg['from_name'] ?: 'App'); + $mail->addAddress($to); + $mail->Subject = 'SMTP test'; + $scope = $tenantId ? 'workspace' : 'platform'; + $mail->Body = '

SMTP test from App ' . $scope . ' gateway.

Server: ' + . htmlspecialchars($cfg['host'] . ':' . $cfg['port']) . '

'; + $mail->AltBody = 'SMTP test from App ' . $scope . ' gateway.'; + $mail->send(); + } + + if ($updateSavedMeta && $saved && empty($override['host'])) { + $this->markTest((int) $saved['id'], true, null); + } + $this->writeLog($tenantId, $to, 'SMTP test', 'smtp_test', null, $transport, 'sent', null); + return ['ok' => true, 'to' => $to]; + } catch (\Throwable $e) { + $err = mb_substr($e->getMessage(), 0, 500); + if ($tenantId) { + // Don't leak raw SMTP banners to tenant admins + $err = 'Connection or authentication failed. Check host, port, and credentials.'; + } + if ($updateSavedMeta && $saved && empty($override['host'] ?? null)) { + $this->markTest((int) $saved['id'], false, $err); + } + $this->writeLog($tenantId, $to, 'SMTP test', 'smtp_test', null, $transport, 'failed', $err); + return ['ok' => false, 'to' => $to, 'error' => $err]; + } + } + + /** @return array|null public view */ + public function getPublicPlatform(): ?array + { + $row = $this->platformRow(); + return $row ? $this->toPublic($row) : null; + } + + /** @param array $body */ + public function savePlatform(array $body): array + { + $cur = $this->platformRow(); + $v = $this->normalizeInput($body, $cur); + if ($cur) { + $stmt = $this->conn->prepare( + "UPDATE smtp_settings SET + host=:host, port=:port, secure=:secure, username=:username, password_enc=:password_enc, + from_email=:from_email, from_name=:from_name, reply_to=:reply_to, enabled=:enabled, + last_test_at=NULL, last_test_ok=NULL, last_error=NULL + WHERE id=:id AND tenant_id IS NULL" + ); + $stmt->execute([ + ':id' => $cur['id'], + ':host' => $v['host'], + ':port' => $v['port'], + ':secure' => $v['secure'], + ':username' => $v['username'], + ':password_enc' => $v['password_enc'], + ':from_email' => $v['from_email'], + ':from_name' => $v['from_name'], + ':reply_to' => $v['reply_to'], + ':enabled' => $v['enabled'], + ]); + } else { + $stmt = $this->conn->prepare( + "INSERT INTO smtp_settings + (tenant_id, host, port, secure, username, password_enc, from_email, from_name, reply_to, enabled) + VALUES + (NULL, :host, :port, :secure, :username, :password_enc, :from_email, :from_name, :reply_to, :enabled)" + ); + $stmt->execute([ + ':host' => $v['host'], + ':port' => $v['port'], + ':secure' => $v['secure'], + ':username' => $v['username'], + ':password_enc' => $v['password_enc'], + ':from_email' => $v['from_email'], + ':from_name' => $v['from_name'], + ':reply_to' => $v['reply_to'], + ':enabled' => $v['enabled'], + ]); + } + return $this->getPublicPlatform() ?? []; + } + + public function deletePlatform(): bool + { + return $this->conn->exec('DELETE FROM smtp_settings WHERE tenant_id IS NULL') !== false; + } + + public function getPublicTenant(int $tenantId): ?array + { + $row = $this->tenantRow($tenantId); + return $row ? $this->toPublic($row) : null; + } + + public function saveTenant(int $tenantId, array $body): array + { + if (!$this->tenantHasCustomSmtp($tenantId)) { + throw new \InvalidArgumentException('Custom SMTP is not included in your plan'); + } + $cur = $this->tenantRow($tenantId); + $v = $this->normalizeInput($body, $cur); + if ($cur) { + $stmt = $this->conn->prepare( + "UPDATE smtp_settings SET + host=:host, port=:port, secure=:secure, username=:username, password_enc=:password_enc, + from_email=:from_email, from_name=:from_name, reply_to=:reply_to, enabled=:enabled, + last_test_at=NULL, last_test_ok=NULL, last_error=NULL + WHERE id=:id AND tenant_id=:tid" + ); + $stmt->execute([ + ':id' => $cur['id'], + ':tid' => $tenantId, + ':host' => $v['host'], + ':port' => $v['port'], + ':secure' => $v['secure'], + ':username' => $v['username'], + ':password_enc' => $v['password_enc'], + ':from_email' => $v['from_email'], + ':from_name' => $v['from_name'], + ':reply_to' => $v['reply_to'], + ':enabled' => $v['enabled'], + ]); + } else { + $stmt = $this->conn->prepare( + "INSERT INTO smtp_settings + (tenant_id, host, port, secure, username, password_enc, from_email, from_name, reply_to, enabled) + VALUES + (:tid, :host, :port, :secure, :username, :password_enc, :from_email, :from_name, :reply_to, :enabled)" + ); + $stmt->execute([ + ':tid' => $tenantId, + ':host' => $v['host'], + ':port' => $v['port'], + ':secure' => $v['secure'], + ':username' => $v['username'], + ':password_enc' => $v['password_enc'], + ':from_email' => $v['from_email'], + ':from_name' => $v['from_name'], + ':reply_to' => $v['reply_to'], + ':enabled' => $v['enabled'], + ]); + } + return $this->getPublicTenant($tenantId) ?? []; + } + + public function deleteTenant(int $tenantId): bool + { + $stmt = $this->conn->prepare('DELETE FROM smtp_settings WHERE tenant_id = :tid'); + return $stmt->execute([':tid' => $tenantId]); + } + + /** Tenant email page payload — network + optional custom SMTP. */ + public function tenantEmailBundle(int $tenantId): array + { + $platform = $this->platformRow(); + $emailsStmt = $this->conn->prepare( + "SELECT id, to_email, subject, template, transport, status, error, created_at + FROM email_log WHERE tenant_id = :tid + ORDER BY created_at DESC LIMIT 25" + ); + $emailsStmt->execute([':tid' => $tenantId]); + $route = $this->describeRoute($tenantId); + $tenantSmtp = $this->getPublicTenant($tenantId); + // Hide stored custom SMTP from UI when plan does not allow it + if (!$route['allowed_custom']) { + $tenantSmtp = null; + } + return [ + 'engine' => 'phpmailer', + 'route' => $route, + 'allowed' => $route['allowed_custom'], + 'smtp' => $tenantSmtp, + 'platform' => ($platform && (int) $platform['enabled'] === 1) + ? [ + 'from_email' => $platform['from_email'], + 'from_name' => $platform['from_name'], + 'enabled' => true, + ] + : ($this->isNetworkConfigured() + ? ['from_email' => (string) Environment::get('MAIL_FROM_ADDRESS', ''), 'from_name' => (string) Environment::get('MAIL_FROM_NAME', 'App'), 'enabled' => true, 'via_env' => true] + : null), + 'emails' => $emailsStmt->fetchAll(\PDO::FETCH_ASSOC), + ]; + } + + public function tenantHasCustomSmtp(int $tenantId): bool + { + $stmt = $this->conn->prepare( + "SELECT t.features AS tenant_features, p.features AS plan_features + FROM tenants t + LEFT JOIN plans p ON p.id = t.plan_id + WHERE t.id = :tid AND t.deleted_at IS NULL LIMIT 1" + ); + $stmt->execute([':tid' => $tenantId]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + if (!$row) { + return false; + } + $tf = json_decode($row['tenant_features'] ?? '{}', true) ?: []; + $pf = json_decode($row['plan_features'] ?? '{}', true) ?: []; + if (array_key_exists('custom_smtp', $tf)) { + return !empty($tf['custom_smtp']); + } + return !empty($pf['custom_smtp']); + } + + /** @return array{emails:list,summary:array} */ + public function listLog(?string $status = 'all', int $limit = 100, ?int $tenantId = null): array + { + $limit = max(1, min(500, $limit)); + $sql = "SELECT e.*, t.name AS tenant_name + FROM email_log e + LEFT JOIN tenants t ON t.id = e.tenant_id + WHERE 1=1"; + $params = []; + if ($tenantId !== null) { + $sql .= ' AND e.tenant_id = :tid'; + $params[':tid'] = $tenantId; + } + if ($status && $status !== 'all') { + $sql .= ' AND e.status = :st'; + $params[':st'] = $status; + } + $sql .= " ORDER BY e.created_at DESC LIMIT {$limit}"; + $stmt = $this->conn->prepare($sql); + $stmt->execute($params); + $emails = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + if ($tenantId !== null) { + $sumStmt = $this->conn->prepare( + "SELECT + SUM(status='sent') AS sent, + SUM(status='failed') AS failed, + SUM(status='skipped') AS skipped, + SUM(transport='platform') AS via_platform, + SUM(transport='tenant') AS via_tenant + FROM email_log + WHERE tenant_id = :tid AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)" + ); + $sumStmt->execute([':tid' => $tenantId]); + $sum = $sumStmt->fetch(\PDO::FETCH_ASSOC) ?: []; + } else { + $sum = $this->conn->query( + "SELECT + SUM(status='sent') AS sent, + SUM(status='failed') AS failed, + SUM(status='skipped') AS skipped, + SUM(transport='platform') AS via_platform, + SUM(transport='tenant') AS via_tenant + FROM email_log + WHERE created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)" + )->fetch(\PDO::FETCH_ASSOC) ?: []; + } + + return [ + 'emails' => $emails, + 'summary' => [ + 'sent' => (int) ($sum['sent'] ?? 0), + 'failed' => (int) ($sum['failed'] ?? 0), + 'skipped' => (int) ($sum['skipped'] ?? 0), + 'via_platform' => (int) ($sum['via_platform'] ?? 0), + 'via_tenant' => (int) ($sum['via_tenant'] ?? 0), + 'smtp_configured' => $this->isConfigured($tenantId), + ], + ]; + } + + /** @param array $body @param array|false|null $cur */ + private function normalizeInput(array $body, $cur): array + { + $host = strtolower(trim((string) ($body['host'] ?? ''))); + if ($host === '' || !preg_match('/^[a-z0-9.-]+$/', $host) || strlen($host) > 253) { + throw new \InvalidArgumentException('SMTP host is invalid'); + } + $port = (int) ($body['port'] ?? 587); + if (!in_array($port, self::PORTS, true)) { + throw new \InvalidArgumentException('Port must be one of ' . implode(', ', self::PORTS)); + } + $from = trim((string) ($body['from_email'] ?? $body['fromEmail'] ?? '')); + if ($from === '' || !filter_var($from, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('From email is invalid'); + } + $username = trim((string) ($body['username'] ?? '')); + $password = array_key_exists('password', $body) ? (string) $body['password'] : ''; + $enc = $cur['password_enc'] ?? null; + if ($password !== '') { + if (strlen($password) > 500) { + throw new \InvalidArgumentException('Password is too long'); + } + $enc = \BitsHost\UpmvcSaas\Security\SecretBox::seal($password); + } elseif ($username === '') { + $enc = null; + } + $reply = trim((string) ($body['reply_to'] ?? $body['replyTo'] ?? '')); + if ($reply !== '' && !filter_var($reply, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Reply-To is invalid'); + } + $secure = array_key_exists('secure', $body) + ? (!empty($body['secure']) ? 1 : 0) + : ($port === 465 ? 1 : 0); + + return [ + 'host' => $host, + 'port' => $port, + 'secure' => $secure, + 'username' => $username !== '' ? $username : null, + 'password_enc' => $enc, + 'from_email' => $from, + 'from_name' => mb_substr(trim((string) ($body['from_name'] ?? $body['fromName'] ?? '')), 0, 120) ?: null, + 'reply_to' => $reply !== '' ? $reply : null, + 'enabled' => array_key_exists('enabled', $body) ? (!empty($body['enabled']) ? 1 : 0) : 1, + ]; + } + + private function platformRow(): array|false + { + return $this->conn->query( + 'SELECT * FROM smtp_settings WHERE tenant_id IS NULL LIMIT 1' + )->fetch(\PDO::FETCH_ASSOC); + } + + private function tenantRow(int $tenantId): array|false + { + $stmt = $this->conn->prepare( + 'SELECT * FROM smtp_settings WHERE tenant_id = :tid LIMIT 1' + ); + $stmt->execute([':tid' => $tenantId]); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + /** + * @param bool $allowTenantSmtp when false, skip tenant custom SMTP (network-only) + * @return array|null + */ + private function resolveConfig(?int $tenantId = null, bool $allowTenantSmtp = true): ?array + { + // Custom SMTP only when plan includes custom_smtp and settings are enabled + if ($allowTenantSmtp && $tenantId && $this->tenantHasCustomSmtp($tenantId)) { + $t = $this->tenantRow($tenantId); + if ($t && (int) $t['enabled'] === 1) { + $cfg = $this->rowToConfig($t); + if ($cfg) { + $cfg['transport'] = 'tenant'; + return $cfg; + } + } + } + + // Network / platform gateway (shared for all tenants) + $row = $this->platformRow(); + if ($row && (int) $row['enabled'] === 1) { + $cfg = $this->rowToConfig($row); + if ($cfg) { + $cfg['transport'] = 'platform'; + return $cfg; + } + } + + // .env fallback (still "network" for tenants) + $host = (string) Environment::get('MAIL_HOST', ''); + if ($host === '') { + return null; + } + return [ + 'host' => $host, + 'port' => (int) Environment::get('MAIL_PORT', 587), + 'secure' => strtolower((string) Environment::get('MAIL_ENCRYPTION', 'tls')) === 'ssl', + 'username' => (string) Environment::get('MAIL_USERNAME', ''), + 'password' => (string) Environment::get('MAIL_PASSWORD', ''), + 'from_email' => (string) Environment::get('MAIL_FROM_ADDRESS', 'noreply@example.com'), + 'from_name' => (string) Environment::get('MAIL_FROM_NAME', 'App'), + 'reply_to' => null, + 'transport' => 'platform', + 'from_env' => true, + 'encryption' => (string) Environment::get('MAIL_ENCRYPTION', 'tls'), + ]; + } + + /** @param array|false|null $row */ + private function rowToConfig($row): ?array + { + if (!$row || empty($row['host'])) { + return null; + } + return [ + 'host' => $row['host'], + 'port' => (int) $row['port'], + 'secure' => (int) $row['secure'] === 1, + 'username' => (string) ($row['username'] ?? ''), + 'password' => (string) (\BitsHost\UpmvcSaas\Security\SecretBox::unseal($row['password_enc'] ?? null) ?? ''), + 'from_email' => $row['from_email'], + 'from_name' => $row['from_name'] ?? '', + 'reply_to' => $row['reply_to'] ?? null, + 'encryption' => ((int) $row['secure'] === 1) ? 'ssl' : 'tls', + ]; + } + + /** @param array $row */ + private function toPublic(array $row): array + { + return [ + 'host' => $row['host'], + 'port' => (int) $row['port'], + 'secure' => (int) $row['secure'] === 1, + 'username' => $row['username'], + 'has_password' => !empty($row['password_enc']), + 'from_email' => $row['from_email'], + 'from_name' => $row['from_name'], + 'reply_to' => $row['reply_to'], + 'enabled' => (int) $row['enabled'] === 1, + 'last_test_at' => $row['last_test_at'], + 'last_test_ok' => $row['last_test_ok'] === null ? null : ((int) $row['last_test_ok'] === 1), + 'last_error' => $row['last_error'], + 'updated_at' => $row['updated_at'] ?? null, + ]; + } + + /** @param array $cfg */ + private function buildMailer(array $cfg): PHPMailer + { + $mail = new PHPMailer(true); + $mail->CharSet = 'UTF-8'; + $mail->isSMTP(); + $mail->Host = $cfg['host']; + $mail->Port = (int) $cfg['port']; + $mail->SMTPAuth = ($cfg['username'] ?? '') !== ''; + if ($mail->SMTPAuth) { + $mail->Username = $cfg['username']; + $mail->Password = $cfg['password'] ?? ''; + } + $enc = $cfg['encryption'] ?? (!empty($cfg['secure']) ? 'ssl' : 'tls'); + if ($enc === 'ssl' || !empty($cfg['secure'])) { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } else { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->SMTPAutoTLS = true; + } + $mail->Timeout = 12; + $mail->isHTML(true); + return $mail; + } + + private function markTest(int $id, bool $ok, ?string $error): void + { + $stmt = $this->conn->prepare( + 'UPDATE smtp_settings SET last_test_at=NOW(), last_test_ok=:ok, last_error=:err WHERE id=:id' + ); + $stmt->execute([':ok' => $ok ? 1 : 0, ':err' => $error, ':id' => $id]); + } + + /** @return array{status:string,transport?:string,error?:string} */ + private function finish( + ?int $tenantId, + string $to, + string $subject, + string $template, + ?string $type, + string $transport, + string $status, + ?string $error + ): array { + $this->writeLog($tenantId, $to, $subject, $template, $type, $transport, $status, $error); + $out = ['status' => $status, 'transport' => $transport]; + if ($error) { + $out['error'] = $error; + } + return $out; + } + + private function writeLog( + ?int $tenantId, + string $to, + string $subject, + string $template, + ?string $type, + string $transport, + string $status, + ?string $error + ): void { + $stmt = $this->conn->prepare( + "INSERT INTO email_log (tenant_id, to_email, subject, template, transport, status, error, type) + VALUES (:tid, :to, :subj, :tpl, :tr, :st, :err, :type)" + ); + $stmt->execute([ + ':tid' => $tenantId, + ':to' => mb_substr($to, 0, 254), + ':subj' => mb_substr($subject, 0, 300), + ':tpl' => mb_substr($template, 0, 80), + ':tr' => $transport, + ':st' => $status, + ':err' => $error, + ':type' => $type, + ]); + } +} diff --git a/src/Saas/Notify/Notify.php b/src/Saas/Notify/Notify.php new file mode 100644 index 0000000..c047f57 --- /dev/null +++ b/src/Saas/Notify/Notify.php @@ -0,0 +1,96 @@ + $users + * @param callable $build fn(array $user): array{subject:string,html:string,text?:string} + * @return array{sent:int,skipped:int,reason?:string,error?:string} + */ + public static function fire( + ?int $tenantId, + string $type, + array $users, + callable $build, + ?int $excludeUserId = null + ): array { + try { + if (!NotifyRegistry::isValid($type)) { + return ['sent' => 0, 'skipped' => 0, 'reason' => 'unknown_type']; + } + if (NotifyGate::isPaused($type)) { + return ['sent' => 0, 'skipped' => count($users), 'reason' => 'paused']; + } + + $mail = new MailGateway(); + $sent = 0; + $skipped = 0; + foreach ($users as $user) { + $uid = isset($user['id']) ? (int) $user['id'] : 0; + if ($excludeUserId && $uid === $excludeUserId) { + $skipped++; + continue; + } + if ($uid > 0 && self::isMuted($uid, $type)) { + $skipped++; + continue; + } + $email = trim((string) ($user['email'] ?? '')); + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $skipped++; + continue; + } + $payload = $build($user); + $subject = (string) ($payload['subject'] ?? ''); + $html = (string) ($payload['html'] ?? ''); + if ($subject === '' || $html === '') { + $skipped++; + continue; + } + $result = $mail->send( + $email, + $subject, + $html, + 'notify', + $tenantId, + $type, + isset($payload['text']) ? (string) $payload['text'] : null + ); + if (($result['status'] ?? '') === 'sent') { + $sent++; + } else { + $skipped++; + } + } + return ['sent' => $sent, 'skipped' => $skipped]; + } catch (\Throwable $e) { + error_log('[saas-notify] ' . $type . ' ' . $e->getMessage()); + return ['sent' => 0, 'skipped' => 0, 'error' => $e->getMessage()]; + } + } + + public static function isMuted(int $userId, string $type): bool + { + try { + $pdo = (new Database())->getConnection(); + $stmt = $pdo->prepare( + 'SELECT 1 FROM notification_mutes WHERE user_id = :u AND mute_key = :k LIMIT 1' + ); + $stmt->execute([':u' => $userId, ':k' => $type]); + return (bool) $stmt->fetchColumn(); + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Saas/Notify/NotifyGate.php b/src/Saas/Notify/NotifyGate.php new file mode 100644 index 0000000..11aa44a --- /dev/null +++ b/src/Saas/Notify/NotifyGate.php @@ -0,0 +1,46 @@ +getConnection(); + $stmt = $pdo->prepare( + 'SELECT paused FROM notification_global WHERE type = :t LIMIT 1' + ); + $stmt->execute([':t' => $type]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + return $row && (int) $row['paused'] === 1; + } catch (\Throwable) { + return false; + } + } + + public static function setPaused(string $type, bool $paused, ?string $reason = null, ?int $by = null): void + { + if (!NotifyRegistry::isValid($type)) { + throw new \InvalidArgumentException('Unknown notification type'); + } + $pdo = (new Database())->getConnection(); + $stmt = $pdo->prepare( + "INSERT INTO notification_global (type, paused, reason, updated_by) + VALUES (:t, :p, :r, :by) + ON DUPLICATE KEY UPDATE paused = VALUES(paused), reason = VALUES(reason), updated_by = VALUES(updated_by)" + ); + $stmt->execute([ + ':t' => $type, + ':p' => $paused ? 1 : 0, + ':r' => $reason, + ':by' => $by, + ]); + } +} diff --git a/src/Saas/Notify/NotifyRegistry.php b/src/Saas/Notify/NotifyRegistry.php new file mode 100644 index 0000000..55b49b7 --- /dev/null +++ b/src/Saas/Notify/NotifyRegistry.php @@ -0,0 +1,77 @@ + 'invoice_paid', 'zone' => 'tenant', 'kind' => 'instant', + * 'audience' => 'roles', 'roles' => ['tenant_owner'], 'enabled' => true, + * 'label' => '…', 'help' => '…', // prefer i18n keys in UI + * ] + */ +final class NotifyRegistry +{ + /** @var array> */ + private static array $types = []; + + /** @param array $type */ + public static function register(array $type): void + { + $key = (string) ($type['key'] ?? ''); + if ($key === '' || !preg_match('/^[a-z][a-z0-9_]{1,62}$/', $key)) { + throw new \InvalidArgumentException('Invalid notification type key'); + } + $type['zone'] = $type['zone'] ?? 'tenant'; + $type['kind'] = $type['kind'] ?? 'instant'; + self::$types[$key] = $type; + } + + /** @param list> $types */ + public static function registerMany(array $types): void + { + foreach ($types as $t) { + self::register($t); + } + } + + public static function clear(): void + { + self::$types = []; + } + + public static function isValid(string $type): bool + { + return isset(self::$types[$type]); + } + + /** @return array|null */ + public static function get(string $type): ?array + { + return self::$types[$type] ?? null; + } + + /** @return list> */ + public static function all(): array + { + return array_values(self::$types); + } + + /** @return list> */ + public static function forZone(string $zone): array + { + return array_values(array_filter( + self::$types, + static fn(array $t): bool => ($t['zone'] ?? '') === $zone + )); + } + + /** @return list */ + public static function keys(): array + { + return array_keys(self::$types); + } +} diff --git a/src/Saas/Ops/Attention.php b/src/Saas/Ops/Attention.php new file mode 100644 index 0000000..e2d180f --- /dev/null +++ b/src/Saas/Ops/Attention.php @@ -0,0 +1,81 @@ + + */ + public static function collect(): array + { + $items = []; + + $mail = new MailGateway(); + if (!$mail->isNetworkConfigured()) { + $items[] = [ + 'tone' => 'bad', + 'key' => 'smtp', + 'tab' => 'email', + 'text' => 'Platform network SMTP is not configured — outbound mail will be skipped.', + ]; + } + + try { + $pdo = (new \App\Etc\Database())->getConnection(); + $failed = (int) $pdo->query( + "SELECT COUNT(*) FROM email_log + WHERE status = 'failed' AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)" + )->fetchColumn(); + if ($failed > 0) { + $items[] = [ + 'tone' => 'bad', + 'key' => 'email_failed', + 'tab' => 'email', + 'meta' => ['count' => $failed], + 'text' => "{$failed} email(s) failed in the last 24 hours.", + ]; + } + } catch (\Throwable) { + } + + $sched = SchedulerHeartbeat::health(); + if ($sched['stale']) { + $items[] = [ + 'tone' => 'bad', + 'key' => 'scheduler', + 'tab' => 'ops', + 'meta' => ['last_run' => $sched['last_run']], + 'text' => $sched['last_run'] + ? 'Scheduler heartbeat is stale — check cron.' + : 'No scheduler heartbeat yet — digests will not run until cron is configured.', + ]; + } + + $flags = PlatformSettings::flags(); + if ($flags['maintenance_mode']) { + $items[] = [ + 'tone' => 'warn', + 'key' => 'maintenance', + 'tab' => 'ops', + 'text' => 'Maintenance mode is ON.', + ]; + } + if (!$flags['signup_enabled']) { + $items[] = [ + 'tone' => 'signal', + 'key' => 'signup_off', + 'tab' => 'ops', + 'text' => 'Public signup is disabled.', + ]; + } + + return $items; + } +} diff --git a/src/Saas/Ops/PlatformSettings.php b/src/Saas/Ops/PlatformSettings.php new file mode 100644 index 0000000..570c53c --- /dev/null +++ b/src/Saas/Ops/PlatformSettings.php @@ -0,0 +1,55 @@ +getConnection(); + $stmt = $pdo->prepare( + 'SELECT setting_value FROM platform_settings WHERE setting_key = :k LIMIT 1' + ); + $stmt->execute([':k' => $key]); + $v = $stmt->fetchColumn(); + return $v === false ? $default : (string) $v; + } catch (\Throwable) { + return $default; + } + } + + public static function set(string $key, string $value): void + { + $pdo = (new Database())->getConnection(); + $stmt = $pdo->prepare( + "INSERT INTO platform_settings (setting_key, setting_value) + VALUES (:k, :v) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)" + ); + $stmt->execute([':k' => $key, ':v' => $value]); + } + + /** @return array{signup_enabled:bool,maintenance_mode:bool} */ + public static function flags(): array + { + return [ + 'signup_enabled' => self::get('signup_enabled', '1') !== '0', + 'maintenance_mode' => self::get('maintenance_mode', '0') === '1', + ]; + } + + /** @param array{signup_enabled?:bool,maintenance_mode?:bool} $flags */ + public static function saveFlags(array $flags): array + { + if (array_key_exists('signup_enabled', $flags)) { + self::set('signup_enabled', !empty($flags['signup_enabled']) ? '1' : '0'); + } + if (array_key_exists('maintenance_mode', $flags)) { + self::set('maintenance_mode', !empty($flags['maintenance_mode']) ? '1' : '0'); + } + return self::flags(); + } +} diff --git a/src/Saas/Ops/SchedulerHeartbeat.php b/src/Saas/Ops/SchedulerHeartbeat.php new file mode 100644 index 0000000..b24ffcc --- /dev/null +++ b/src/Saas/Ops/SchedulerHeartbeat.php @@ -0,0 +1,35 @@ + null, 'stale' => true, 'age_seconds' => null]; + } + $ts = strtotime($raw); + if ($ts === false) { + return ['last_run' => $raw, 'stale' => true, 'age_seconds' => null]; + } + $age = time() - $ts; + return [ + 'last_run' => $raw, + 'stale' => $age > $staleAfterSeconds, + 'age_seconds' => $age, + ]; + } +} diff --git a/src/Saas/Security/SecretBox.php b/src/Saas/Security/SecretBox.php new file mode 100644 index 0000000..252ab37 --- /dev/null +++ b/src/Saas/Security/SecretBox.php @@ -0,0 +1,80 @@ + + */ + private static function keyCandidates(): array + { + $src = (string) ( + getenv('APP_KEY') + ?: ($_ENV['APP_KEY'] ?? '') + ?: getenv('JWT_SECRET') + ?: ($_ENV['JWT_SECRET'] ?? '') + ?: Environment::get('APP_KEY', '') + ?: Environment::get('JWT_SECRET', '') + ?: 'upmvc-saas-dev-insecure-key-change-me' + ); + $keys = [ + hash('sha256', $src . '|saas-secret-v1', true), + hash('sha256', $src . '|smtp-password-v1', true), // myCMMS-PHP legacy + ]; + // Also try insecure defaults if APP_KEY differed historically + $keys[] = hash('sha256', 'upmvc-saas-dev-insecure-key-change-me|saas-secret-v1', true); + $keys[] = hash('sha256', 'mycmms-dev-insecure-key-change-me|smtp-password-v1', true); + return array_values(array_unique($keys)); + } +} diff --git a/src/Saas/Security/Totp.php b/src/Saas/Security/Totp.php new file mode 100644 index 0000000..6265ab6 --- /dev/null +++ b/src/Saas/Security/Totp.php @@ -0,0 +1,156 @@ + $secret, + 'issuer' => $issuer, + 'algorithm' => 'SHA1', + 'digits' => 6, + 'period' => self::PERIOD, + ], '', '&', PHP_QUERY_RFC3986); + return "otpauth://totp/{$label}?{$q}"; + } + + public static function stepAt(?int $ms = null): int + { + $ms ??= (int) floor(microtime(true) * 1000); + return (int) floor($ms / 1000 / self::PERIOD); + } + + public static function codeAt(string $secret, ?int $ms = null): string + { + return self::hotp($secret, self::stepAt($ms)); + } + + /** + * @return int|null matched time-step, or null if invalid + */ + public static function verify(string $secret, string $code, ?int $lastStep = null): ?int + { + $c = preg_replace('/\s+/', '', $code) ?? ''; + if (!preg_match('/^\d{6}$/', $c)) { + return null; + } + $now = self::stepAt(); + foreach ([$now - 1, $now, $now + 1] as $s) { + if ($lastStep !== null && $s <= $lastStep) { + continue; + } + $expect = self::hotp($secret, $s); + if (hash_equals($expect, $c)) { + return $s; + } + } + return null; + } + + public static function seal(string $secret): string + { + $key = self::sealKey(); + $iv = random_bytes(16); + $cipher = openssl_encrypt($secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + if ($cipher === false) { + throw new \RuntimeException('Failed to seal TOTP secret'); + } + return self::SEAL_PREFIX . base64_encode($iv . $cipher); + } + + public static function unseal(string $sealed): ?string + { + if (!str_starts_with($sealed, self::SEAL_PREFIX)) { + return null; + } + $raw = base64_decode(substr($sealed, strlen(self::SEAL_PREFIX)), true); + if ($raw === false || strlen($raw) < 17) { + return null; + } + $iv = substr($raw, 0, 16); + $cipher = substr($raw, 16); + foreach (self::sealKeyCandidates() as $key) { + $plain = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + if ($plain !== false && $plain !== '') { + return $plain; + } + } + return null; + } + + private static function sealKey(): string + { + return self::sealKeyCandidates()[0]; + } + + /** @return list */ + private static function sealKeyCandidates(): array + { + // Prefer process env so unit tests / CLI do not force-load .env + $secret = (string) (getenv('JWT_SECRET') ?: ($_ENV['JWT_SECRET'] ?? '') ?: Environment::get('JWT_SECRET', '')); + if ($secret === '') { + throw new \RuntimeException('JWT_SECRET is required to seal TOTP secrets'); + } + return [ + hash('sha256', 'upmvc-saas-totp|' . $secret, true), + hash('sha256', 'mycmms-totp|' . $secret, true), // myCMMS-PHP legacy + ]; + } + + private static function hotp(string $secret, int $counter): string + { + $key = self::decodeBase32($secret); + // 8-byte big-endian counter (high 32 bits always 0 for practical steps) + $msg = pack('NN', ($counter >> 32) & 0xffffffff, $counter & 0xffffffff); + $h = hash_hmac('sha1', $msg, $key, true); + $o = ord($h[strlen($h) - 1]) & 0x0f; + $bin = unpack('N', substr($h, $o, 4)); + $code = ($bin[1] & 0x7fffffff) % 1000000; + return str_pad((string) $code, 6, '0', STR_PAD_LEFT); + } + + private static function decodeBase32(string $secret): string + { + $secret = strtoupper(rtrim($secret, '=')); + $bits = ''; + for ($i = 0, $n = strlen($secret); $i < $n; $i++) { + $idx = strpos(self::B32, $secret[$i]); + if ($idx === false) { + continue; + } + $bits .= str_pad(decbin($idx), 5, '0', STR_PAD_LEFT); + } + $bytes = ''; + for ($i = 0; $i + 8 <= strlen($bits); $i += 8) { + $bytes .= chr(bindec(substr($bits, $i, 8))); + } + return $bytes; + } +} diff --git a/tests/Unit/HarvestKernelTest.php b/tests/Unit/HarvestKernelTest.php new file mode 100644 index 0000000..206106d --- /dev/null +++ b/tests/Unit/HarvestKernelTest.php @@ -0,0 +1,150 @@ +assertMatchesRegularExpression('/^[A-Z2-7]+$/', $secret); + $code = Totp::codeAt($secret); + $this->assertMatchesRegularExpression('/^\d{6}$/', $code); + $step = Totp::verify($secret, $code); + $this->assertNotNull($step); + $this->assertNull(Totp::verify($secret, '000000', $step)); + } + + public function testTotpSealRoundTrip(): void + { + // Seal needs JWT_SECRET — set a test value + $_ENV['JWT_SECRET'] = 'harvest-test-secret-not-for-prod'; + putenv('JWT_SECRET=harvest-test-secret-not-for-prod'); + $secret = Totp::newSecret(); + $sealed = Totp::seal($secret); + $this->assertStringStartsWith('v1:', $sealed); + $this->assertSame($secret, Totp::unseal($sealed)); + } + + /** Pack unseal must still read myCMMS-era SMTP seals (`|smtp-password-v1`). */ + public function testSecretBoxUnsealsLegacySmtpPepper(): void + { + $src = 'harvest-dual-unseal-app-key'; + $_ENV['APP_KEY'] = $src; + putenv('APP_KEY=' . $src); + unset($_ENV['JWT_SECRET']); + putenv('JWT_SECRET'); + + $plain = 'smtp-password-legacy-proof'; + $key = hash('sha256', $src . '|smtp-password-v1', true); + $iv = random_bytes(12); + $tag = ''; + $ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); + $this->assertNotFalse($ct); + $blob = 'v1:' . base64_encode($iv . $tag . $ct); + + $this->assertSame($plain, SecretBox::unseal($blob)); + // New writes use pack pepper only + $this->assertSame($plain, SecretBox::unseal(SecretBox::seal($plain))); + } + + /** Pack unseal must still read myCMMS-era TOTP seals (`mycmms-totp|`). */ + public function testTotpUnsealsLegacyMycmmsPepper(): void + { + $jwt = 'harvest-dual-unseal-jwt-secret'; + $_ENV['JWT_SECRET'] = $jwt; + putenv('JWT_SECRET=' . $jwt); + + $secret = Totp::newSecret(); + $key = hash('sha256', 'mycmms-totp|' . $jwt, true); + $iv = random_bytes(16); + $cipher = openssl_encrypt($secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + $this->assertNotFalse($cipher); + $sealed = 'v1:' . base64_encode($iv . $cipher); + + $this->assertSame($secret, Totp::unseal($sealed)); + } + + public function testNotifyRegistryRegisterAndZone(): void + { + NotifyRegistry::register([ + 'key' => 'invoice_paid', + 'zone' => 'tenant', + 'kind' => 'instant', + ]); + NotifyRegistry::register([ + 'key' => 'mail_failures', + 'zone' => 'platform', + 'kind' => 'digest', + ]); + $this->assertTrue(NotifyRegistry::isValid('invoice_paid')); + $this->assertCount(1, NotifyRegistry::forZone('tenant')); + $this->assertCount(1, NotifyRegistry::forZone('platform')); + $this->assertSame(['invoice_paid', 'mail_failures'], NotifyRegistry::keys()); + } + + public function testNotifyRegistryRejectsBadKey(): void + { + $this->expectException(\InvalidArgumentException::class); + NotifyRegistry::register(['key' => 'Bad-Key!']); + } + + public function testAuditActorsFromUser(): void + { + $a = AuditLog::actorsFromUser([ + 'sub' => 10, + 'impersonated' => true, + 'impersonator_id' => 2, + ]); + $this->assertSame(10, $a['userId']); + $this->assertSame(2, $a['impersonatorId']); + } + + public function testCatalogApplyOverlays(): void + { + $base = [ + 'common' => ['save' => 'Save', 'name' => 'Name'], + 'platform' => ['ops_tab' => 'Ops'], + ]; + $out = Catalog::applyOverlays($base, [ + 'common.save' => 'Salvează', + 'platform.ops_tab' => 'Operațiuni', + 'platform.new_key' => 'New', + ]); + $this->assertSame('Salvează', $out['common']['save']); + $this->assertSame('Name', $out['common']['name']); + $this->assertSame('Operațiuni', $out['platform']['ops_tab']); + $this->assertSame('New', $out['platform']['new_key']); + } + + public function testCatalogLoadFromPackLang(): void + { + $dir = dirname(__DIR__, 2) . '/public/lang'; + $loaded = Catalog::load('en', $dir, ['en', 'ro'], false); + $this->assertSame('en', $loaded['locale']); + $this->assertSame('file', $loaded['source']); + $this->assertIsArray($loaded['catalog']['common'] ?? null); + } + + public function testMigration002ExistsAndMentionsOpsTables(): void + { + $sql = (string) file_get_contents(dirname(__DIR__, 2) . '/database/migrations/002_saas_ops_layer.sql'); + foreach (['smtp_settings', 'email_log', 'audit_log', 'notification_global', 'i18n_overlays', 'totp_enabled'] as $needle) { + $this->assertStringContainsString($needle, $sql); + } + } +} diff --git a/tests/Unit/PackStructureTest.php b/tests/Unit/PackStructureTest.php index faaf3c5..1f658c7 100644 --- a/tests/Unit/PackStructureTest.php +++ b/tests/Unit/PackStructureTest.php @@ -131,6 +131,15 @@ public function testMigrationsCreateTheSaasTables(): void "Migrations must create the $table table" ); } + + // Harvest ops layer (002) + foreach (['platform_settings', 'smtp_settings', 'email_log', 'audit_log', 'i18n_overlays'] as $table) { + $this->assertMatchesRegularExpression( + '/CREATE TABLE (IF NOT EXISTS )?`?' . $table . '`?/i', + $sql, + "Ops migrations must create the $table table" + ); + } } public function testSchemaAndSeedShipForNonMigrationInstalls(): void