Framework updates
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -260,7 +260,9 @@ class Maint_Migrate extends Command
|
||||
}
|
||||
|
||||
// Run normalize_schema BEFORE migrations to fix existing tables
|
||||
$requiredColumnsArgs = $is_development ? [] : ['--production' => true];
|
||||
// Use --production flag if not using snapshots (framework-only or non-development mode)
|
||||
$use_snapshot = $is_development && !$is_framework_only;
|
||||
$requiredColumnsArgs = $use_snapshot ? [] : ['--production' => true];
|
||||
|
||||
$this->info("\n Pre-migration normalization (fixing existing tables)...\n");
|
||||
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Console\Command;
|
||||
use Symfony\Component\Process\Process;
|
||||
use App\RSpade\Core\Debug\Debugger;
|
||||
use App\RSpade\Core\Models\Login_User_Model;
|
||||
use App\RSpade\Core\Portal\Portal_User_Model;
|
||||
|
||||
/**
|
||||
* RSX Route Debug Command
|
||||
@@ -156,7 +157,9 @@ class Route_Debug_Command extends Command
|
||||
{--console-list : Alias for --console-log to display all console output}
|
||||
{--screenshot-width= : Screenshot width (px or preset: mobile, iphone-mobile, tablet, desktop-small, desktop-medium, desktop-large). Defaults to 1920}
|
||||
{--screenshot-path= : Path to save screenshot file (triggers screenshot capture, max height 5000px)}
|
||||
{--dump-dimensions= : Add data-dimensions attribute to elements matching selector (for layout debugging)}';
|
||||
{--dump-dimensions= : Add data-dimensions attribute to elements matching selector (for layout debugging)}
|
||||
{--portal : Test portal routes (uses /_portal/ prefix and portal authentication)}
|
||||
{--portal-user= : Test as specific portal user ID or email (requires --portal)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -213,15 +216,46 @@ class Route_Debug_Command extends Command
|
||||
$url = '/' . $url;
|
||||
}
|
||||
|
||||
// Get portal mode options
|
||||
$portal_mode = $this->option('portal');
|
||||
$portal_user_input = $this->option('portal-user');
|
||||
|
||||
// Validate portal options
|
||||
if ($portal_user_input && !$portal_mode) {
|
||||
$this->error('--portal-user requires --portal flag');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Normalize URL for portal mode (strip /_portal/ prefix if present)
|
||||
if ($portal_mode && str_starts_with($url, '/_portal')) {
|
||||
$url = substr($url, 8); // Remove '/_portal'
|
||||
if ($url === '' || $url === false) {
|
||||
$url = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Get user ID from options (accepts ID or email)
|
||||
$user_id = $this->option('user');
|
||||
if ($user_id !== null) {
|
||||
if ($portal_mode) {
|
||||
$this->error('Use --portal-user instead of --user when --portal flag is set');
|
||||
return 1;
|
||||
}
|
||||
$user_id = $this->resolve_user($user_id);
|
||||
if ($user_id === null) {
|
||||
return 1; // Error already displayed
|
||||
}
|
||||
}
|
||||
|
||||
// Get portal user ID from options (accepts ID or email)
|
||||
$portal_user_id = null;
|
||||
if ($portal_user_input !== null) {
|
||||
$portal_user_id = $this->resolve_portal_user($portal_user_input);
|
||||
if ($portal_user_id === null) {
|
||||
return 1; // Error already displayed
|
||||
}
|
||||
}
|
||||
|
||||
// Get log flag
|
||||
$show_log = $this->option('log');
|
||||
|
||||
@@ -384,16 +418,26 @@ class Route_Debug_Command extends Command
|
||||
// This prevents unauthorized requests from hijacking sessions via headers
|
||||
$dev_auth_token = null;
|
||||
if ($user_id) {
|
||||
$dev_auth_token = $this->generate_dev_auth_token($url, $user_id);
|
||||
$dev_auth_token = $this->generate_dev_auth_token($url, $user_id, false);
|
||||
} elseif ($portal_user_id) {
|
||||
$dev_auth_token = $this->generate_dev_auth_token($url, $portal_user_id, true);
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
$command_args = ['node', $playwright_script, $url];
|
||||
|
||||
if ($portal_mode) {
|
||||
$command_args[] = '--portal';
|
||||
}
|
||||
|
||||
if ($user_id) {
|
||||
$command_args[] = "--user={$user_id}";
|
||||
}
|
||||
|
||||
if ($portal_user_id) {
|
||||
$command_args[] = "--portal-user={$portal_user_id}";
|
||||
}
|
||||
|
||||
if ($dev_auth_token) {
|
||||
$command_args[] = "--dev-auth-token={$dev_auth_token}";
|
||||
}
|
||||
@@ -560,6 +604,15 @@ class Route_Debug_Command extends Command
|
||||
$this->line(' php artisan rsx:debug /admin --user=admin@example.com # Test as user by email');
|
||||
$this->line('');
|
||||
|
||||
$this->comment('PORTAL ROUTES:');
|
||||
$this->line(' php artisan rsx:debug /dashboard --portal --portal-user=1');
|
||||
$this->line(' # Test portal as user ID 1');
|
||||
$this->line(' php artisan rsx:debug /_portal/dashboard --portal --portal-user=1');
|
||||
$this->line(' # Same (/_portal/ prefix stripped)');
|
||||
$this->line(' php artisan rsx:debug /mail --portal --portal-user=client@example.com');
|
||||
$this->line(' # Test portal as user by email');
|
||||
$this->line('');
|
||||
|
||||
$this->comment('TESTING RSX JAVASCRIPT (use return or console.log for output):');
|
||||
$this->line(' php artisan rsx:debug / --eval="return typeof Rsx_Time" # Check if class exists');
|
||||
$this->line(' php artisan rsx:debug / --eval="return Rsx_Time.now_iso()" # Get current time');
|
||||
@@ -677,6 +730,43 @@ class Route_Debug_Command extends Command
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve portal user identifier to user ID
|
||||
*
|
||||
* Accepts either a numeric user ID or an email address.
|
||||
* Validates that the portal user exists in the database.
|
||||
*
|
||||
* @param string $user_input Portal user ID or email address
|
||||
* @return int|null User ID or null if not found (error already displayed)
|
||||
*/
|
||||
protected function resolve_portal_user(string $user_input): ?int
|
||||
{
|
||||
// Check if input is an email address
|
||||
if (str_contains($user_input, '@')) {
|
||||
$portal_user = Portal_User_Model::where('email', $user_input)->first();
|
||||
if (!$portal_user) {
|
||||
$this->error("Portal user not found: {$user_input}");
|
||||
return null;
|
||||
}
|
||||
return $portal_user->id;
|
||||
}
|
||||
|
||||
// Input is a user ID - validate it exists
|
||||
if (!ctype_digit($user_input)) {
|
||||
$this->error("Invalid portal user identifier: {$user_input} (must be numeric ID or email address)");
|
||||
return null;
|
||||
}
|
||||
|
||||
$user_id = (int) $user_input;
|
||||
$portal_user = Portal_User_Model::find($user_id);
|
||||
if (!$portal_user) {
|
||||
$this->error("Portal user ID not found: {$user_id}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a signed dev auth token for Playwright requests
|
||||
*
|
||||
@@ -686,9 +776,10 @@ class Route_Debug_Command extends Command
|
||||
*
|
||||
* @param string $url The URL being tested
|
||||
* @param int $user_id The user ID to authenticate as
|
||||
* @param bool $is_portal Whether this is a portal user (vs main site user)
|
||||
* @return string The signed token
|
||||
*/
|
||||
protected function generate_dev_auth_token(string $url, int $user_id): string
|
||||
protected function generate_dev_auth_token(string $url, int $user_id, bool $is_portal = false): string
|
||||
{
|
||||
$app_key = config('app.key');
|
||||
if (!$app_key) {
|
||||
@@ -700,6 +791,7 @@ class Route_Debug_Command extends Command
|
||||
$payload = json_encode([
|
||||
'url' => $url,
|
||||
'user_id' => $user_id,
|
||||
'portal' => $is_portal,
|
||||
]);
|
||||
|
||||
// Sign with HMAC-SHA256
|
||||
|
||||
@@ -6,6 +6,8 @@ use RuntimeException;
|
||||
use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors;
|
||||
use App\RSpade\Core\Bundle\BundleCompiler;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Portal\Portal_Session;
|
||||
use App\RSpade\Core\Portal\Rsx_Portal;
|
||||
use App\RSpade\Core\Rsx;
|
||||
use App\RSpade\Core\Session\Session;
|
||||
|
||||
@@ -261,10 +263,12 @@ abstract class Rsx_Bundle_Abstract
|
||||
|
||||
// Add runtime data
|
||||
$rsxapp_data['debug'] = Rsx::is_development();
|
||||
$rsxapp_data['current_controller'] = Rsx::get_current_controller();
|
||||
$rsxapp_data['current_action'] = Rsx::get_current_action();
|
||||
$rsxapp_data['is_auth'] = Session::is_logged_in();
|
||||
$rsxapp_data['is_spa'] = Rsx::is_spa();
|
||||
// Use portal-specific methods when in portal context
|
||||
$is_portal = Rsx_Portal::is_portal_request();
|
||||
$rsxapp_data['is_portal'] = $is_portal;
|
||||
$rsxapp_data['current_controller'] = $is_portal ? Rsx_Portal::get_current_controller() : Rsx::get_current_controller();
|
||||
$rsxapp_data['current_action'] = $is_portal ? Rsx_Portal::get_current_action() : Rsx::get_current_action();
|
||||
$rsxapp_data['is_spa'] = $is_portal ? Rsx_Portal::is_spa() : Rsx::is_spa();
|
||||
|
||||
// Enable ajax batching in debug/production modes, disable in development for easier debugging
|
||||
$rsxapp_data['ajax_batching'] = !Rsx::is_development();
|
||||
@@ -274,9 +278,18 @@ abstract class Rsx_Bundle_Abstract
|
||||
$rsxapp_data['params'] = $current_params ?? [];
|
||||
|
||||
// Add user, site, and csrf data from session
|
||||
$rsxapp_data['user'] = Session::get_user();
|
||||
$rsxapp_data['site'] = Session::get_site();
|
||||
$rsxapp_data['csrf'] = Session::get_csrf_token();
|
||||
// Use Portal_Session for portal requests, Session for regular requests
|
||||
if ($is_portal) {
|
||||
$rsxapp_data['is_auth'] = Portal_Session::is_logged_in();
|
||||
$rsxapp_data['user'] = Portal_Session::get_portal_user();
|
||||
$rsxapp_data['site'] = Portal_Session::get_site();
|
||||
$rsxapp_data['csrf'] = Portal_Session::get_csrf_token();
|
||||
} else {
|
||||
$rsxapp_data['is_auth'] = Session::is_logged_in();
|
||||
$rsxapp_data['user'] = Session::get_user();
|
||||
$rsxapp_data['site'] = Session::get_site();
|
||||
$rsxapp_data['csrf'] = Session::get_csrf_token();
|
||||
}
|
||||
|
||||
// Add browser error logging flag (enabled in both dev and production)
|
||||
if (config('rsx.log_browser_errors', false)) {
|
||||
|
||||
@@ -19,6 +19,8 @@ use App\RSpade\Core\Debug\Debugger;
|
||||
use App\RSpade\Core\Dispatch\AssetHandler;
|
||||
use App\RSpade\Core\Dispatch\RouteResolver;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Portal\Portal_Dispatcher;
|
||||
use App\RSpade\Core\Portal\Rsx_Portal;
|
||||
use App\RSpade\Core\Rsx;
|
||||
|
||||
/**
|
||||
@@ -83,6 +85,12 @@ class Dispatcher
|
||||
|
||||
$request = $request ?? request();
|
||||
|
||||
// Check if this is a portal request - delegate to Portal_Dispatcher
|
||||
if (Rsx_Portal::is_portal_request()) {
|
||||
console_debug('DISPATCH', 'Portal request detected, delegating to Portal_Dispatcher');
|
||||
return Portal_Dispatcher::dispatch($url, $method, $extra_params, $request);
|
||||
}
|
||||
|
||||
// Custom session is handled by Session::init() in RsxAuth
|
||||
|
||||
// Check if this is an asset request
|
||||
|
||||
@@ -30,50 +30,42 @@ use App\RSpade\Core\Files\File_Storage_Model;
|
||||
* provides the basic structure for categorizing uploaded files.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_ Database type hints - do not edit manually
|
||||
* Generated on: 2026-01-29 08:25:50
|
||||
* Table: _file_attachments
|
||||
*
|
||||
* @property int $id
|
||||
* @property mixed $key
|
||||
* @property int $file_storage_id
|
||||
* @property mixed $file_name
|
||||
* @property mixed $file_extension
|
||||
* @property int $file_type_id
|
||||
* @property int $width
|
||||
* @property int $height
|
||||
* @property int $duration
|
||||
* @property bool $is_animated
|
||||
* @property int $frame_count
|
||||
* @property int $fileable_type
|
||||
* @property int $fileable_id
|
||||
* @property mixed $fileable_category
|
||||
* @property mixed $fileable_type_meta
|
||||
* @property int $fileable_order
|
||||
* _AUTO_GENERATED_
|
||||
* @property integer $id
|
||||
* @property string $key
|
||||
* @property integer $file_storage_id
|
||||
* @property string $file_name
|
||||
* @property string $file_extension
|
||||
* @property integer $file_type_id
|
||||
* @property integer $width
|
||||
* @property integer $height
|
||||
* @property integer $duration
|
||||
* @property boolean $is_animated
|
||||
* @property integer $frame_count
|
||||
* @property integer $fileable_type
|
||||
* @property integer $fileable_id
|
||||
* @property string $fileable_category
|
||||
* @property string $fileable_type_meta
|
||||
* @property integer $fileable_order
|
||||
* @property string $fileable_meta
|
||||
* @property int $site_id
|
||||
* @property mixed $session_id
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
*
|
||||
* @property-read string $file_type_id__label
|
||||
* @property-read string $file_type_id__constant
|
||||
*
|
||||
* @method static array file_type_id__enum() Get all enum definitions with full metadata
|
||||
* @method static array file_type_id__enum_select() Get selectable items for dropdowns
|
||||
* @method static array file_type_id__enum_labels() Get simple id => label map
|
||||
* @method static array file_type_id__enum_ids() Get array of all valid enum IDs
|
||||
*
|
||||
* @property integer $site_id
|
||||
* @property string $session_id
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property integer $created_by
|
||||
* @property integer $updated_by
|
||||
* @method static mixed file_type_id_enum()
|
||||
* @method static mixed file_type_id_enum_select()
|
||||
* @method static mixed file_type_id_enum_ids()
|
||||
* @property-read mixed $file_type_id_constant
|
||||
* @property-read mixed $file_type_id_label
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
{
|
||||
/**
|
||||
* _AUTO_GENERATED_ Enum constants
|
||||
*/
|
||||
{
|
||||
/** __AUTO_GENERATED: */
|
||||
const FILE_TYPE_IMAGE = 1;
|
||||
const FILE_TYPE_ANIMATED_IMAGE = 2;
|
||||
const FILE_TYPE_VIDEO = 3;
|
||||
@@ -81,9 +73,6 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
|
||||
const FILE_TYPE_TEXT = 5;
|
||||
const FILE_TYPE_DOCUMENT = 6;
|
||||
const FILE_TYPE_OTHER = 7;
|
||||
|
||||
/** __AUTO_GENERATED: */
|
||||
|
||||
/** __/AUTO_GENERATED */
|
||||
|
||||
/**
|
||||
|
||||
405
app/RSpade/Core/Js/Rsx_Portal.js
Executable file
405
app/RSpade/Core/Js/Rsx_Portal.js
Executable file
@@ -0,0 +1,405 @@
|
||||
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
|
||||
|
||||
/**
|
||||
* Rsx_Portal - Client Portal JavaScript Runtime Utilities
|
||||
*
|
||||
* Provides static utility methods for client portal JavaScript code including
|
||||
* route generation and portal context detection.
|
||||
*
|
||||
* Key differences from Rsx:
|
||||
* - Route() prepends portal domain/prefix
|
||||
* - Portal-specific context detection
|
||||
* - Simpler (no event system, fewer utilities)
|
||||
*
|
||||
* Usage Examples:
|
||||
* ```javascript
|
||||
* // Check if in portal context
|
||||
* if (Rsx_Portal.is_portal()) { ... }
|
||||
*
|
||||
* // Route generation (applies portal prefix/domain)
|
||||
* const url = Rsx_Portal.Route('Portal_Dashboard_Action');
|
||||
* // Development: /_portal/dashboard
|
||||
* // Production: /dashboard (on portal domain)
|
||||
*
|
||||
* // Route with parameters
|
||||
* const url = Rsx_Portal.Route('Portal_Project_View_Action', 123);
|
||||
* // Development: /_portal/projects/123
|
||||
* ```
|
||||
*
|
||||
* @static
|
||||
* @global
|
||||
*/
|
||||
class Rsx_Portal {
|
||||
/**
|
||||
* URL prefix for portal when no dedicated domain configured
|
||||
* Must match PHP Rsx_Portal::URL_PREFIX
|
||||
*/
|
||||
static URL_PREFIX = '/_portal';
|
||||
|
||||
/**
|
||||
* Storage for portal route definitions loaded from bundles
|
||||
*/
|
||||
static _routes = {};
|
||||
|
||||
/**
|
||||
* Cached portal context detection result
|
||||
*/
|
||||
static _is_portal = null;
|
||||
|
||||
// =========================================================================
|
||||
// Configuration Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get the configured portal domain
|
||||
*
|
||||
* @returns {string|null} Domain if configured, null otherwise
|
||||
*/
|
||||
static get_domain() {
|
||||
return window.rsxapp?.portal?.domain || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the portal URL prefix (used when no domain configured)
|
||||
*
|
||||
* @returns {string} The prefix, defaults to '/_portal'
|
||||
*/
|
||||
static get_prefix() {
|
||||
return window.rsxapp?.portal?.prefix || Rsx_Portal.URL_PREFIX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if portal is using a dedicated domain (vs URL prefix)
|
||||
*
|
||||
* @returns {boolean} True if dedicated domain is configured
|
||||
*/
|
||||
static has_dedicated_domain() {
|
||||
return !!Rsx_Portal.get_domain();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Portal Context Detection
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if currently in portal context
|
||||
*
|
||||
* @returns {boolean} True if this is a portal page
|
||||
*/
|
||||
static is_portal() {
|
||||
if (Rsx_Portal._is_portal !== null) {
|
||||
return Rsx_Portal._is_portal;
|
||||
}
|
||||
|
||||
// Check rsxapp flag (set by portal bootstrap)
|
||||
Rsx_Portal._is_portal = !!window.rsxapp?.is_portal;
|
||||
|
||||
return Rsx_Portal._is_portal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current portal user
|
||||
*
|
||||
* @returns {Object|null} Portal user data or null if not logged in
|
||||
*/
|
||||
static user() {
|
||||
if (!Rsx_Portal.is_portal()) {
|
||||
return null;
|
||||
}
|
||||
return window.rsxapp?.user || null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route Generation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate URL for a portal route
|
||||
*
|
||||
* Similar to Rsx.Route() but:
|
||||
* - Returns URLs with portal domain or prefix
|
||||
* - Only works with portal routes
|
||||
*
|
||||
* Usage examples:
|
||||
* ```javascript
|
||||
* // Portal action route
|
||||
* const url = Rsx_Portal.Route('Portal_Dashboard_Action');
|
||||
* // Development: /_portal/dashboard
|
||||
*
|
||||
* // Route with integer parameter (sets 'id')
|
||||
* const url = Rsx_Portal.Route('Portal_Project_View_Action', 123);
|
||||
* // Development: /_portal/projects/123
|
||||
*
|
||||
* // Route with named parameters
|
||||
* const url = Rsx_Portal.Route('Portal_Project_View_Action', {id: 123, tab: 'files'});
|
||||
* // Development: /_portal/projects/123?tab=files
|
||||
*
|
||||
* // Placeholder route
|
||||
* const url = Rsx_Portal.Route('Future_Portal_Feature::#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param {string} action Controller class, SPA action, or "Class::method"
|
||||
* @param {number|Object} [params=null] Route parameters
|
||||
* @returns {string} The generated URL (includes portal prefix in dev mode)
|
||||
*/
|
||||
static Route(action, params = null) {
|
||||
// Parse action into class_name and action_name
|
||||
let class_name, action_name;
|
||||
if (action.includes('::')) {
|
||||
[class_name, action_name] = action.split('::', 2);
|
||||
} else {
|
||||
class_name = action;
|
||||
action_name = 'index';
|
||||
}
|
||||
|
||||
// Normalize params to object
|
||||
let params_obj = {};
|
||||
if (typeof params === 'number') {
|
||||
params_obj = { id: params };
|
||||
} else if (typeof params === 'string' && /^\d+$/.test(params)) {
|
||||
params_obj = { id: parseInt(params, 10) };
|
||||
} else if (params && typeof params === 'object') {
|
||||
params_obj = params;
|
||||
} else if (params !== null && params !== undefined) {
|
||||
throw new Error('Params must be number, object, or null');
|
||||
}
|
||||
|
||||
// Placeholder route: action starts with # means unimplemented/scaffolding
|
||||
if (action_name.startsWith('#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Check if route exists in portal route definitions
|
||||
let pattern = null;
|
||||
|
||||
if (Rsx_Portal._routes[class_name] && Rsx_Portal._routes[class_name][action_name]) {
|
||||
const route_patterns = Rsx_Portal._routes[class_name][action_name];
|
||||
pattern = Rsx_Portal._select_best_route_pattern(route_patterns, params_obj);
|
||||
|
||||
if (!pattern) {
|
||||
const route_list = route_patterns.join(', ');
|
||||
throw new Error(
|
||||
`No suitable portal route found for ${class_name}::${action_name} with provided parameters. ` +
|
||||
`Available routes: ${route_list}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Not found in portal routes - try SPA action route
|
||||
pattern = Rsx_Portal._try_spa_action_route(class_name, params_obj);
|
||||
|
||||
if (!pattern) {
|
||||
throw new Error(
|
||||
`Portal route not found for ${action}. ` +
|
||||
`Ensure the class has a @portal_route decorator.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate base URL from pattern
|
||||
const path = Rsx_Portal._generate_url_from_pattern(pattern, params_obj);
|
||||
|
||||
// Apply portal prefix (in dev mode) or return as-is (domain mode)
|
||||
return Rsx_Portal._apply_portal_base(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply portal domain or prefix to a path
|
||||
*
|
||||
* @param {string} path The route path (e.g., '/dashboard')
|
||||
* @returns {string} Path with portal prefix (dev) or plain path (domain mode)
|
||||
* @private
|
||||
*/
|
||||
static _apply_portal_base(path) {
|
||||
// If using dedicated domain, path is relative to that domain
|
||||
if (Rsx_Portal.has_dedicated_domain()) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Development mode: prepend prefix
|
||||
const prefix = Rsx_Portal.get_prefix();
|
||||
return prefix + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best matching route pattern from available patterns
|
||||
*
|
||||
* @param {Array<string>} patterns Array of route patterns
|
||||
* @param {Object} params_obj Provided parameters
|
||||
* @returns {string|null} Selected pattern or null if none match
|
||||
* @private
|
||||
*/
|
||||
static _select_best_route_pattern(patterns, params_obj) {
|
||||
const satisfiable = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
// Extract required parameters from pattern
|
||||
const required_params = [];
|
||||
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
required_params.push(match.substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all required parameters are provided
|
||||
let can_satisfy = true;
|
||||
for (const required of required_params) {
|
||||
if (!(required in params_obj)) {
|
||||
can_satisfy = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (can_satisfy) {
|
||||
satisfiable.push({
|
||||
pattern: pattern,
|
||||
param_count: required_params.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (satisfiable.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by parameter count descending (most parameters first)
|
||||
satisfiable.sort((a, b) => b.param_count - a.param_count);
|
||||
|
||||
return satisfiable[0].pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from route pattern by replacing parameters
|
||||
*
|
||||
* @param {string} pattern The route pattern (e.g., '/projects/:id')
|
||||
* @param {Object} params Parameters to fill into the route
|
||||
* @returns {string} The generated URL
|
||||
* @private
|
||||
*/
|
||||
static _generate_url_from_pattern(pattern, params) {
|
||||
// Extract required parameters from the pattern
|
||||
const required_params = [];
|
||||
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
required_params.push(match.substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required parameters
|
||||
const missing = [];
|
||||
for (const required of required_params) {
|
||||
if (!(required in params)) {
|
||||
missing.push(required);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Required parameters [${missing.join(', ')}] are missing for portal route ${pattern}`);
|
||||
}
|
||||
|
||||
// Build the URL by replacing parameters
|
||||
let url = pattern;
|
||||
const used_params = {};
|
||||
|
||||
for (const param_name of required_params) {
|
||||
const value = params[param_name];
|
||||
const encoded_value = encodeURIComponent(value);
|
||||
url = url.replace(':' + param_name, encoded_value);
|
||||
used_params[param_name] = true;
|
||||
}
|
||||
|
||||
// Collect extra parameters for query string
|
||||
const internal_params = ['_loader_title_hint'];
|
||||
const query_params = {};
|
||||
for (const key in params) {
|
||||
if (!used_params[key] && !internal_params.includes(key)) {
|
||||
query_params[key] = params[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Append query string if there are extra parameters
|
||||
if (Object.keys(query_params).length > 0) {
|
||||
const query_string = Object.entries(query_params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
url += '?' + query_string;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a route pattern for a portal SPA action class
|
||||
*
|
||||
* @param {string} class_name The action class name
|
||||
* @param {Object} params_obj The parameters for route selection
|
||||
* @returns {string|null} The route pattern or null
|
||||
* @private
|
||||
*/
|
||||
static _try_spa_action_route(class_name, params_obj) {
|
||||
// Get all classes from manifest
|
||||
const all_classes = Manifest.get_all_classes();
|
||||
|
||||
// Find the class by name
|
||||
for (const class_info of all_classes) {
|
||||
if (class_info.class_name === class_name) {
|
||||
const class_object = class_info.class_object;
|
||||
|
||||
// Check if it's a SPA action with portal routes
|
||||
if (typeof Spa_Action !== 'undefined' &&
|
||||
class_object.prototype instanceof Spa_Action) {
|
||||
|
||||
// Get route patterns from decorator metadata
|
||||
// Portal SPA actions use @route() decorator (stores in _spa_routes)
|
||||
// and @portal_spa() decorator (sets _is_portal_spa = true)
|
||||
const routes = class_object._spa_routes || [];
|
||||
|
||||
// Only match if this is a portal SPA action
|
||||
if (routes.length > 0 && class_object._is_portal_spa) {
|
||||
const selected = Rsx_Portal._select_best_route_pattern(routes, params_obj);
|
||||
|
||||
if (!selected) {
|
||||
throw new Error(
|
||||
`No suitable portal route found for SPA action ${class_name} with provided parameters. ` +
|
||||
`Available routes: ${routes.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define portal routes from bundled data
|
||||
* Called by generated JavaScript in portal bundles
|
||||
*
|
||||
* @param {Object} routes Route definitions object
|
||||
*/
|
||||
static _define_routes(routes) {
|
||||
for (const class_name in routes) {
|
||||
if (!Rsx_Portal._routes[class_name]) {
|
||||
Rsx_Portal._routes[class_name] = {};
|
||||
}
|
||||
for (const method_name in routes[class_name]) {
|
||||
Rsx_Portal._routes[class_name][method_name] = routes[class_name][method_name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached state (for testing)
|
||||
*/
|
||||
static _clear_cache() {
|
||||
Rsx_Portal._is_portal = null;
|
||||
Rsx_Portal._routes = {};
|
||||
}
|
||||
}
|
||||
@@ -23,46 +23,41 @@ use App\RSpade\Core\Models\User_Profile_Model;
|
||||
* See: php artisan rsx:man acls
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_ Database type hints - do not edit manually
|
||||
* Generated on: 2026-01-29 08:25:50
|
||||
* Table: users
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $login_user_id
|
||||
* @property int $site_id
|
||||
* @property mixed $first_name
|
||||
* @property mixed $last_name
|
||||
* @property mixed $phone
|
||||
* @property int $role_id
|
||||
* @property bool $is_enabled
|
||||
* @property int $user_role_id
|
||||
* @property mixed $email
|
||||
* @property string $deleted_at
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property int $deleted_by
|
||||
* @property mixed $invite_code
|
||||
* @property string $invite_accepted_at
|
||||
* @property string $invite_expires_at
|
||||
*
|
||||
* @property-read string $role_id__label
|
||||
* @property-read string $role_id__constant
|
||||
*
|
||||
* @method static array role_id__enum() Get all enum definitions with full metadata
|
||||
* @method static array role_id__enum_select() Get selectable items for dropdowns
|
||||
* @method static array role_id__enum_labels() Get simple id => label map
|
||||
* @method static array role_id__enum_ids() Get array of all valid enum IDs
|
||||
*
|
||||
* _AUTO_GENERATED_
|
||||
* @property integer $id
|
||||
* @property integer $login_user_id
|
||||
* @property integer $site_id
|
||||
* @property string $first_name
|
||||
* @property string $last_name
|
||||
* @property string $phone
|
||||
* @property integer $role_id
|
||||
* @property boolean $is_enabled
|
||||
* @property integer $user_role_id
|
||||
* @property string $email
|
||||
* @property \Carbon\Carbon $deleted_at
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property integer $created_by
|
||||
* @property integer $updated_by
|
||||
* @property integer $deleted_by
|
||||
* @property string $invite_code
|
||||
* @property \Carbon\Carbon $invite_accepted_at
|
||||
* @property \Carbon\Carbon $invite_expires_at
|
||||
* @method static mixed role_id_enum()
|
||||
* @method static mixed role_id_enum_select()
|
||||
* @method static mixed role_id_enum_ids()
|
||||
* @property-read mixed $role_id_constant
|
||||
* @property-read mixed $role_id_label
|
||||
* @property-read mixed $role_id_permissions
|
||||
* @property-read mixed $role_id_can_admin_roles
|
||||
* @property-read mixed $role_id_selectable
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class User_Model extends Rsx_Site_Model_Abstract
|
||||
{
|
||||
/**
|
||||
* _AUTO_GENERATED_ Enum constants
|
||||
*/
|
||||
{
|
||||
/** __AUTO_GENERATED: */
|
||||
const ROLE_DEVELOPER = 100;
|
||||
const ROLE_ROOT_ADMIN = 200;
|
||||
const ROLE_SITE_OWNER = 300;
|
||||
@@ -71,9 +66,6 @@ class User_Model extends Rsx_Site_Model_Abstract
|
||||
const ROLE_USER = 600;
|
||||
const ROLE_VIEWER = 700;
|
||||
const ROLE_DISABLED = 800;
|
||||
|
||||
/** __AUTO_GENERATED: */
|
||||
|
||||
/** __/AUTO_GENERATED */
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -11,44 +11,33 @@ use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
* and two-factor authentication via email or SMS.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_ Database type hints - do not edit manually
|
||||
* Generated on: 2026-01-29 08:25:50
|
||||
* Table: user_verifications
|
||||
*
|
||||
* @property int $id
|
||||
* @property mixed $email
|
||||
* @property mixed $verification_code
|
||||
* @property int $verification_type_id
|
||||
* @property string $verified_at
|
||||
* @property string $expires_at
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
*
|
||||
* @property-read string $verification_type_id__label
|
||||
* @property-read string $verification_type_id__constant
|
||||
*
|
||||
* @method static array verification_type_id__enum() Get all enum definitions with full metadata
|
||||
* @method static array verification_type_id__enum_select() Get selectable items for dropdowns
|
||||
* @method static array verification_type_id__enum_labels() Get simple id => label map
|
||||
* @method static array verification_type_id__enum_ids() Get array of all valid enum IDs
|
||||
*
|
||||
* _AUTO_GENERATED_
|
||||
* @property integer $id
|
||||
* @property string $email
|
||||
* @property string $verification_code
|
||||
* @property integer $verification_type_id
|
||||
* @property \Carbon\Carbon $verified_at
|
||||
* @property \Carbon\Carbon $expires_at
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property integer $created_by
|
||||
* @property integer $updated_by
|
||||
* @method static mixed verification_type_id_enum()
|
||||
* @method static mixed verification_type_id_enum_select()
|
||||
* @method static mixed verification_type_id_enum_ids()
|
||||
* @property-read mixed $verification_type_id_constant
|
||||
* @property-read mixed $verification_type_id_label
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class User_Verification_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
/**
|
||||
* _AUTO_GENERATED_ Enum constants
|
||||
*/
|
||||
{
|
||||
/** __AUTO_GENERATED: */
|
||||
const VERIFICATION_TYPE_EMAIL = 1;
|
||||
const VERIFICATION_TYPE_SMS = 2;
|
||||
const VERIFICATION_TYPE_EMAIL_RECOVERY = 3;
|
||||
const VERIFICATION_TYPE_SMS_RECOVERY = 4;
|
||||
|
||||
/** __AUTO_GENERATED: */
|
||||
|
||||
/** __/AUTO_GENERATED */
|
||||
|
||||
/**
|
||||
|
||||
412
app/RSpade/Core/Portal/Portal_Dispatcher.php
Executable file
412
app/RSpade/Core/Portal/Portal_Dispatcher.php
Executable file
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Portal;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\RSpade\Core\Dispatch\AssetHandler;
|
||||
use App\RSpade\Core\Dispatch\RouteResolver;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Portal\Portal_Session;
|
||||
use App\RSpade\Core\Portal\Rsx_Portal;
|
||||
|
||||
/**
|
||||
* Portal_Dispatcher - Handles dispatch for client portal requests
|
||||
*
|
||||
* Similar to Dispatcher but for portal routes:
|
||||
* - Uses portal_routes from manifest instead of routes
|
||||
* - Uses Portal_Session instead of Session
|
||||
* - Calls portal.php hooks instead of main.php
|
||||
* - Strips portal prefix in development mode
|
||||
*/
|
||||
class Portal_Dispatcher
|
||||
{
|
||||
/**
|
||||
* Check if URL is a portal request and should be handled by portal dispatcher
|
||||
*
|
||||
* Detection logic:
|
||||
* 1. If portal domain configured: check if request host matches
|
||||
* 2. If no domain: check if URL starts with portal prefix
|
||||
*
|
||||
* @param string $url Request URL
|
||||
* @param Request|null $request Optional request object
|
||||
* @return bool True if this is a portal request
|
||||
*/
|
||||
public static function is_portal_request(string $url, ?Request $request = null): bool
|
||||
{
|
||||
return Rsx_Portal::is_portal_request();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the normalized portal URL (with prefix stripped if applicable)
|
||||
*
|
||||
* @param string $url Original request URL
|
||||
* @return string URL normalized for portal route matching
|
||||
*/
|
||||
public static function normalize_portal_url(string $url): string
|
||||
{
|
||||
// If not using dedicated domain, strip the prefix
|
||||
if (!Rsx_Portal::has_dedicated_domain()) {
|
||||
$prefix = Rsx_Portal::get_prefix();
|
||||
if (str_starts_with($url, $prefix)) {
|
||||
$url = substr($url, strlen($prefix)) ?: '/';
|
||||
}
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a portal request to the appropriate handler
|
||||
*
|
||||
* @param string $url The URL to dispatch (will be normalized)
|
||||
* @param string $method HTTP method (GET, POST, etc.)
|
||||
* @param array $extra_params Additional parameters to merge
|
||||
* @param Request|null $request Optional request object
|
||||
* @return mixed Response from handler, or null if no route found
|
||||
*/
|
||||
public static function dispatch(string $url, string $method = 'GET', array $extra_params = [], ?Request $request = null)
|
||||
{
|
||||
console_debug('PORTAL', "Portal dispatch started for: {$method} {$url}");
|
||||
|
||||
// Handle dev auth for rsx:debug testing (development only)
|
||||
static::__handle_dev_auth($request ?? request(), $url);
|
||||
|
||||
// Normalize the URL (strip prefix if needed)
|
||||
$normalized_url = static::normalize_portal_url($url);
|
||||
console_debug('PORTAL', "Normalized URL: {$normalized_url}");
|
||||
|
||||
// Initialize manifest
|
||||
Manifest::init();
|
||||
|
||||
$request = $request ?? request();
|
||||
|
||||
// Check if this is an asset request
|
||||
if (AssetHandler::is_asset_request($normalized_url)) {
|
||||
console_debug('PORTAL', "Serving static asset: {$normalized_url}");
|
||||
|
||||
return AssetHandler::serve($normalized_url, $request);
|
||||
}
|
||||
|
||||
// HEAD requests should be treated as GET
|
||||
$original_method = $method;
|
||||
$route_method = ($method === 'HEAD') ? 'GET' : $method;
|
||||
|
||||
if ($method === 'HEAD' && $request) {
|
||||
$request->setMethod('GET');
|
||||
}
|
||||
|
||||
// Find matching portal route
|
||||
console_debug('PORTAL', "Looking for portal route: {$normalized_url}, method: {$route_method}");
|
||||
$route_match = static::__find_portal_route($normalized_url, $route_method);
|
||||
|
||||
if (!$route_match) {
|
||||
console_debug('PORTAL', "No portal route found for: {$normalized_url}");
|
||||
|
||||
// Call unhandled_route hook from Portal_Main
|
||||
$unhandled_response = static::__call_portal_unhandled_route($request, $extra_params);
|
||||
if ($unhandled_response !== null) {
|
||||
return $unhandled_response;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
console_debug('PORTAL', "Found portal route: {$route_match['class']}::{$route_match['method']}");
|
||||
|
||||
// Call pre_dispatch hooks with handler info
|
||||
$pre_dispatch_params = array_merge($route_match['params'], [
|
||||
'_handler' => $route_match['class'] . '::' . $route_match['method'],
|
||||
'_class' => $route_match['class'],
|
||||
'_method' => $route_match['method'],
|
||||
]);
|
||||
$pre_dispatch_response = static::__call_portal_pre_dispatch($request, $pre_dispatch_params);
|
||||
if ($pre_dispatch_response !== null) {
|
||||
return $pre_dispatch_response;
|
||||
}
|
||||
|
||||
// Track current controller/action for portal context
|
||||
Rsx_Portal::_set_current_controller_action(
|
||||
$route_match['class'],
|
||||
$route_match['method'],
|
||||
$route_match['type'] ?? 'standard'
|
||||
);
|
||||
|
||||
// Load controller class (autoloaded by framework)
|
||||
$controller_class = $route_match['class'];
|
||||
|
||||
// Call controller pre_dispatch if it exists
|
||||
if (method_exists($controller_class, 'pre_dispatch')) {
|
||||
$controller_response = $controller_class::pre_dispatch($request, $route_match['params']);
|
||||
if ($controller_response !== null) {
|
||||
return $controller_response;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the action
|
||||
$action_method = $route_match['method'];
|
||||
if (!method_exists($controller_class, $action_method)) {
|
||||
throw new \RuntimeException("Portal action method not found: {$controller_class}::{$action_method}");
|
||||
}
|
||||
|
||||
console_debug('PORTAL', "Executing: {$controller_class}::{$action_method}");
|
||||
$result = $controller_class::$action_method($request, $route_match['params']);
|
||||
|
||||
// Build response
|
||||
return static::__build_response($result, $original_method, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching portal route
|
||||
*
|
||||
* @param string $url URL to match
|
||||
* @param string $method HTTP method
|
||||
* @return array|null Route match data or null
|
||||
*/
|
||||
protected static function __find_portal_route(string $url, string $method): ?array
|
||||
{
|
||||
$manifest = Manifest::get_full_manifest();
|
||||
$portal_routes = $manifest['data']['portal_routes'] ?? [];
|
||||
|
||||
if (empty($portal_routes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort routes for deterministic matching
|
||||
$sorted_routes = [];
|
||||
foreach ($portal_routes as $pattern => $route_data) {
|
||||
$sorted_routes[] = array_merge($route_data, ['pattern' => $pattern]);
|
||||
}
|
||||
|
||||
// Sort by specificity (longer patterns first, static before dynamic)
|
||||
// Catch-all routes (/*) should always be matched last
|
||||
usort($sorted_routes, function ($a, $b) {
|
||||
// Catch-all routes should be last
|
||||
$a_catchall = str_contains($a['pattern'], '*');
|
||||
$b_catchall = str_contains($b['pattern'], '*');
|
||||
|
||||
if ($a_catchall !== $b_catchall) {
|
||||
return $a_catchall ? 1 : -1; // Catch-all routes last
|
||||
}
|
||||
|
||||
// Count path segments
|
||||
$a_segments = count(explode('/', trim($a['pattern'], '/')));
|
||||
$b_segments = count(explode('/', trim($b['pattern'], '/')));
|
||||
|
||||
if ($a_segments !== $b_segments) {
|
||||
return $b_segments <=> $a_segments; // More segments first
|
||||
}
|
||||
|
||||
// Count dynamic segments (: params and {} params)
|
||||
$a_dynamic = substr_count($a['pattern'], ':') + substr_count($a['pattern'], '{');
|
||||
$b_dynamic = substr_count($b['pattern'], ':') + substr_count($b['pattern'], '{');
|
||||
|
||||
return $a_dynamic <=> $b_dynamic; // Fewer dynamic first
|
||||
});
|
||||
|
||||
// Try to match each route
|
||||
foreach ($sorted_routes as $route_data) {
|
||||
$pattern = $route_data['pattern'];
|
||||
$route_methods = $route_data['methods'] ?? ['GET'];
|
||||
|
||||
// Check HTTP method
|
||||
if (!in_array($method, $route_methods)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to match the pattern
|
||||
$params = RouteResolver::match($url, $pattern);
|
||||
|
||||
if ($params !== false) {
|
||||
return [
|
||||
'class' => $route_data['class'],
|
||||
'method' => $route_data['method'],
|
||||
'params' => $params,
|
||||
'pattern' => $pattern,
|
||||
'type' => $route_data['type'] ?? 'portal',
|
||||
'require' => $route_data['require'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Portal_Main::pre_dispatch if it exists
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $params
|
||||
* @return mixed|null Response or null to continue
|
||||
*/
|
||||
protected static function __call_portal_pre_dispatch(Request $request, array $params)
|
||||
{
|
||||
// Find classes extending Portal_Main_Abstract via manifest
|
||||
$portal_main_classes = Manifest::php_get_extending('Portal_Main_Abstract');
|
||||
|
||||
foreach ($portal_main_classes as $portal_main_class) {
|
||||
if (isset($portal_main_class['fqcn']) && $portal_main_class['fqcn']) {
|
||||
$class_name = $portal_main_class['fqcn'];
|
||||
if (method_exists($class_name, 'pre_dispatch')) {
|
||||
console_debug('PORTAL', "Calling {$class_name}::pre_dispatch");
|
||||
$result = $class_name::pre_dispatch($request, $params);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Portal_Main::unhandled_route if it exists
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $params
|
||||
* @return mixed|null Response or null for default 404
|
||||
*/
|
||||
protected static function __call_portal_unhandled_route(Request $request, array $params)
|
||||
{
|
||||
// Find classes extending Portal_Main_Abstract via manifest
|
||||
$portal_main_classes = Manifest::php_get_extending('Portal_Main_Abstract');
|
||||
|
||||
foreach ($portal_main_classes as $portal_main_class) {
|
||||
if (isset($portal_main_class['fqcn']) && $portal_main_class['fqcn']) {
|
||||
$class_name = $portal_main_class['fqcn'];
|
||||
if (method_exists($class_name, 'unhandled_route')) {
|
||||
console_debug('PORTAL', "Calling {$class_name}::unhandled_route");
|
||||
$result = $class_name::unhandled_route($request, $params);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build appropriate response from handler result
|
||||
*
|
||||
* @param mixed $result Handler result
|
||||
* @param string $method Original HTTP method
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
protected static function __build_response($result, string $method, Request $request)
|
||||
{
|
||||
// If already a Response, return as-is
|
||||
if ($result instanceof \Illuminate\Http\Response ||
|
||||
$result instanceof \Symfony\Component\HttpFoundation\Response) {
|
||||
// For HEAD requests, strip the body
|
||||
if ($method === 'HEAD') {
|
||||
$result->setContent('');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// If a View object, render it to a response
|
||||
if ($result instanceof \Illuminate\Contracts\View\View) {
|
||||
$response = response($result->render());
|
||||
|
||||
if ($method === 'HEAD') {
|
||||
$response->setContent('');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// If array, return as JSON
|
||||
if (is_array($result)) {
|
||||
$response = response()->json($result);
|
||||
|
||||
if ($method === 'HEAD') {
|
||||
$response->setContent('');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// If string, return as HTML
|
||||
if (is_string($result)) {
|
||||
$response = response($result);
|
||||
|
||||
if ($method === 'HEAD') {
|
||||
$response->setContent('');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// If null, let caller handle (likely 404)
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dev auth headers for rsx:debug testing
|
||||
*
|
||||
* This allows rsx:debug to authenticate as any portal user in development.
|
||||
* The token is validated using APP_KEY to ensure only authorized requests.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $url
|
||||
* @return void
|
||||
*/
|
||||
protected static function __handle_dev_auth(Request $request, string $url): void
|
||||
{
|
||||
// Only in non-production environments
|
||||
if (app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for portal dev auth header
|
||||
$portal_user_id = $request->header('X-Dev-Auth-Portal-User-Id');
|
||||
if (!$portal_user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $request->header('X-Dev-Auth-Token');
|
||||
if (!$token) {
|
||||
console_debug('PORTAL', 'Dev auth: Missing token header');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
$app_key = config('app.key');
|
||||
if (!$app_key) {
|
||||
console_debug('PORTAL', 'Dev auth: APP_KEY not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize URL for token validation (strip /_portal prefix)
|
||||
$normalized_url = static::normalize_portal_url($url);
|
||||
|
||||
// Recreate the expected token payload
|
||||
$expected_payload = json_encode([
|
||||
'url' => $normalized_url,
|
||||
'user_id' => (int) $portal_user_id,
|
||||
'portal' => true,
|
||||
]);
|
||||
|
||||
$expected_token = hash_hmac('sha256', $expected_payload, $app_key);
|
||||
|
||||
if (!hash_equals($expected_token, $token)) {
|
||||
console_debug('PORTAL', 'Dev auth: Token validation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Token is valid - authenticate as the portal user
|
||||
$portal_user = Portal_User_Model::find((int) $portal_user_id);
|
||||
if (!$portal_user) {
|
||||
console_debug('PORTAL', "Dev auth: Portal user not found: {$portal_user_id}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the user in using the portal user's site_id
|
||||
Portal_Session::set_portal_user_id((int) $portal_user_id, $portal_user->site_id);
|
||||
console_debug('PORTAL', "Dev auth: Logged in as portal user {$portal_user_id}");
|
||||
}
|
||||
}
|
||||
61
app/RSpade/Core/Portal/Portal_Main_Abstract.php
Executable file
61
app/RSpade/Core/Portal/Portal_Main_Abstract.php
Executable file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/**
|
||||
* CODING CONVENTION:
|
||||
* This file follows the coding convention where variable_names and function_names
|
||||
* use snake_case (underscore_wherever_possible).
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Portal;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Portal_Main_Abstract - Base class for portal-wide middleware-style hooks
|
||||
*
|
||||
* Similar to Main_Abstract but specifically for portal requests.
|
||||
* Application extends this via /rsx/portal.php as Portal_Main.
|
||||
*
|
||||
* Portal_Main provides hooks that run BEFORE individual controller hooks:
|
||||
* 1. Portal_Main::init() - Called once during portal bootstrap
|
||||
* 2. Portal_Main::pre_dispatch() - Called before any portal route dispatch
|
||||
* 3. Portal_Main::unhandled_route() - Called when no portal route matches
|
||||
*/
|
||||
abstract class Portal_Main_Abstract
|
||||
{
|
||||
/**
|
||||
* Initialize the Portal_Main class
|
||||
*
|
||||
* Called once during portal bootstrap (when a portal request is detected)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
abstract public static function init();
|
||||
|
||||
/**
|
||||
* Pre-dispatch hook for portal requests
|
||||
*
|
||||
* Called before any portal route dispatch. If a non-null value is returned,
|
||||
* dispatch is halted and that value is returned as the response.
|
||||
*
|
||||
* Typical uses:
|
||||
* - Require portal authentication
|
||||
* - Load portal user data
|
||||
* - Set portal-specific headers
|
||||
*
|
||||
* @param Request $request The current request
|
||||
* @param array $params Combined GET values and URL parameters
|
||||
* @return mixed|null Return null to continue, or a response to halt dispatch
|
||||
*/
|
||||
abstract public static function pre_dispatch(Request $request, array $params);
|
||||
|
||||
/**
|
||||
* Unhandled route hook for portal requests
|
||||
*
|
||||
* Called when no portal route matches the request
|
||||
*
|
||||
* @param Request $request The current request
|
||||
* @param array $params Combined GET values and URL parameters
|
||||
* @return mixed|null Return null for default 404, or a response to handle
|
||||
*/
|
||||
abstract public static function unhandled_route(Request $request, array $params);
|
||||
}
|
||||
142
app/RSpade/Core/Portal/Portal_Route_ManifestSupport.php
Executable file
142
app/RSpade/Core/Portal/Portal_Route_ManifestSupport.php
Executable file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Portal;
|
||||
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for building portal routes index from #[Portal_Route] attributes
|
||||
*
|
||||
* Similar to Route_ManifestSupport but for portal-specific routes.
|
||||
* Portal routes are stored separately in the manifest and handled by
|
||||
* the portal dispatcher.
|
||||
*
|
||||
* Usage in controllers:
|
||||
* ```php
|
||||
* #[Portal_Route('/dashboard')]
|
||||
* public static function index(Request $request, array $params = []) { ... }
|
||||
*
|
||||
* #[Portal_Route('/projects/:id', methods: ['GET'])]
|
||||
* public static function view(Request $request, array $params = []) { ... }
|
||||
* ```
|
||||
*/
|
||||
class Portal_Route_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Portal Routes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the manifest and build portal routes index
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize portal routes structures
|
||||
if (!isset($manifest_data['data']['portal_routes'])) {
|
||||
$manifest_data['data']['portal_routes'] = [];
|
||||
}
|
||||
if (!isset($manifest_data['data']['portal_routes_by_target'])) {
|
||||
$manifest_data['data']['portal_routes_by_target'] = [];
|
||||
}
|
||||
|
||||
// Look for Portal_Route attributes
|
||||
$files = $manifest_data['data']['files'];
|
||||
$portal_route_classes = [];
|
||||
|
||||
foreach ($files as $file => $metadata) {
|
||||
// Check public static method attributes for Portal_Route
|
||||
if (isset($metadata['public_static_methods'])) {
|
||||
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
|
||||
if (isset($method_data['attributes'])) {
|
||||
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
|
||||
// Check if this is a Portal_Route attribute
|
||||
if (str_ends_with($attr_name, '\\Portal_Route') || $attr_name === 'Portal_Route') {
|
||||
$portal_route_classes[] = [
|
||||
'file' => $file,
|
||||
'class' => $metadata['class'] ?? null,
|
||||
'fqcn' => $metadata['fqcn'] ?? null,
|
||||
'method' => $method_name,
|
||||
'type' => 'method',
|
||||
'instances' => $attr_instances,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($portal_route_classes as $item) {
|
||||
if ($item['type'] === 'method') {
|
||||
foreach ($item['instances'] as $route_args) {
|
||||
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
|
||||
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
|
||||
$name = $route_args[2] ?? ($route_args['name'] ?? null);
|
||||
|
||||
if ($pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($pattern[0] !== '/') {
|
||||
$pattern = '/' . $pattern;
|
||||
}
|
||||
|
||||
// Type is always 'portal' for routes with #[Portal_Route] attribute
|
||||
$type = 'portal';
|
||||
|
||||
// Extract Auth attributes for this method (portal-specific auth would use Portal_Auth or similar)
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$item['file']] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Portal_Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Portal_Auth'];
|
||||
}
|
||||
|
||||
// Check for duplicate portal route definition
|
||||
if (isset($manifest_data['data']['portal_routes'][$pattern])) {
|
||||
$existing = $manifest_data['data']['portal_routes'][$pattern];
|
||||
$existing_location = "{$existing['class']}::{$existing['method']} in {$existing['file']}";
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Duplicate portal route definition: {$pattern}\n" .
|
||||
" Already defined: {$existing_location}\n" .
|
||||
" Conflicting: {$item['fqcn']}::{$item['method']} in {$item['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Store route with flat structure (for portal dispatcher)
|
||||
$route_data = [
|
||||
'methods' => array_map('strtoupper', (array) $methods),
|
||||
'type' => $type,
|
||||
'class' => $item['fqcn'] ?? $item['class'],
|
||||
'method' => $item['method'],
|
||||
'name' => $name,
|
||||
'file' => $item['file'],
|
||||
'require' => $require_attrs,
|
||||
'pattern' => $pattern,
|
||||
];
|
||||
|
||||
$manifest_data['data']['portal_routes'][$pattern] = $route_data;
|
||||
|
||||
// Also store by target for URL generation
|
||||
$target = $item['class'] . '::' . $item['method'];
|
||||
if (!isset($manifest_data['data']['portal_routes_by_target'][$target])) {
|
||||
$manifest_data['data']['portal_routes_by_target'][$target] = [];
|
||||
}
|
||||
$manifest_data['data']['portal_routes_by_target'][$target][] = $route_data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort routes alphabetically by path
|
||||
ksort($manifest_data['data']['portal_routes']);
|
||||
ksort($manifest_data['data']['portal_routes_by_target']);
|
||||
}
|
||||
}
|
||||
729
app/RSpade/Core/Portal/Portal_Session.php
Executable file
729
app/RSpade/Core/Portal/Portal_Session.php
Executable file
@@ -0,0 +1,729 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Portal;
|
||||
|
||||
use App\RSpade\Core\Database\Models\Rsx_System_Model_Abstract;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Models\Site_Model;
|
||||
use App\RSpade\Core\Portal\Rsx_Portal;
|
||||
use App\RSpade\Core\Session\User_Agent;
|
||||
|
||||
/**
|
||||
* Portal_Session - handles authentication for external portal users
|
||||
*
|
||||
* This class serves dual purposes:
|
||||
* 1. As a Laravel Eloquent model for the portal_sessions table
|
||||
* 2. As a static interface for portal session management
|
||||
*
|
||||
* Key differences from Session:
|
||||
* - Site-scoped (no multi-site switching)
|
||||
* - Simpler model (no experience_id)
|
||||
* - Uses different cookie name ('rsx_portal')
|
||||
* - References Portal_User_Model instead of Login_User_Model
|
||||
*
|
||||
* @FILE-SUBCLASS-01-EXCEPTION Class intentionally named Portal_Session instead of Portal_Session_Model
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $site_id
|
||||
* @property int $portal_user_id
|
||||
* @property string $session_token
|
||||
* @property string $csrf_token
|
||||
* @property string $ip_address
|
||||
* @property string $user_agent
|
||||
* @property \Carbon\Carbon $last_active
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class Portal_Session extends Rsx_System_Model_Abstract
|
||||
{
|
||||
// Enum definitions (required by abstract parent)
|
||||
public static $enums = [];
|
||||
|
||||
// Cookie name for portal sessions (different from internal 'rsx')
|
||||
const COOKIE_NAME = 'rsx_portal';
|
||||
|
||||
// Static session management properties
|
||||
private static $_session = null;
|
||||
|
||||
private static $_site = null;
|
||||
|
||||
private static $_portal_user = null;
|
||||
|
||||
private static $_session_token = null;
|
||||
|
||||
private static $_has_init = false;
|
||||
|
||||
private static $_has_activate = false;
|
||||
|
||||
private static $_has_set_cookie = false;
|
||||
|
||||
// CLI mode properties (static-only, no database)
|
||||
private static $_cli_site_id = null;
|
||||
|
||||
private static $_cli_portal_user_id = null;
|
||||
|
||||
/**
|
||||
* The table associated with the model
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'portal_sessions';
|
||||
|
||||
/**
|
||||
* The attributes that should be cast
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'site_id' => 'integer',
|
||||
'portal_user_id' => 'integer',
|
||||
'last_active' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Columns that should never be exported to JavaScript
|
||||
* @var array
|
||||
*/
|
||||
protected $neverExport = [
|
||||
'session_token',
|
||||
'csrf_token',
|
||||
'ip_address',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if running in CLI mode
|
||||
* @return bool
|
||||
*/
|
||||
private static function __is_cli(): bool
|
||||
{
|
||||
return php_sapi_name() === 'cli';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session from cookie
|
||||
* Loads existing session but does not create new one
|
||||
* In CLI mode: does nothing
|
||||
* @return void
|
||||
*/
|
||||
public static function init(): void
|
||||
{
|
||||
if (self::$_has_init) {
|
||||
return;
|
||||
}
|
||||
self::$_has_init = true;
|
||||
|
||||
// CLI mode: do nothing
|
||||
if (self::__is_cli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Manifest::init();
|
||||
|
||||
// Try to get session token from cookie
|
||||
$session_token = $_COOKIE[self::COOKIE_NAME] ?? null;
|
||||
|
||||
if (empty($session_token)) {
|
||||
self::$_session = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load session
|
||||
$session = static::where('session_token', $session_token)->first();
|
||||
|
||||
if (!$session) {
|
||||
self::$_session = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update last activity
|
||||
static::where('id', $session->id)->update(['last_active' => now()]);
|
||||
|
||||
// Reload the session to ensure we have the latest version
|
||||
$session = static::find($session->id);
|
||||
|
||||
self::$_session_token = $session_token;
|
||||
self::$_session = $session;
|
||||
|
||||
self::_set_cookie();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate session - creates new one if needed
|
||||
* In CLI mode: does nothing
|
||||
* @param int $site_id Required site ID for new sessions
|
||||
* @return void
|
||||
*/
|
||||
private static function __activate(int $site_id = 0): void
|
||||
{
|
||||
if (self::$_has_activate) {
|
||||
return;
|
||||
}
|
||||
self::$_has_activate = true;
|
||||
|
||||
// CLI mode: do nothing
|
||||
if (self::__is_cli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::init();
|
||||
|
||||
// If no session exists, create one
|
||||
if (empty(self::$_session)) {
|
||||
if ($site_id === 0) {
|
||||
throw new \RuntimeException('Portal session requires site_id for activation');
|
||||
}
|
||||
|
||||
// Generate cryptographically secure token
|
||||
self::$_session_token = bin2hex(random_bytes(32));
|
||||
|
||||
// Generate CSRF token
|
||||
$csrf_token = bin2hex(random_bytes(32));
|
||||
|
||||
$session = new static();
|
||||
$session->session_token = self::$_session_token;
|
||||
$session->csrf_token = $csrf_token;
|
||||
$session->ip_address = self::__get_client_ip();
|
||||
$session->user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
|
||||
$session->last_active = now();
|
||||
$session->site_id = $site_id;
|
||||
$session->portal_user_id = null;
|
||||
|
||||
$session->save();
|
||||
|
||||
self::$_session = $session;
|
||||
self::_set_cookie();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session cookie with security flags
|
||||
* In CLI mode: does nothing
|
||||
* @return void
|
||||
*/
|
||||
private static function _set_cookie(): void
|
||||
{
|
||||
if (self::$_has_set_cookie) {
|
||||
return;
|
||||
}
|
||||
self::$_has_set_cookie = true;
|
||||
|
||||
// CLI mode: do nothing
|
||||
if (self::__is_cli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lifetime_days = config('rsx.portal.session_lifetime_days', 30);
|
||||
|
||||
// Set cookie with security flags
|
||||
setcookie(self::COOKIE_NAME, self::$_session_token, [
|
||||
'expires' => time() + ($lifetime_days * 86400),
|
||||
'path' => '/',
|
||||
'domain' => '', // Current domain only
|
||||
'secure' => true, // HTTPS only
|
||||
'httponly' => true, // No JavaScript access
|
||||
'samesite' => 'Lax', // CSRF protection
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address, handling proxies
|
||||
* In CLI mode: returns "CLI"
|
||||
* @return string
|
||||
*/
|
||||
private static function __get_client_ip(): string
|
||||
{
|
||||
// CLI mode: return "CLI"
|
||||
if (self::__is_cli()) {
|
||||
return 'CLI';
|
||||
}
|
||||
|
||||
// Check for forwarded IP (when behind proxy/CDN)
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
|
||||
return trim($ips[0]);
|
||||
}
|
||||
|
||||
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
return $_SERVER['HTTP_X_REAL_IP'];
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset/logout the current session
|
||||
* @return void
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::init();
|
||||
|
||||
if (!empty(self::$_session)) {
|
||||
self::$_session->delete();
|
||||
}
|
||||
|
||||
self::$_session = null;
|
||||
self::$_site = null;
|
||||
self::$_portal_user = null;
|
||||
self::$_has_init = false;
|
||||
self::$_has_activate = false;
|
||||
self::$_has_set_cookie = false;
|
||||
|
||||
// Clear cookie
|
||||
setcookie(self::COOKIE_NAME, '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => true,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site ID for current session
|
||||
* In CLI mode: returns static CLI property
|
||||
* If no session exists, detects site from domain/config
|
||||
* @return int
|
||||
*/
|
||||
public static function get_site_id(): int
|
||||
{
|
||||
// CLI mode: return static property
|
||||
if (self::__is_cli()) {
|
||||
return self::$_cli_site_id ?? 0;
|
||||
}
|
||||
|
||||
self::init();
|
||||
|
||||
// If session exists, use its site_id
|
||||
if (!empty(self::$_session)) {
|
||||
return self::$_session->site_id ?? 0;
|
||||
}
|
||||
|
||||
// No session - detect site from context
|
||||
return self::detect_site_id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect site ID from request context (domain or config default)
|
||||
* Used when no session exists yet (e.g., login page)
|
||||
* @return int
|
||||
*/
|
||||
public static function detect_site_id(): int
|
||||
{
|
||||
// Check for portal domain in config that matches current host
|
||||
$portal_domain = Rsx_Portal::get_domain();
|
||||
if (!empty($portal_domain)) {
|
||||
$request_host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if (strtolower($request_host) === strtolower($portal_domain)) {
|
||||
// In production, could look up site by portal_domain
|
||||
// For now, use default site
|
||||
return config('rsx.portal.default_site_id', 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Development mode or no specific domain - use default site
|
||||
return config('rsx.portal.default_site_id', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site model for current session
|
||||
* @return Site_Model|null
|
||||
*/
|
||||
public static function get_site()
|
||||
{
|
||||
$site_id = self::get_site_id();
|
||||
|
||||
if ($site_id === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty(self::$_site)) {
|
||||
self::$_site = Site_Model::find($site_id);
|
||||
}
|
||||
|
||||
return self::$_site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portal user ID for current session
|
||||
* In CLI mode: returns static CLI property
|
||||
* @return int|null
|
||||
*/
|
||||
public static function get_portal_user_id()
|
||||
{
|
||||
// CLI mode: return static property
|
||||
if (self::__is_cli()) {
|
||||
return self::$_cli_portal_user_id;
|
||||
}
|
||||
|
||||
self::init();
|
||||
|
||||
if (empty(self::$_session)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$_session->portal_user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if portal user is logged in
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_logged_in(): bool
|
||||
{
|
||||
return !empty(self::get_portal_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portal user model for current session
|
||||
* @return \Portal_User_Model|null
|
||||
*/
|
||||
public static function get_user()
|
||||
{
|
||||
$portal_user_id = self::get_portal_user_id();
|
||||
|
||||
if (empty($portal_user_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty(self::$_portal_user)) {
|
||||
self::$_portal_user = \Portal_User_Model::find($portal_user_id);
|
||||
}
|
||||
|
||||
return self::$_portal_user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for get_user() to match naming convention
|
||||
* @return \Portal_User_Model|null
|
||||
*/
|
||||
public static function get_portal_user()
|
||||
{
|
||||
return self::get_user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session model (creates if needed)
|
||||
* @param int $site_id Required for new sessions
|
||||
* @return Portal_Session
|
||||
*/
|
||||
public static function get_session(int $site_id = 0): Portal_Session
|
||||
{
|
||||
self::__activate($site_id);
|
||||
|
||||
return self::$_session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID (creates session if needed)
|
||||
* @param int $site_id Required for new sessions
|
||||
* @return int
|
||||
*/
|
||||
public static function get_session_id(int $site_id = 0): int
|
||||
{
|
||||
self::__activate($site_id);
|
||||
|
||||
return self::$_session->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token for current session
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_csrf_token(): ?string
|
||||
{
|
||||
self::init();
|
||||
|
||||
if (empty(self::$_session)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$_session->csrf_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSRF token
|
||||
* @param string $token
|
||||
* @return bool
|
||||
*/
|
||||
public static function verify_csrf_token(string $token): bool
|
||||
{
|
||||
self::init();
|
||||
|
||||
if (empty(self::$_session)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use constant-time comparison
|
||||
return hash_equals(self::$_session->csrf_token, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current portal user
|
||||
* @return void
|
||||
*/
|
||||
public static function logout(): void
|
||||
{
|
||||
self::set_portal_user_id(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set portal user ID for current session (login/logout)
|
||||
* In CLI mode: sets static CLI property only, no database
|
||||
* @param int|null $portal_user_id Portal user ID, or null to logout
|
||||
* @param int $site_id Required for new sessions when logging in
|
||||
* @return void
|
||||
*/
|
||||
public static function set_portal_user_id(?int $portal_user_id, int $site_id = 0): void
|
||||
{
|
||||
// Logout if null/0
|
||||
if (empty($portal_user_id)) {
|
||||
// CLI mode: clear static property only
|
||||
if (self::__is_cli()) {
|
||||
self::$_cli_portal_user_id = null;
|
||||
self::$_portal_user = null;
|
||||
self::$_site = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::init();
|
||||
if (!empty(self::$_session)) {
|
||||
self::$_session->portal_user_id = null;
|
||||
self::$_session->save();
|
||||
}
|
||||
|
||||
self::$_portal_user = null;
|
||||
self::$_site = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// CLI mode: set static property only
|
||||
if (self::__is_cli()) {
|
||||
self::$_cli_portal_user_id = $portal_user_id;
|
||||
self::$_portal_user = null;
|
||||
self::$_site = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::__activate($site_id);
|
||||
|
||||
// Regenerate session token and CSRF token on login (prevent session fixation)
|
||||
$new_token = bin2hex(random_bytes(32));
|
||||
$new_csrf = bin2hex(random_bytes(32));
|
||||
self::$_session->session_token = $new_token;
|
||||
self::$_session->csrf_token = $new_csrf;
|
||||
self::$_session->portal_user_id = $portal_user_id;
|
||||
self::$_session->save();
|
||||
|
||||
self::$_session_token = $new_token;
|
||||
self::$_has_set_cookie = false; // Force new cookie
|
||||
self::_set_cookie();
|
||||
|
||||
// Clear cached portal_user/site
|
||||
self::$_portal_user = null;
|
||||
self::$_site = null;
|
||||
|
||||
// Update portal user's last login timestamp
|
||||
$portal_user = self::get_user();
|
||||
if ($portal_user) {
|
||||
$portal_user->last_login = now();
|
||||
$portal_user->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists
|
||||
* In CLI mode: returns true if site_id or user_id is set
|
||||
* In web mode: returns true if session record exists
|
||||
* @return bool
|
||||
*/
|
||||
public static function has_session(): bool
|
||||
{
|
||||
// CLI mode: check if site_id or user_id is set
|
||||
if (self::__is_cli()) {
|
||||
return self::$_cli_site_id !== null || self::$_cli_portal_user_id !== null;
|
||||
}
|
||||
|
||||
// Web mode: init and check if session exists
|
||||
self::init();
|
||||
|
||||
return !empty(self::$_session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by token (for API/external access)
|
||||
* @param string $token
|
||||
* @return Portal_Session|null
|
||||
*/
|
||||
public static function find_by_token(string $token)
|
||||
{
|
||||
return static::where('session_token', $token)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions (garbage collection)
|
||||
* @param int $days_until_expiry
|
||||
* @return int Number of sessions deleted
|
||||
*/
|
||||
public static function cleanup_expired(int $days_until_expiry = 30): int
|
||||
{
|
||||
return static::where('last_active', '<', now()->subDays($days_until_expiry))
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship: Portal User
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function portal_user()
|
||||
{
|
||||
return $this->belongsTo(\Portal_User_Model::class, 'portal_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship: Site
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function site()
|
||||
{
|
||||
return $this->belongsTo(Site_Model::class, 'site_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CLI MODE SETTERS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Set site ID in CLI mode
|
||||
* @param int $site_id
|
||||
* @return void
|
||||
*/
|
||||
public static function cli_set_site_id(int $site_id): void
|
||||
{
|
||||
self::$_cli_site_id = $site_id;
|
||||
self::$_site = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set portal user ID in CLI mode
|
||||
* @param int $portal_user_id
|
||||
* @return void
|
||||
*/
|
||||
public static function cli_set_portal_user_id(int $portal_user_id): void
|
||||
{
|
||||
self::$_cli_portal_user_id = $portal_user_id;
|
||||
self::$_portal_user = null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SESSION MANAGEMENT METHODS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get all active sessions for a portal user
|
||||
* Returns formatted session info including device parsing
|
||||
*
|
||||
* @param int|null $portal_user_id If null, uses current logged-in user
|
||||
* @return array Array of session info
|
||||
*/
|
||||
public static function get_sessions_for_user(?int $portal_user_id = null): array
|
||||
{
|
||||
if ($portal_user_id === null) {
|
||||
$portal_user_id = self::get_portal_user_id();
|
||||
}
|
||||
|
||||
if (empty($portal_user_id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$current_session_id = self::has_session() ? self::$_session?->id : null;
|
||||
|
||||
return static::where('portal_user_id', $portal_user_id)
|
||||
->orderBy('last_active', 'desc')
|
||||
->get()
|
||||
->map(function ($session) use ($current_session_id) {
|
||||
$parsed_ua = User_Agent::parse($session->user_agent);
|
||||
|
||||
return [
|
||||
'id' => $session->id,
|
||||
'ip_address' => $session->ip_address,
|
||||
'user_agent' => $session->user_agent,
|
||||
'user_agent_parsed' => $parsed_ua,
|
||||
'device_summary' => $parsed_ua['summary'],
|
||||
'last_active' => $session->last_active,
|
||||
'created_at' => $session->created_at,
|
||||
'is_current' => $session->id === $current_session_id,
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a specific session by ID
|
||||
* Cannot terminate the current session (use logout() instead)
|
||||
*
|
||||
* @param int $session_id
|
||||
* @return bool True if session was terminated, false if not found or is current
|
||||
*/
|
||||
public static function terminate_session(int $session_id): bool
|
||||
{
|
||||
self::init();
|
||||
|
||||
// Don't allow terminating current session
|
||||
if (self::$_session && self::$_session->id === $session_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$affected = static::where('id', $session_id)->delete();
|
||||
|
||||
return $affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all sessions for the current user except the current one
|
||||
*
|
||||
* @return int Number of sessions terminated
|
||||
*/
|
||||
public static function terminate_all_other_sessions(): int
|
||||
{
|
||||
self::init();
|
||||
|
||||
$portal_user_id = self::get_portal_user_id();
|
||||
if (empty($portal_user_id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$query = static::where('portal_user_id', $portal_user_id);
|
||||
|
||||
// Exclude current session if we have one
|
||||
if (self::$_session) {
|
||||
$query->where('id', '!=', self::$_session->id);
|
||||
}
|
||||
|
||||
return $query->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all sessions for a specific portal user
|
||||
* Useful for admin actions or password changes
|
||||
*
|
||||
* @param int $portal_user_id
|
||||
* @param int|null $except_session_id Optional session ID to exclude
|
||||
* @return int Number of sessions terminated
|
||||
*/
|
||||
public static function terminate_all_sessions_for_user(int $portal_user_id, ?int $except_session_id = null): int
|
||||
{
|
||||
$query = static::where('portal_user_id', $portal_user_id);
|
||||
|
||||
if ($except_session_id !== null) {
|
||||
$query->where('id', '!=', $except_session_id);
|
||||
}
|
||||
|
||||
return $query->delete();
|
||||
}
|
||||
}
|
||||
195
app/RSpade/Core/Portal/Portal_Spa_ManifestSupport.php
Executable file
195
app/RSpade/Core/Portal/Portal_Spa_ManifestSupport.php
Executable file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Portal;
|
||||
|
||||
use RuntimeException;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for extracting Portal Spa route metadata from Spa_Action classes
|
||||
*
|
||||
* Similar to Spa_ManifestSupport but for portal-specific SPA routes.
|
||||
* Portal SPA actions use the @portal_spa() decorator instead of @spa()
|
||||
* and their routes are registered in portal_routes instead of routes.
|
||||
*
|
||||
* Usage in JS action:
|
||||
* ```javascript
|
||||
* @route('/dashboard')
|
||||
* @layout('Portal_Layout')
|
||||
* @portal_spa('Portal_Spa_Controller::index')
|
||||
* class Portal_Dashboard_Action extends Spa_Action {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class Portal_Spa_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Portal Spa Routes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the manifest and build Portal Spa routes index
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize portal routes structures if not already set
|
||||
if (!isset($manifest_data['data']['portal_routes'])) {
|
||||
$manifest_data['data']['portal_routes'] = [];
|
||||
}
|
||||
if (!isset($manifest_data['data']['portal_routes_by_target'])) {
|
||||
$manifest_data['data']['portal_routes_by_target'] = [];
|
||||
}
|
||||
|
||||
// Get all files to look up PHP controller metadata
|
||||
$files = $manifest_data['data']['files'];
|
||||
|
||||
// Get all JavaScript classes extending Spa_Action
|
||||
$action_classes = Manifest::js_get_extending('Spa_Action');
|
||||
|
||||
foreach ($action_classes as $class_name => $action_metadata) {
|
||||
// Extract decorator metadata
|
||||
$decorators = $action_metadata['decorators'] ?? [];
|
||||
|
||||
// Parse decorators into route configuration
|
||||
$route_info = static::_parse_decorators($decorators);
|
||||
|
||||
// Skip if no @portal_spa decorator (this is a regular SPA action)
|
||||
if (empty($route_info['portal_spa_controller'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if no route decorator found
|
||||
if (empty($route_info['routes'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the PHP controller file and metadata
|
||||
$php_controller_class = $route_info['portal_spa_controller'];
|
||||
$php_controller_method = $route_info['portal_spa_method'];
|
||||
$php_controller_file = null;
|
||||
$php_controller_fqcn = null;
|
||||
|
||||
// Search for the controller in the manifest
|
||||
foreach ($files as $file => $metadata) {
|
||||
if (($metadata['class'] ?? null) === $php_controller_class || ($metadata['fqcn'] ?? null) === $php_controller_class) {
|
||||
$php_controller_file = $file;
|
||||
$php_controller_fqcn = $metadata['fqcn'] ?? $metadata['class'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$php_controller_file) {
|
||||
throw new RuntimeException(
|
||||
"Portal Spa action '{$class_name}' references unknown controller '{$php_controller_class}'.\n" .
|
||||
"The @portal_spa decorator must reference a valid PHP controller class.\n" .
|
||||
"File: {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Build complete route metadata for each route pattern
|
||||
foreach ($route_info['routes'] as $route_pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($route_pattern[0] !== '/') {
|
||||
$route_pattern = '/' . $route_pattern;
|
||||
}
|
||||
|
||||
// Check for duplicate portal route definition
|
||||
if (isset($manifest_data['data']['portal_routes'][$route_pattern])) {
|
||||
$existing = $manifest_data['data']['portal_routes'][$route_pattern];
|
||||
$existing_type = $existing['type'] ?? 'portal';
|
||||
$existing_location = $existing_type === 'portal_spa'
|
||||
? "Portal Spa action {$existing['js_action_class']} in {$existing['file']}"
|
||||
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
|
||||
|
||||
throw new RuntimeException(
|
||||
"Duplicate portal route definition: {$route_pattern}\n" .
|
||||
" Already defined: {$existing_location}\n" .
|
||||
" Conflicting: Portal Spa action {$class_name} in {$action_metadata['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Store route with unified structure (for portal dispatcher)
|
||||
$route_data = [
|
||||
'methods' => ['GET'], // Spa routes are always GET
|
||||
'type' => 'portal_spa',
|
||||
'class' => $php_controller_fqcn,
|
||||
'method' => $php_controller_method,
|
||||
'name' => null,
|
||||
'file' => $php_controller_file,
|
||||
'require' => [],
|
||||
'js_action_class' => $class_name,
|
||||
'pattern' => $route_pattern,
|
||||
];
|
||||
|
||||
$manifest_data['data']['portal_routes'][$route_pattern] = $route_data;
|
||||
|
||||
// Also store by target for URL generation (group multiple routes per action class)
|
||||
$target = $class_name; // For SPA, target is the JS action class name
|
||||
if (!isset($manifest_data['data']['portal_routes_by_target'][$target])) {
|
||||
$manifest_data['data']['portal_routes_by_target'][$target] = [];
|
||||
}
|
||||
$manifest_data['data']['portal_routes_by_target'][$target][] = $route_data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse decorator metadata into route configuration
|
||||
*
|
||||
* @param array $decorators Array of decorator data from manifest
|
||||
* @return array Parsed route configuration
|
||||
*/
|
||||
private static function _parse_decorators(array $decorators): array
|
||||
{
|
||||
$config = [
|
||||
'routes' => [],
|
||||
'layout' => null,
|
||||
'portal_spa_controller' => null,
|
||||
'portal_spa_method' => null,
|
||||
];
|
||||
|
||||
foreach ($decorators as $decorator) {
|
||||
[$name, $args] = $decorator;
|
||||
|
||||
switch ($name) {
|
||||
case 'route':
|
||||
// @route('/path') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$config['routes'][] = $args[0];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'layout':
|
||||
// @layout('Layout_Name') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$config['layout'] = $args[0];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'portal_spa':
|
||||
// @portal_spa('Controller::method') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
$parts = explode('::', $args[0]);
|
||||
if (count($parts) === 2) {
|
||||
$config['portal_spa_controller'] = $parts[0];
|
||||
$config['portal_spa_method'] = $parts[1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
481
app/RSpade/Core/Portal/Rsx_Portal.php
Executable file
481
app/RSpade/Core/Portal/Rsx_Portal.php
Executable file
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
/**
|
||||
* @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
|
||||
*/
|
||||
|
||||
namespace App\RSpade\Core\Portal;
|
||||
|
||||
use RuntimeException;
|
||||
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
|
||||
use App\RSpade\Core\Manifest\Manifest;
|
||||
|
||||
/**
|
||||
* Portal utility class
|
||||
*
|
||||
* Provides static utility methods for the client portal including
|
||||
* route generation and portal context detection.
|
||||
*
|
||||
* Key differences from Rsx:
|
||||
* - Route() prepends portal domain/prefix
|
||||
* - Portal-specific context detection
|
||||
* - Simpler (no event system, fewer utilities)
|
||||
*/
|
||||
class Rsx_Portal
|
||||
{
|
||||
/**
|
||||
* URL prefix for portal when no dedicated domain configured
|
||||
*/
|
||||
public const URL_PREFIX = '/_portal';
|
||||
|
||||
/**
|
||||
* Whether we're currently in a portal request context
|
||||
* @var bool|null
|
||||
*/
|
||||
protected static ?bool $_is_portal_request = null;
|
||||
|
||||
/**
|
||||
* Current portal controller being executed
|
||||
* @var string|null
|
||||
*/
|
||||
protected static ?string $current_controller = null;
|
||||
|
||||
/**
|
||||
* Current portal action being executed
|
||||
* @var string|null
|
||||
*/
|
||||
protected static ?string $current_action = null;
|
||||
|
||||
/**
|
||||
* Current portal route type ('spa' or 'standard')
|
||||
* @var string|null
|
||||
*/
|
||||
protected static ?string $current_route_type = null;
|
||||
|
||||
// =========================================================================
|
||||
// Configuration Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get the configured portal domain
|
||||
*
|
||||
* @return string|null Domain if configured, null otherwise
|
||||
*/
|
||||
public static function get_domain(): ?string
|
||||
{
|
||||
return config('rsx.portal.domain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the portal URL prefix (used when no domain configured)
|
||||
*
|
||||
* @return string The prefix, defaults to '/_portal'
|
||||
*/
|
||||
public static function get_prefix(): string
|
||||
{
|
||||
return config('rsx.portal.prefix', self::URL_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if portal is using a dedicated domain (vs URL prefix)
|
||||
*
|
||||
* @return bool True if dedicated domain is configured
|
||||
*/
|
||||
public static function has_dedicated_domain(): bool
|
||||
{
|
||||
return !empty(self::get_domain());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Portal Request Context Detection
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if current request is a portal request
|
||||
*
|
||||
* Detection logic:
|
||||
* 1. If portal domain configured: check if request host matches
|
||||
* 2. If no domain: check if URL starts with portal prefix
|
||||
*
|
||||
* @return bool True if this is a portal request
|
||||
*/
|
||||
public static function is_portal_request(): bool
|
||||
{
|
||||
if (self::$_is_portal_request !== null) {
|
||||
return self::$_is_portal_request;
|
||||
}
|
||||
|
||||
// CLI mode: default to false (can be overridden)
|
||||
if (php_sapi_name() === 'cli') {
|
||||
self::$_is_portal_request = false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$portal_domain = self::get_domain();
|
||||
|
||||
if (!empty($portal_domain)) {
|
||||
// Domain-based detection
|
||||
$request_host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
self::$_is_portal_request = (strtolower($request_host) === strtolower($portal_domain));
|
||||
} else {
|
||||
// Prefix-based detection
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$prefix = self::get_prefix();
|
||||
self::$_is_portal_request = str_starts_with($request_uri, $prefix . '/') ||
|
||||
$request_uri === $prefix;
|
||||
}
|
||||
|
||||
return self::$_is_portal_request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually set whether this is a portal request (for testing/CLI)
|
||||
*
|
||||
* @param bool $is_portal
|
||||
* @return void
|
||||
*/
|
||||
public static function set_portal_request(bool $is_portal): void
|
||||
{
|
||||
self::$_is_portal_request = $is_portal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current request URL with portal prefix stripped (if applicable)
|
||||
*
|
||||
* @return string The normalized path
|
||||
*/
|
||||
public static function get_normalized_path(): string
|
||||
{
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
|
||||
// Remove query string
|
||||
$path = parse_url($request_uri, PHP_URL_PATH) ?? '/';
|
||||
|
||||
// If using prefix mode and path starts with prefix, strip it
|
||||
if (!self::has_dedicated_domain()) {
|
||||
$prefix = self::get_prefix();
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
$path = substr($path, strlen($prefix)) ?: '/';
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route Generation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate URL for a portal route
|
||||
*
|
||||
* Similar to Rsx::Route() but:
|
||||
* - Returns URLs with portal domain or prefix
|
||||
* - Only works with routes that have #[Portal_Route] attribute
|
||||
*
|
||||
* Usage examples:
|
||||
* ```php
|
||||
* // Portal action route
|
||||
* $url = Rsx_Portal::Route('Portal_Dashboard_Action');
|
||||
* // Development: /_portal/dashboard
|
||||
* // Production: https://portal.example.com/dashboard
|
||||
*
|
||||
* // Route with integer parameter
|
||||
* $url = Rsx_Portal::Route('Portal_Project_View_Action', 123);
|
||||
* // Development: /_portal/projects/123
|
||||
*
|
||||
* // Placeholder route
|
||||
* $url = Rsx_Portal::Route('Future_Portal_Feature::#index');
|
||||
* // Returns: #
|
||||
* ```
|
||||
*
|
||||
* @param string $action Controller class, SPA action, or "Class::method"
|
||||
* @param int|array|\stdClass|null $params Route parameters
|
||||
* @return string The generated URL (may include portal domain/prefix)
|
||||
*/
|
||||
public static function Route($action, $params = null): string
|
||||
{
|
||||
// Parse action into class_name and action_name
|
||||
if (str_contains($action, '::')) {
|
||||
[$class_name, $action_name] = explode('::', $action, 2);
|
||||
} else {
|
||||
$class_name = $action;
|
||||
$action_name = 'index';
|
||||
}
|
||||
|
||||
// Normalize params to array
|
||||
$params_array = [];
|
||||
if (is_int($params)) {
|
||||
$params_array = ['id' => $params];
|
||||
} elseif (is_array($params)) {
|
||||
$params_array = $params;
|
||||
} elseif ($params instanceof \stdClass) {
|
||||
$params_array = (array) $params;
|
||||
} elseif ($params !== null) {
|
||||
throw new RuntimeException("Params must be integer, array, stdClass, or null");
|
||||
}
|
||||
|
||||
// Placeholder route
|
||||
if (str_starts_with($action_name, '#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Look up routes in manifest using portal_routes_by_target
|
||||
$manifest = Manifest::get_full_manifest();
|
||||
|
||||
// Build target - always include ::method for consistency with manifest keys
|
||||
$target = $class_name . '::' . $action_name;
|
||||
|
||||
// First try direct target lookup (Class::method)
|
||||
if (!isset($manifest['data']['portal_routes_by_target'][$target])) {
|
||||
// Allow shorthand: Route('MyController') implies Route('MyController::index')
|
||||
if ($action_name === 'index' && isset($manifest['data']['portal_routes_by_target'][$class_name])) {
|
||||
$target = $class_name;
|
||||
} else {
|
||||
throw new Rsx_Caller_Exception(
|
||||
"Portal route not found for {$action}. " .
|
||||
"Ensure the class has a #[Portal_Route] attribute."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$routes = $manifest['data']['portal_routes_by_target'][$target];
|
||||
|
||||
// Select best matching route
|
||||
$selected_route = self::_select_best_route($routes, $params_array);
|
||||
|
||||
if (!$selected_route) {
|
||||
throw new Rsx_Caller_Exception(
|
||||
"No suitable portal route found for {$action} with provided parameters. " .
|
||||
"Available routes: " . implode(', ', array_column($routes, 'pattern'))
|
||||
);
|
||||
}
|
||||
|
||||
// Generate base URL from pattern
|
||||
$path = self::_generate_url_from_pattern($selected_route['pattern'], $params_array);
|
||||
|
||||
// Apply portal prefix/domain
|
||||
return self::_apply_portal_base($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate absolute URL for a portal route
|
||||
*
|
||||
* @param string $action Controller class, SPA action, or "Class::method"
|
||||
* @param int|array|\stdClass|null $params Route parameters
|
||||
* @return string Full URL including protocol and domain
|
||||
*/
|
||||
public static function url($action, $params = null): string
|
||||
{
|
||||
$path = self::Route($action, $params);
|
||||
|
||||
// If already has domain, return as-is
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
// Build absolute URL
|
||||
$portal_domain = self::get_domain();
|
||||
|
||||
if (!empty($portal_domain)) {
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
|
||||
return $protocol . '://' . $portal_domain . $path;
|
||||
}
|
||||
|
||||
// No portal domain, use current host with path
|
||||
return url($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply portal domain or prefix to a path
|
||||
*
|
||||
* @param string $path The route path (e.g., '/dashboard')
|
||||
* @return string Path with portal prefix, or full URL if domain configured
|
||||
*/
|
||||
protected static function _apply_portal_base(string $path): string
|
||||
{
|
||||
// If using dedicated domain in production, keep path as-is
|
||||
// The domain will be handled at routing level
|
||||
if (self::has_dedicated_domain()) {
|
||||
// In production, the path is relative to portal domain
|
||||
// The caller should use url() if they need full URL
|
||||
return $path;
|
||||
}
|
||||
|
||||
// Development mode: prepend prefix
|
||||
$prefix = self::get_prefix();
|
||||
|
||||
return $prefix . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best matching route from available routes
|
||||
*
|
||||
* @param array $routes Array of route data from manifest
|
||||
* @param array $params_array Provided parameters
|
||||
* @return array|null Selected route data or null if none match
|
||||
*/
|
||||
protected static function _select_best_route(array $routes, array $params_array): ?array
|
||||
{
|
||||
$satisfiable = [];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$pattern = $route['pattern'];
|
||||
|
||||
// Extract required parameters from pattern
|
||||
$required_params = [];
|
||||
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
|
||||
$required_params = $matches[1];
|
||||
}
|
||||
|
||||
// Check if all required parameters are provided
|
||||
$can_satisfy = true;
|
||||
foreach ($required_params as $required) {
|
||||
if (!array_key_exists($required, $params_array)) {
|
||||
$can_satisfy = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($can_satisfy) {
|
||||
$satisfiable[] = [
|
||||
'route' => $route,
|
||||
'param_count' => count($required_params),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($satisfiable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by parameter count descending (most parameters first)
|
||||
usort($satisfiable, function ($a, $b) {
|
||||
return $b['param_count'] <=> $a['param_count'];
|
||||
});
|
||||
|
||||
return $satisfiable[0]['route'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL from route pattern by replacing parameters
|
||||
*
|
||||
* @param string $pattern The route pattern
|
||||
* @param array $params Parameters to fill in
|
||||
* @return string The generated URL
|
||||
*/
|
||||
protected static function _generate_url_from_pattern(string $pattern, array $params): string
|
||||
{
|
||||
// Extract required parameters from the pattern
|
||||
$required_params = [];
|
||||
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
|
||||
$required_params = $matches[1];
|
||||
}
|
||||
|
||||
// Build the URL by replacing parameters
|
||||
$url = $pattern;
|
||||
$used_params = [];
|
||||
|
||||
foreach ($required_params as $param_name) {
|
||||
if (!array_key_exists($param_name, $params)) {
|
||||
throw new RuntimeException("Required parameter '{$param_name}' missing for route {$pattern}");
|
||||
}
|
||||
$value = $params[$param_name];
|
||||
$encoded_value = urlencode((string) $value);
|
||||
$url = str_replace(':' . $param_name, $encoded_value, $url);
|
||||
$used_params[$param_name] = true;
|
||||
}
|
||||
|
||||
// Collect extra parameters for query string
|
||||
$query_params = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if (!isset($used_params[$key])) {
|
||||
$query_params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Append query string if there are extra parameters
|
||||
if (!empty($query_params)) {
|
||||
$url .= '?' . http_build_query($query_params);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Controller/Action Tracking
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Set the current portal controller and action being executed
|
||||
*
|
||||
* @param string $controller_class The controller class name
|
||||
* @param string $action_method The action method name
|
||||
* @param string|null $route_type Route type ('spa' or 'standard')
|
||||
*/
|
||||
public static function _set_current_controller_action(
|
||||
string $controller_class,
|
||||
string $action_method,
|
||||
?string $route_type = null
|
||||
): void {
|
||||
// Extract just the class name without namespace
|
||||
$parts = explode('\\', $controller_class);
|
||||
$class_name = end($parts);
|
||||
|
||||
static::$current_controller = $class_name;
|
||||
static::$current_action = $action_method;
|
||||
static::$current_route_type = $route_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current portal controller class name
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_current_controller(): ?string
|
||||
{
|
||||
return static::$current_controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current portal action method name
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_current_action(): ?string
|
||||
{
|
||||
return static::$current_action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current portal route is a SPA route
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_spa(): bool
|
||||
{
|
||||
return static::$current_route_type === 'spa' || static::$current_route_type === 'portal_spa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current controller and action tracking
|
||||
*/
|
||||
public static function _clear_current_controller_action(): void
|
||||
{
|
||||
static::$current_controller = null;
|
||||
static::$current_action = null;
|
||||
static::$current_route_type = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached state (for testing)
|
||||
*/
|
||||
public static function _clear_cache(): void
|
||||
{
|
||||
static::$_is_portal_request = null;
|
||||
static::$current_controller = null;
|
||||
static::$current_action = null;
|
||||
static::$current_route_type = null;
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,14 @@ class Spa {
|
||||
const parsed = Spa.parse_url(url);
|
||||
let path = parsed.path;
|
||||
|
||||
// Strip portal prefix if in portal context
|
||||
if (Rsx_Portal.is_portal() && !Rsx_Portal.has_dedicated_domain()) {
|
||||
const prefix = Rsx_Portal.get_prefix();
|
||||
if (path.startsWith(prefix)) {
|
||||
path = path.substring(prefix.length) || '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize path - remove leading/trailing slashes for matching
|
||||
path = path.substring(1); // Remove leading /
|
||||
|
||||
|
||||
@@ -86,3 +86,22 @@ function title(page_title) {
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @decorator
|
||||
* Link a Portal Spa action to its PHP controller method
|
||||
* Used for portal routes which are handled by Portal_Spa_ManifestSupport
|
||||
*
|
||||
* Usage:
|
||||
* @portal_spa('Portal_Spa_Controller::index')
|
||||
* class Portal_Dashboard_Action extends Spa_Action { }
|
||||
*/
|
||||
function portal_spa(controller_method) {
|
||||
return function (target) {
|
||||
// Store controller::method reference on the class
|
||||
target._spa_controller_method = controller_method;
|
||||
// Mark as portal SPA action
|
||||
target._is_portal_spa = true;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
|
||||
// Parse decorators into route configuration
|
||||
$route_info = static::_parse_decorators($decorators);
|
||||
|
||||
// Skip if this is a portal SPA action (handled by Portal_Spa_ManifestSupport)
|
||||
if (!empty($route_info['is_portal_spa'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if no route decorator found
|
||||
if (empty($route_info['routes'])) {
|
||||
continue;
|
||||
@@ -155,6 +160,7 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
|
||||
'layout' => null,
|
||||
'spa_controller' => null,
|
||||
'spa_method' => null,
|
||||
'is_portal_spa' => false,
|
||||
];
|
||||
|
||||
foreach ($decorators as $decorator) {
|
||||
@@ -175,6 +181,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
|
||||
}
|
||||
break;
|
||||
|
||||
case 'portal_spa':
|
||||
// @portal_spa decorator - handled by Portal_Spa_ManifestSupport, skip here
|
||||
$config['is_portal_spa'] = true;
|
||||
break;
|
||||
|
||||
case 'spa':
|
||||
// @spa('Controller::method') - args is array with single string
|
||||
if (!empty($args[0])) {
|
||||
|
||||
@@ -5,41 +5,29 @@ namespace App\RSpade\Lib\Flash;
|
||||
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||
|
||||
/**
|
||||
* _AUTO_GENERATED_ Database type hints - do not edit manually
|
||||
* Generated on: 2026-01-29 08:25:50
|
||||
* Table: _flash_alerts
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $session_id
|
||||
* @property int $type_id
|
||||
* _AUTO_GENERATED_
|
||||
* @property integer $id
|
||||
* @property integer $session_id
|
||||
* @property integer $type_id
|
||||
* @property string $message
|
||||
* @property string $created_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property string $updated_at
|
||||
*
|
||||
* @property-read string $type_id__label
|
||||
* @property-read string $type_id__constant
|
||||
*
|
||||
* @method static array type_id__enum() Get all enum definitions with full metadata
|
||||
* @method static array type_id__enum_select() Get selectable items for dropdowns
|
||||
* @method static array type_id__enum_labels() Get simple id => label map
|
||||
* @method static array type_id__enum_ids() Get array of all valid enum IDs
|
||||
*
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property integer $created_by
|
||||
* @property integer $updated_by
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @method static mixed type_id_enum()
|
||||
* @method static mixed type_id_enum_select()
|
||||
* @method static mixed type_id_enum_ids()
|
||||
* @property-read mixed $type_id_constant
|
||||
* @property-read mixed $type_id_label
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class Flash_Alert_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
/**
|
||||
* _AUTO_GENERATED_ Enum constants
|
||||
*/
|
||||
{
|
||||
/** __AUTO_GENERATED: */
|
||||
const TYPE_SUCCESS = 1;
|
||||
const TYPE_ERROR = 2;
|
||||
const TYPE_INFO = 3;
|
||||
const TYPE_WARNING = 4;
|
||||
|
||||
/** __AUTO_GENERATED: */
|
||||
|
||||
/** __/AUTO_GENERATED */
|
||||
|
||||
// Enum constants (auto-generated by rsx:migrate:document_models)
|
||||
|
||||
@@ -27,6 +27,16 @@ CORE OPTIONS
|
||||
running test. Uses backdoor authentication that only works in
|
||||
development environments.
|
||||
|
||||
--portal
|
||||
Enable portal mode for testing client portal routes. When set, the URL
|
||||
is automatically prefixed with /_portal/ (if not already present).
|
||||
Use with --portal-user to authenticate as a portal user.
|
||||
|
||||
--portal-user=<id|email>
|
||||
Test as a specific portal user, bypassing portal authentication.
|
||||
Accepts either a numeric portal user ID or email address. Requires
|
||||
--portal flag. Validates portal user exists before running test.
|
||||
|
||||
--no-body
|
||||
Suppress HTTP response body output. Useful when you only want to see
|
||||
headers, status code, console errors, or other diagnostic information.
|
||||
@@ -281,6 +291,17 @@ Test a protected route as user ID 1:
|
||||
Test a protected route by email:
|
||||
php artisan rsx:debug /admin/users --user=admin@example.com
|
||||
|
||||
Test a portal route as portal user ID 1:
|
||||
php artisan rsx:debug /dashboard --portal --portal-user=1
|
||||
|
||||
Test a portal route by email:
|
||||
php artisan rsx:debug /mail --portal --portal-user=client@example.com
|
||||
|
||||
Test a portal route (URL with or without /_portal/ prefix):
|
||||
php artisan rsx:debug /_portal/dashboard --portal --portal-user=1
|
||||
php artisan rsx:debug /dashboard --portal --portal-user=1
|
||||
# Both are equivalent - prefix is normalized
|
||||
|
||||
Check if JavaScript errors occur:
|
||||
php artisan rsx:debug /page
|
||||
# Console errors are always shown
|
||||
|
||||
@@ -57,6 +57,8 @@ function parse_args() {
|
||||
console.log(' --console-debug-benchmark Include benchmark timing in console_debug');
|
||||
console.log(' --console-debug-all Show all console_debug channels');
|
||||
console.log(' --dump-dimensions=<sel> Add layout dimensions to matching elements');
|
||||
console.log(' --portal Test portal routes (uses /_portal/ prefix)');
|
||||
console.log(' --portal-user=<id> Test as specific portal user ID');
|
||||
console.log(' --help Show this help message');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -99,7 +101,9 @@ function parse_args() {
|
||||
screenshot_width: null,
|
||||
screenshot_path: null,
|
||||
dump_dimensions: null,
|
||||
dev_auth_token: null
|
||||
dev_auth_token: null,
|
||||
portal: false,
|
||||
portal_user_id: null
|
||||
};
|
||||
|
||||
for (const arg of args) {
|
||||
@@ -176,6 +180,10 @@ function parse_args() {
|
||||
options.screenshot_path = arg.substring(18);
|
||||
} else if (arg.startsWith('--dump-dimensions=')) {
|
||||
options.dump_dimensions = arg.substring(18);
|
||||
} else if (arg === '--portal') {
|
||||
options.portal = true;
|
||||
} else if (arg.startsWith('--portal-user=')) {
|
||||
options.portal_user_id = arg.substring(14);
|
||||
} else if (!arg.startsWith('--')) {
|
||||
options.route = arg;
|
||||
}
|
||||
@@ -208,9 +216,14 @@ function parse_args() {
|
||||
// Main execution
|
||||
(async () => {
|
||||
const options = parse_args();
|
||||
|
||||
|
||||
const baseUrl = 'http://localhost';
|
||||
const fullUrl = baseUrl + options.route;
|
||||
// In portal mode, ensure route has /_portal prefix
|
||||
let route = options.route;
|
||||
if (options.portal && !route.startsWith('/_portal')) {
|
||||
route = '/_portal' + route;
|
||||
}
|
||||
const fullUrl = baseUrl + route;
|
||||
const laravel_log_path = process.env.LARAVEL_LOG_PATH || '/var/www/html/storage/logs/laravel.log';
|
||||
|
||||
// Launch browser (always headless)
|
||||
@@ -380,6 +393,9 @@ function parse_args() {
|
||||
if (options.user_id) {
|
||||
extraHeaders['X-Dev-Auth-User-Id'] = options.user_id;
|
||||
}
|
||||
if (options.portal_user_id) {
|
||||
extraHeaders['X-Dev-Auth-Portal-User-Id'] = options.portal_user_id;
|
||||
}
|
||||
if (options.dev_auth_token) {
|
||||
extraHeaders['X-Dev-Auth-Token'] = options.dev_auth_token;
|
||||
}
|
||||
@@ -699,6 +715,14 @@ function parse_args() {
|
||||
if (options.user_id) {
|
||||
output += ` user:${options.user_id}`;
|
||||
}
|
||||
|
||||
if (options.portal) {
|
||||
output += ` portal:true`;
|
||||
}
|
||||
|
||||
if (options.portal_user_id) {
|
||||
output += ` portal-user:${options.portal_user_id}`;
|
||||
}
|
||||
|
||||
// Add key headers
|
||||
if (headers_response['content-type']) {
|
||||
|
||||
@@ -142,6 +142,8 @@ return [
|
||||
|
||||
'manifest_support' => [
|
||||
\App\RSpade\Core\Dispatch\Route_ManifestSupport::class,
|
||||
\App\RSpade\Core\Portal\Portal_Route_ManifestSupport::class,
|
||||
\App\RSpade\Core\Portal\Portal_Spa_ManifestSupport::class,
|
||||
\App\RSpade\Core\Manifest\Modules\Model_ManifestSupport::class,
|
||||
\App\RSpade\Integrations\Jqhtml\Jqhtml_ManifestSupport::class,
|
||||
\App\RSpade\Core\SPA\Spa_ManifestSupport::class,
|
||||
|
||||
@@ -396,6 +396,26 @@
|
||||
"created_at": "2026-01-29T08:19:02+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_notifications_table"
|
||||
},
|
||||
"2026_01_29_180540_create_portal_users_table.php": {
|
||||
"created_at": "2026-01-29T18:05:40+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_portal_users_table"
|
||||
},
|
||||
"2026_01_29_180545_create_portal_invitations_table.php": {
|
||||
"created_at": "2026-01-29T18:05:45+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_portal_invitations_table"
|
||||
},
|
||||
"2026_01_29_180545_create_portal_sessions_table.php": {
|
||||
"created_at": "2026-01-29T18:05:45+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_portal_sessions_table"
|
||||
},
|
||||
"2026_01_29_184331_create_portal_password_resets_table.php": {
|
||||
"created_at": "2026-01-29T18:43:31+00:00",
|
||||
"created_by": "root",
|
||||
"command": "php artisan make:migration:safe create_portal_password_resets_table"
|
||||
}
|
||||
}
|
||||
}
|
||||
57
database/migrations/2026_01_29_180540_create_portal_users_table.php
Executable file
57
database/migrations/2026_01_29_180540_create_portal_users_table.php
Executable file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Portal users are external users (customers, clients, vendors) who access
|
||||
* a site through the client portal. They are completely separate from
|
||||
* internal system users (login_users).
|
||||
*
|
||||
* Key differences from login_users:
|
||||
* - Site-scoped (no multi-tenant access)
|
||||
* - Simpler auth model (no multi-site switching)
|
||||
* - Invite-only registration
|
||||
* - No remember_token (portal sessions are simpler)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
DB::statement("
|
||||
CREATE TABLE portal_users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
is_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status_id BIGINT NOT NULL DEFAULT 1,
|
||||
metadata JSON NULL,
|
||||
last_login TIMESTAMP(3) NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT DEFAULT NULL,
|
||||
updated_by BIGINT DEFAULT NULL,
|
||||
|
||||
UNIQUE KEY uk_portal_users_site_email (site_id, email),
|
||||
KEY idx_portal_users_site_id (site_id),
|
||||
KEY idx_portal_users_email (email),
|
||||
KEY idx_portal_users_status_id (status_id),
|
||||
KEY idx_portal_users_is_verified (is_verified),
|
||||
KEY idx_portal_users_created_at (created_at),
|
||||
KEY idx_portal_users_last_login (last_login),
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
57
database/migrations/2026_01_29_180545_create_portal_invitations_table.php
Executable file
57
database/migrations/2026_01_29_180545_create_portal_invitations_table.php
Executable file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Portal invitations are single-use codes that allow external users
|
||||
* to register for portal access. The invitation flow:
|
||||
*
|
||||
* 1. Admin creates invitation with email + optional metadata
|
||||
* 2. System sends email with unique invitation code
|
||||
* 3. User clicks link, lands on portal registration page
|
||||
* 4. User sets password, account created with email verified
|
||||
* 5. Invitation marked as used (used_at set)
|
||||
*
|
||||
* Metadata allows linking portal user to business entities
|
||||
* (e.g., contact_id, client_id) - application-specific.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
DB::statement("
|
||||
CREATE TABLE portal_invitations (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
invitation_code VARCHAR(64) NOT NULL,
|
||||
metadata JSON NULL,
|
||||
expires_at TIMESTAMP(3) NOT NULL,
|
||||
used_at TIMESTAMP(3) NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT DEFAULT NULL,
|
||||
updated_by BIGINT DEFAULT NULL,
|
||||
|
||||
UNIQUE KEY uk_portal_invitations_code (invitation_code),
|
||||
KEY idx_portal_invitations_site_id (site_id),
|
||||
KEY idx_portal_invitations_email (email),
|
||||
KEY idx_portal_invitations_site_email (site_id, email),
|
||||
KEY idx_portal_invitations_expires_at (expires_at),
|
||||
KEY idx_portal_invitations_used_at (used_at),
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
53
database/migrations/2026_01_29_180545_create_portal_sessions_table.php
Executable file
53
database/migrations/2026_01_29_180545_create_portal_sessions_table.php
Executable file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Portal sessions are completely separate from internal sessions.
|
||||
* This ensures:
|
||||
* - Different cookie names (no session bleeding)
|
||||
* - Independent session management
|
||||
* - Simpler structure (no experience_id, no multi-site)
|
||||
*
|
||||
* Portal sessions are site-scoped - a portal user can only
|
||||
* be logged into one site at a time with a given session.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
DB::statement("
|
||||
CREATE TABLE portal_sessions (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
portal_user_id BIGINT NULL,
|
||||
session_token VARCHAR(64) NOT NULL,
|
||||
csrf_token VARCHAR(64) NULL,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
last_active TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE KEY uk_portal_sessions_token (session_token),
|
||||
KEY idx_portal_sessions_site_id (site_id),
|
||||
KEY idx_portal_sessions_portal_user_id (portal_user_id),
|
||||
KEY idx_portal_sessions_last_active (last_active),
|
||||
KEY idx_portal_sessions_site_user (site_id, portal_user_id),
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (portal_user_id) REFERENCES portal_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* down() method is prohibited in RSpade framework
|
||||
* Migrations should only move forward, never backward
|
||||
* You may remove this comment as soon as you see it and understand.
|
||||
*/
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Portal password reset tokens allow users to reset their password.
|
||||
* Tokens are single-use and expire after a configurable time period.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User requests password reset with their email
|
||||
* 2. System creates token and sends email with reset link
|
||||
* 3. User clicks link, lands on password reset page
|
||||
* 4. User sets new password
|
||||
* 5. Token marked as used (used_at set)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
DB::statement("
|
||||
CREATE TABLE portal_password_resets (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
portal_user_id BIGINT NOT NULL,
|
||||
token VARCHAR(128) NOT NULL,
|
||||
expires_at TIMESTAMP(3) NOT NULL,
|
||||
used_at TIMESTAMP(3) NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE KEY uk_portal_password_resets_token (token),
|
||||
KEY idx_portal_password_resets_site_id (site_id),
|
||||
KEY idx_portal_password_resets_portal_user_id (portal_user_id),
|
||||
KEY idx_portal_password_resets_expires_at (expires_at),
|
||||
KEY idx_portal_password_resets_used_at (used_at),
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (portal_user_id) REFERENCES portal_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
}
|
||||
};
|
||||
@@ -1048,9 +1048,10 @@ rsx:debug /page --screenshot-path=/tmp/page.png # Capture screenshot
|
||||
rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction
|
||||
rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return)
|
||||
rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console
|
||||
rsx:debug /dashboard --portal --portal-user=1 # Portal route as portal user
|
||||
```
|
||||
|
||||
Options: `--user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help`
|
||||
Options: `--user=ID`, `--portal`, `--portal-user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help`
|
||||
|
||||
**SPA routes ARE server routes.** If you get 404, the route doesn't exist - check route definitions. Never dismiss as "SPA can't be tested server-side".
|
||||
|
||||
|
||||
Reference in New Issue
Block a user