Fix code quality violations and rename select input components
Move small tasks from wishlist to todo, update npm packages Replace #[Auth] attributes with manual auth checks and code quality rule Remove on_jqhtml_ready lifecycle method from framework Complete ACL system with 100-based role indexing and /dev/acl tester WIP: ACL system implementation with debug instrumentation Convert rsx:check JS linting to RPC socket server Clean up docs and fix $id→$sid in man pages, remove SSR/FPC feature Reorganize wishlists: priority order, mark sublayouts complete, add email Update model_fetch docs: mark MVP complete, fix enum docs, reorganize Comprehensive documentation overhaul: clarity, compression, and critical rules Convert Contacts/Projects CRUD to Model.fetch() and add fetch_or_null() Add JS ORM relationship lazy-loading and fetch array handling Add JS ORM relationship fetching and CRUD documentation Fix ORM hydration and add IDE resolution for Base_* model stubs Rename Json_Tree_Component to JS_Tree_Debug_Component and move to framework Enhance JS ORM infrastructure and add Json_Tree class name badges 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
namespace App\RSpade\Bundles;
|
namespace App\RSpade\Bundles;
|
||||||
|
|
||||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap 5 CDN Bundle
|
* Bootstrap 5 CDN Bundle
|
||||||
*
|
*
|
||||||
* Provides Bootstrap 5 CSS and JavaScript via CDN.
|
* Provides Bootstrap 5 CSS and JavaScript via CDN.
|
||||||
*/
|
*/
|
||||||
class Bootstrap5_Bundle extends Rsx_Bundle_Abstract
|
class Bootstrap5_Bundle extends Rsx_Asset_Bundle_Abstract
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Define the bundle configuration
|
* Define the bundle configuration
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\RSpade\Bundles;
|
namespace App\RSpade\Bundles;
|
||||||
|
|
||||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* jQuery CDN Bundle
|
* jQuery CDN Bundle
|
||||||
@@ -10,7 +10,7 @@ use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
|||||||
* Provides jQuery library via CDN. This bundle is automatically included
|
* Provides jQuery library via CDN. This bundle is automatically included
|
||||||
* in all other bundles as a required dependency.
|
* in all other bundles as a required dependency.
|
||||||
*/
|
*/
|
||||||
class Jquery_Bundle extends Rsx_Bundle_Abstract
|
class Jquery_Bundle extends Rsx_Asset_Bundle_Abstract
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Define the bundle configuration
|
* Define the bundle configuration
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\RSpade\Bundles;
|
namespace App\RSpade\Bundles;
|
||||||
|
|
||||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lodash CDN Bundle
|
* Lodash CDN Bundle
|
||||||
@@ -10,7 +10,7 @@ use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
|||||||
* Provides Lodash utility library via CDN. This bundle is automatically included
|
* Provides Lodash utility library via CDN. This bundle is automatically included
|
||||||
* in all other bundles as a required dependency.
|
* in all other bundles as a required dependency.
|
||||||
*/
|
*/
|
||||||
class Lodash_Bundle extends Rsx_Bundle_Abstract
|
class Lodash_Bundle extends Rsx_Asset_Bundle_Abstract
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Define the bundle configuration
|
* Define the bundle configuration
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
|
|
||||||
namespace App\RSpade\Bundles;
|
namespace App\RSpade\Bundles;
|
||||||
|
|
||||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||||
|
|
||||||
class Quill_Bundle extends Rsx_Bundle_Abstract
|
class Quill_Bundle extends Rsx_Asset_Bundle_Abstract
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Define the bundle configuration
|
* Define the bundle configuration
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\RSpade\Bundles;
|
|
||||||
|
|
||||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tom Select CDN Bundle
|
|
||||||
*
|
|
||||||
* Provides Tom Select (modern select2 alternative) via CDN.
|
|
||||||
* Tom Select is a lightweight, vanilla JS library for enhanced select boxes
|
|
||||||
* with search, ajax, tagging, and accessibility features.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Text search/filtering
|
|
||||||
* - Ajax/remote data loading
|
|
||||||
* - Custom value creation (tagging)
|
|
||||||
* - Multi-select support
|
|
||||||
* - Excellent accessibility (ARIA, screen readers)
|
|
||||||
* - Bootstrap-friendly theming
|
|
||||||
*/
|
|
||||||
class Tom_Select_Bundle extends Rsx_Bundle_Abstract
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Define the bundle configuration
|
|
||||||
*
|
|
||||||
* @return array Bundle configuration
|
|
||||||
*/
|
|
||||||
public static function define(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'include' => [], // No local files
|
|
||||||
'cdn_assets' => [
|
|
||||||
'css' => [
|
|
||||||
[
|
|
||||||
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.default.min.css',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'js' => [
|
|
||||||
[
|
|
||||||
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ namespace App\RSpade\CodeQuality;
|
|||||||
use App\RSpade\CodeQuality\CodeQuality_Violation;
|
use App\RSpade\CodeQuality\CodeQuality_Violation;
|
||||||
use App\RSpade\CodeQuality\Support\CacheManager;
|
use App\RSpade\CodeQuality\Support\CacheManager;
|
||||||
use App\RSpade\CodeQuality\Support\FileSanitizer;
|
use App\RSpade\CodeQuality\Support\FileSanitizer;
|
||||||
|
use App\RSpade\CodeQuality\Support\Js_CodeQuality_Rpc;
|
||||||
use App\RSpade\CodeQuality\Support\ViolationCollector;
|
use App\RSpade\CodeQuality\Support\ViolationCollector;
|
||||||
use App\RSpade\Core\Manifest\Manifest;
|
use App\RSpade\Core\Manifest\Manifest;
|
||||||
|
|
||||||
@@ -409,7 +410,7 @@ class CodeQualityChecker
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lint JavaScript file (from monolith line 602)
|
* Lint JavaScript file using RPC server
|
||||||
* Returns true if syntax error found
|
* Returns true if syntax error found
|
||||||
*/
|
*/
|
||||||
protected static function lint_javascript_file(string $file_path): bool
|
protected static function lint_javascript_file(string $file_path): bool
|
||||||
@@ -428,70 +429,60 @@ class CodeQualityChecker
|
|||||||
if (str_contains($file_path, '/resource/vscode_extension/')) {
|
if (str_contains($file_path, '/resource/vscode_extension/')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cache directory for lint flags
|
// Create cache directory for lint flags
|
||||||
$cache_dir = function_exists('storage_path') ? storage_path('rsx-tmp/cache/js-lint-passed') : '/var/www/html/storage/rsx-tmp/cache/js-lint-passed';
|
$cache_dir = function_exists('storage_path') ? storage_path('rsx-tmp/cache/js-lint-passed') : '/var/www/html/storage/rsx-tmp/cache/js-lint-passed';
|
||||||
if (!is_dir($cache_dir)) {
|
if (!is_dir($cache_dir)) {
|
||||||
mkdir($cache_dir, 0755, true);
|
mkdir($cache_dir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate flag file path (no .js extension to avoid IDE detection)
|
// Generate flag file path (no .js extension to avoid IDE detection)
|
||||||
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
|
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
|
||||||
$relative_path = str_replace($base_path . '/', '', $file_path);
|
$relative_path = str_replace($base_path . '/', '', $file_path);
|
||||||
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
|
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
|
||||||
|
|
||||||
// Check if lint was already passed
|
// Check if lint was already passed
|
||||||
if (file_exists($flag_path)) {
|
if (file_exists($flag_path)) {
|
||||||
$source_mtime = filemtime($file_path);
|
$source_mtime = filemtime($file_path);
|
||||||
$flag_mtime = filemtime($flag_path);
|
$flag_mtime = filemtime($flag_path);
|
||||||
|
|
||||||
if ($flag_mtime >= $source_mtime) {
|
if ($flag_mtime >= $source_mtime) {
|
||||||
// File hasn't changed since last successful lint
|
// File hasn't changed since last successful lint
|
||||||
return false; // No errors
|
return false; // No errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run JavaScript syntax check using Node.js
|
// Lint via RPC server (lazy starts if not running)
|
||||||
$linter_path = $base_path . '/bin/js-linter.js';
|
$error = Js_CodeQuality_Rpc::lint($file_path);
|
||||||
if (!file_exists($linter_path)) {
|
|
||||||
// Linter script not found, skip linting
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$command = sprintf('node %s %s 2>&1', escapeshellarg($linter_path), escapeshellarg($file_path));
|
|
||||||
$output = shell_exec($command);
|
|
||||||
|
|
||||||
// Check if there's a syntax error
|
// Check if there's a syntax error
|
||||||
if ($output && trim($output) !== '') {
|
if ($error !== null) {
|
||||||
// Delete flag file if it exists (file now has errors)
|
// Delete flag file if it exists (file now has errors)
|
||||||
if (file_exists($flag_path)) {
|
if (file_exists($flag_path)) {
|
||||||
unlink($flag_path);
|
unlink($flag_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse error message for line number if available
|
$line_number = $error['line'] ?? 0;
|
||||||
$line_number = 0;
|
$message = $error['message'] ?? 'Unknown syntax error';
|
||||||
if (preg_match('/Line (\d+)/', $output, $matches)) {
|
|
||||||
$line_number = (int)$matches[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
static::$collector->add(
|
static::$collector->add(
|
||||||
new CodeQuality_Violation(
|
new CodeQuality_Violation(
|
||||||
'JS-SYNTAX',
|
'JS-SYNTAX',
|
||||||
$file_path,
|
$file_path,
|
||||||
$line_number,
|
$line_number,
|
||||||
trim($output),
|
$message,
|
||||||
'critical',
|
'critical',
|
||||||
null,
|
null,
|
||||||
'Fix the JavaScript syntax error before running other checks.'
|
'Fix the JavaScript syntax error before running other checks.'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return true; // Error found
|
return true; // Error found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create flag file to indicate successful lint
|
// Create flag file to indicate successful lint
|
||||||
touch($flag_path);
|
touch($flag_path);
|
||||||
|
|
||||||
return false; // No errors
|
return false; // No errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class LifecycleMethodsStatic_CodeQualityRule extends CodeQualityRule_Abstract
|
|||||||
'on_app_modules_init',
|
'on_app_modules_init',
|
||||||
'on_app_init',
|
'on_app_init',
|
||||||
'on_app_ready',
|
'on_app_ready',
|
||||||
'on_jqhtml_ready',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function get_id(): string
|
public function get_id(): string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\RSpade\CodeQuality\Rules\JavaScript;
|
namespace App\RSpade\CodeQuality\Rules\JavaScript;
|
||||||
|
|
||||||
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||||
|
use App\RSpade\CodeQuality\Support\Js_CodeQuality_Rpc;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JavaScript 'this' Usage Rule
|
* JavaScript 'this' Usage Rule
|
||||||
@@ -94,8 +95,7 @@ class ThisUsage_CodeQualityRule extends CodeQualityRule_Abstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Node.js with acorn to parse JavaScript and find violations
|
* Analyze JavaScript file for 'this' usage violations via RPC server
|
||||||
* Uses external parser script stored in resources directory
|
|
||||||
*/
|
*/
|
||||||
private function parse_with_acorn(string $file_path): array
|
private function parse_with_acorn(string $file_path): array
|
||||||
{
|
{
|
||||||
@@ -125,33 +125,8 @@ class ThisUsage_CodeQualityRule extends CodeQualityRule_Abstract
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path to the parser script
|
// Analyze via RPC server (lazy starts if not running)
|
||||||
$parser_script = __DIR__ . '/resource/this-usage-parser.js';
|
$violations = Js_CodeQuality_Rpc::analyze_this($file_path);
|
||||||
|
|
||||||
if (!file_exists($parser_script)) {
|
|
||||||
// Parser script missing - fatal error
|
|
||||||
throw new \RuntimeException("JS-THIS parser script missing: {$parser_script}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run Node.js parser with the external script
|
|
||||||
$output = shell_exec("cd /tmp && node " . escapeshellarg($parser_script) . " " . escapeshellarg($file_path) . " 2>&1");
|
|
||||||
|
|
||||||
if (!$output) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = json_decode($output, true);
|
|
||||||
if (!$result) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for errors from the parser
|
|
||||||
if (isset($result['error'])) {
|
|
||||||
// Parser encountered an error but it's not fatal for the rule
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$violations = $result['violations'] ?? [];
|
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
file_put_contents($cache_file, json_encode($violations));
|
file_put_contents($cache_file, json_encode($violations));
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* JavaScript 'this' usage parser for code quality checks
|
|
||||||
*
|
|
||||||
* PURPOSE: Parse JavaScript files to find 'this' usage violations
|
|
||||||
* according to RSpade coding standards.
|
|
||||||
*
|
|
||||||
* USAGE: node this-usage-parser.js <filepath>
|
|
||||||
* OUTPUT: JSON with violations array
|
|
||||||
*
|
|
||||||
* RULES:
|
|
||||||
* - Anonymous functions can use: const $var = $(this) as first line
|
|
||||||
* - Instance methods must use: const that = this as first line
|
|
||||||
* - Static methods should never use 'this', use ClassName instead
|
|
||||||
* - Arrow functions are ignored (they inherit 'this')
|
|
||||||
*
|
|
||||||
* @FILENAME-CONVENTION-EXCEPTION - Node.js utility script
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const acorn = require('acorn');
|
|
||||||
const walk = require('acorn-walk');
|
|
||||||
|
|
||||||
// Known jQuery callback methods - used for better remediation messages
|
|
||||||
const JQUERY_CALLBACKS = new Set([
|
|
||||||
'click', 'dblclick', 'mouseenter', 'mouseleave', 'mousedown', 'mouseup',
|
|
||||||
'mousemove', 'mouseover', 'mouseout', 'change', 'submit', 'focus', 'blur',
|
|
||||||
'keydown', 'keyup', 'keypress', 'resize', 'scroll', 'select', 'load',
|
|
||||||
'on', 'off', 'one', 'each', 'map', 'filter',
|
|
||||||
'fadeIn', 'fadeOut', 'slideDown', 'slideUp', 'animate',
|
|
||||||
'done', 'fail', 'always', 'then', 'ready', 'hover'
|
|
||||||
]);
|
|
||||||
|
|
||||||
function analyzeFile(filePath) {
|
|
||||||
try {
|
|
||||||
const code = fs.readFileSync(filePath, 'utf8');
|
|
||||||
const lines = code.split('\n');
|
|
||||||
|
|
||||||
let ast;
|
|
||||||
try {
|
|
||||||
ast = acorn.parse(code, {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
locations: true
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Parse error - return empty violations
|
|
||||||
return { violations: [], error: `Parse error: ${e.message}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const violations = [];
|
|
||||||
const classInfo = new Map(); // Track class info
|
|
||||||
|
|
||||||
// First pass: identify all classes and their types
|
|
||||||
walk.simple(ast, {
|
|
||||||
ClassDeclaration(node) {
|
|
||||||
const hasStaticInit = node.body.body.some(member =>
|
|
||||||
member.static && member.key?.name === 'init'
|
|
||||||
);
|
|
||||||
classInfo.set(node.id.name, {
|
|
||||||
isStatic: hasStaticInit
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to check if first line of function has valid pattern
|
|
||||||
function checkFirstLinePattern(funcNode) {
|
|
||||||
if (!funcNode.body || !funcNode.body.body || funcNode.body.body.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let checkIndex = 0;
|
|
||||||
const firstStmt = funcNode.body.body[0];
|
|
||||||
|
|
||||||
// Check if first statement is e.preventDefault() or similar
|
|
||||||
if (firstStmt.type === 'ExpressionStatement' &&
|
|
||||||
firstStmt.expression?.type === 'CallExpression' &&
|
|
||||||
firstStmt.expression?.callee?.type === 'MemberExpression' &&
|
|
||||||
firstStmt.expression?.callee?.property?.name === 'preventDefault') {
|
|
||||||
// First line is preventDefault, check second line for pattern
|
|
||||||
checkIndex = 1;
|
|
||||||
if (funcNode.body.body.length <= 1) {
|
|
||||||
return null; // No second statement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetStmt = funcNode.body.body[checkIndex];
|
|
||||||
if (targetStmt.type !== 'VariableDeclaration') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstDecl = targetStmt.declarations[0];
|
|
||||||
if (!firstDecl || !firstDecl.init) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const varKind = targetStmt.kind; // 'const', 'let', or 'var'
|
|
||||||
|
|
||||||
// Check for 'that = this' pattern
|
|
||||||
if (firstDecl.id.name === 'that' &&
|
|
||||||
firstDecl.init.type === 'ThisExpression') {
|
|
||||||
if (varKind !== 'const') {
|
|
||||||
return 'that-pattern-wrong-kind';
|
|
||||||
}
|
|
||||||
return 'that-pattern';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for 'CurrentClass = this' pattern (for static polymorphism)
|
|
||||||
if (firstDecl.id.name === 'CurrentClass' &&
|
|
||||||
firstDecl.init.type === 'ThisExpression') {
|
|
||||||
if (varKind !== 'const') {
|
|
||||||
return 'currentclass-pattern-wrong-kind';
|
|
||||||
}
|
|
||||||
return 'currentclass-pattern';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for '$var = $(this)' pattern
|
|
||||||
if (firstDecl.id.name.startsWith('$') &&
|
|
||||||
firstDecl.init.type === 'CallExpression' &&
|
|
||||||
firstDecl.init.callee.name === '$' &&
|
|
||||||
firstDecl.init.arguments.length === 1 &&
|
|
||||||
firstDecl.init.arguments[0].type === 'ThisExpression') {
|
|
||||||
if (varKind !== 'const') {
|
|
||||||
return 'jquery-pattern-wrong-kind';
|
|
||||||
}
|
|
||||||
return 'jquery-pattern';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to detect if we're in a jQuery callback (best effort)
|
|
||||||
function isLikelyJQueryCallback(ancestors) {
|
|
||||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
||||||
const node = ancestors[i];
|
|
||||||
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
|
|
||||||
const methodName = node.callee.property.name;
|
|
||||||
if (JQUERY_CALLBACKS.has(methodName)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk the AST looking for 'this' usage
|
|
||||||
walk.ancestor(ast, {
|
|
||||||
ThisExpression(node, ancestors) {
|
|
||||||
// Skip arrow functions - they inherit 'this'
|
|
||||||
for (const ancestor of ancestors) {
|
|
||||||
if (ancestor.type === 'ArrowFunctionExpression') {
|
|
||||||
return; // Skip - arrow functions inherit context
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find containing function and class
|
|
||||||
let containingFunc = null;
|
|
||||||
let containingClass = null;
|
|
||||||
let isAnonymousFunc = false;
|
|
||||||
let isStaticMethod = false;
|
|
||||||
let isConstructor = false;
|
|
||||||
let isInstanceMethod = false;
|
|
||||||
let hasMethodDefinition = false;
|
|
||||||
|
|
||||||
// First pass: check if we're in a MethodDefinition
|
|
||||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
||||||
const ancestor = ancestors[i];
|
|
||||||
if (ancestor.type === 'MethodDefinition') {
|
|
||||||
hasMethodDefinition = true;
|
|
||||||
isStaticMethod = ancestor.static;
|
|
||||||
isConstructor = ancestor.kind === 'constructor';
|
|
||||||
isInstanceMethod = !ancestor.static && ancestor.kind !== 'constructor';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: find function and class
|
|
||||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
||||||
const ancestor = ancestors[i];
|
|
||||||
|
|
||||||
if (!containingFunc && (
|
|
||||||
ancestor.type === 'FunctionExpression' ||
|
|
||||||
ancestor.type === 'FunctionDeclaration'
|
|
||||||
)) {
|
|
||||||
containingFunc = ancestor;
|
|
||||||
// Only mark as anonymous if NOT inside a MethodDefinition
|
|
||||||
isAnonymousFunc = ancestor.type === 'FunctionExpression' && !hasMethodDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!containingClass && (
|
|
||||||
ancestor.type === 'ClassDeclaration' ||
|
|
||||||
ancestor.type === 'ClassExpression'
|
|
||||||
)) {
|
|
||||||
containingClass = ancestor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!containingFunc) {
|
|
||||||
return; // Not in a function
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip constructors - 'this' is allowed for property assignment
|
|
||||||
if (isConstructor) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip instance methods - 'this' is allowed directly in instance methods
|
|
||||||
// Only enforce aliasing for anonymous functions and static methods
|
|
||||||
if (isInstanceMethod) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is part of the allowed first-line pattern with const
|
|
||||||
const parent = ancestors[ancestors.length - 2];
|
|
||||||
const firstStmt = containingFunc.body?.body?.[0];
|
|
||||||
let checkIndex = 0;
|
|
||||||
|
|
||||||
// Check if first statement is preventDefault
|
|
||||||
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
|
|
||||||
firstStmt?.expression?.type === 'CallExpression' &&
|
|
||||||
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
|
|
||||||
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
|
|
||||||
|
|
||||||
if (hasPreventDefault) {
|
|
||||||
checkIndex = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetStmt = containingFunc.body?.body?.[checkIndex];
|
|
||||||
const isTargetConst = targetStmt?.type === 'VariableDeclaration' && targetStmt?.kind === 'const';
|
|
||||||
|
|
||||||
// Check if this 'this' is inside $(this) on the first or second line
|
|
||||||
// For jQuery pattern: const $var = $(this)
|
|
||||||
if (parent && parent.type === 'CallExpression' && parent.callee?.name === '$') {
|
|
||||||
// This is $(this) - check if it's in the right position with const
|
|
||||||
if (isTargetConst &&
|
|
||||||
targetStmt?.declarations?.[0]?.init === parent &&
|
|
||||||
targetStmt?.declarations?.[0]?.id?.name?.startsWith('$')) {
|
|
||||||
return; // This is const $var = $(this) in correct position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this 'this' is the 'const that = this' in correct position
|
|
||||||
if (parent && parent.type === 'VariableDeclarator' &&
|
|
||||||
parent.id?.name === 'that' &&
|
|
||||||
isTargetConst &&
|
|
||||||
targetStmt?.declarations?.[0]?.id?.name === 'that') {
|
|
||||||
return; // This is 'const that = this' in correct position
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this 'this' is the 'const CurrentClass = this' in correct position
|
|
||||||
if (parent && parent.type === 'VariableDeclarator' &&
|
|
||||||
parent.id?.name === 'CurrentClass' &&
|
|
||||||
isTargetConst &&
|
|
||||||
targetStmt?.declarations?.[0]?.id?.name === 'CurrentClass') {
|
|
||||||
return; // This is 'const CurrentClass = this' in correct position
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check what pattern is used
|
|
||||||
const pattern = checkFirstLinePattern(containingFunc);
|
|
||||||
|
|
||||||
// Determine the violation and remediation
|
|
||||||
let message = '';
|
|
||||||
let remediation = '';
|
|
||||||
const lineNum = node.loc.start.line;
|
|
||||||
const codeSnippet = lines[lineNum - 1].trim();
|
|
||||||
const className = containingClass?.id?.name || 'unknown';
|
|
||||||
const isJQueryContext = isLikelyJQueryCallback(ancestors);
|
|
||||||
|
|
||||||
// Anonymous functions take precedence - even if inside a static method
|
|
||||||
if (isAnonymousFunc) {
|
|
||||||
if (!pattern) {
|
|
||||||
// Check if there's a preventDefault on the first line
|
|
||||||
const firstStmt = containingFunc.body?.body?.[0];
|
|
||||||
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
|
|
||||||
firstStmt?.expression?.type === 'CallExpression' &&
|
|
||||||
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
|
|
||||||
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
|
|
||||||
|
|
||||||
if (isJQueryContext) {
|
|
||||||
message = `'this' in jQuery callback should be aliased for clarity.`;
|
|
||||||
if (hasPreventDefault) {
|
|
||||||
remediation = `Add 'const $element = $(this);' as the second line (after preventDefault), then use $element instead of 'this'.`;
|
|
||||||
} else {
|
|
||||||
remediation = `Add 'const $element = $(this);' as the first line of this function, then use $element instead of 'this'.`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message = `Ambiguous 'this' usage in anonymous function.`;
|
|
||||||
if (hasPreventDefault) {
|
|
||||||
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as the second line (after preventDefault).\n` +
|
|
||||||
`If this is an instance context: Add 'const that = this;' as the second line.\n` +
|
|
||||||
`Then use the aliased variable instead of 'this'.`;
|
|
||||||
} else {
|
|
||||||
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as first line.\n` +
|
|
||||||
`If this is an instance context: Add 'const that = this;' as first line.\n` +
|
|
||||||
`Then use the aliased variable instead of 'this'.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (pattern === 'that-pattern' || pattern === 'jquery-pattern') {
|
|
||||||
message = `'this' used after aliasing. Use the aliased variable instead.`;
|
|
||||||
// Find the variable declaration (might be first or second statement)
|
|
||||||
let varDeclIndex = 0;
|
|
||||||
const firstStmt = containingFunc.body?.body?.[0];
|
|
||||||
if (firstStmt?.type === 'ExpressionStatement' &&
|
|
||||||
firstStmt?.expression?.callee?.property?.name === 'preventDefault') {
|
|
||||||
varDeclIndex = 1;
|
|
||||||
}
|
|
||||||
const varName = containingFunc.body?.body?.[varDeclIndex]?.declarations?.[0]?.id?.name;
|
|
||||||
remediation = pattern === 'jquery-pattern'
|
|
||||||
? `You already have 'const ${varName} = $(this)'. Use that variable instead of 'this'.`
|
|
||||||
: `You already have 'const that = this'. Use 'that' instead of 'this'.`;
|
|
||||||
} else if (pattern === 'that-pattern-wrong-kind') {
|
|
||||||
message = `Instance alias must use 'const', not 'let' or 'var'.`;
|
|
||||||
remediation = `Change to 'const that = this;' - the instance reference should never be reassigned.`;
|
|
||||||
} else if (pattern === 'jquery-pattern-wrong-kind') {
|
|
||||||
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
|
|
||||||
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
|
|
||||||
}
|
|
||||||
} else if (isStaticMethod) {
|
|
||||||
if (!pattern) {
|
|
||||||
message = `Static method in '${className}' should not use naked 'this'.`;
|
|
||||||
remediation = `Static methods have two options:\n` +
|
|
||||||
`1. If you need the exact class (no polymorphism): Replace 'this' with '${className}'\n` +
|
|
||||||
`2. If you need polymorphism (inherited classes): Add 'const CurrentClass = this;' as first line\n` +
|
|
||||||
` Then use CurrentClass.name or CurrentClass.property for polymorphic access.\n` +
|
|
||||||
`Consider: Does this.name need to work for child classes? If yes, use CurrentClass pattern.`;
|
|
||||||
} else if (pattern === 'currentclass-pattern') {
|
|
||||||
message = `'this' used after aliasing to CurrentClass. Use 'CurrentClass' instead.`;
|
|
||||||
remediation = `You already have 'const CurrentClass = this;'. Use 'CurrentClass' for all access, not naked 'this'.`;
|
|
||||||
} else if (pattern === 'currentclass-pattern-wrong-kind') {
|
|
||||||
message = `CurrentClass pattern must use 'const', not 'let' or 'var'.`;
|
|
||||||
remediation = `Change to 'const CurrentClass = this;' - the CurrentClass reference should never be reassigned.`;
|
|
||||||
} else if (pattern === 'jquery-pattern') {
|
|
||||||
// jQuery pattern in static method's anonymous function is OK
|
|
||||||
return;
|
|
||||||
} else if (pattern === 'jquery-pattern-wrong-kind') {
|
|
||||||
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
|
|
||||||
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAnonymousFunc && !pattern) {
|
|
||||||
remediation += `\nException: If this is a jQuery callback, add 'const $element = $(this);' as the first line.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// NOTE: Instance methods are exempt from this rule - they can use 'this' directly
|
|
||||||
// The check returns early for instance methods, so this else block is unreachable for them
|
|
||||||
|
|
||||||
if (message) {
|
|
||||||
violations.push({
|
|
||||||
line: lineNum,
|
|
||||||
message: message,
|
|
||||||
codeSnippet: codeSnippet,
|
|
||||||
remediation: remediation
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { violations: violations };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
return { violations: [], error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main execution
|
|
||||||
const filePath = process.argv[2];
|
|
||||||
if (!filePath) {
|
|
||||||
console.log(JSON.stringify({ violations: [], error: 'No file path provided' }));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
console.log(JSON.stringify({ violations: [], error: `File not found: ${filePath}` }));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = analyzeFile(filePath);
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
@@ -81,6 +81,48 @@ class InstanceMethods_CodeQualityRule extends CodeQualityRule_Abstract
|
|||||||
|
|
||||||
// Check JavaScript classes
|
// Check JavaScript classes
|
||||||
$this->check_javascript_classes($files);
|
$this->check_javascript_classes($files);
|
||||||
|
|
||||||
|
// Check JS model monoprogenic enforcement
|
||||||
|
$this->check_js_model_monoprogenic($files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HACK #3 - JS Model Monoprogenic Enforcement
|
||||||
|
*
|
||||||
|
* Ensures that JS model classes cannot be extended. Only Base_ stubs can be extended,
|
||||||
|
* not the PHP model class names directly. This prevents broken inheritance chains.
|
||||||
|
*
|
||||||
|
* Monoprogenic = can only produce one level of offspring
|
||||||
|
*/
|
||||||
|
private function check_js_model_monoprogenic(array $files): void
|
||||||
|
{
|
||||||
|
foreach ($files as $file => $metadata) {
|
||||||
|
// Skip if not a JavaScript class
|
||||||
|
if (!isset($metadata['class']) || ($metadata['extension'] ?? '') !== 'js') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no parent class
|
||||||
|
$parent_class = $metadata['extends'] ?? null;
|
||||||
|
if (!$parent_class) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if parent is a PHP model class name (not Base_)
|
||||||
|
if (\App\RSpade\Core\Manifest\Manifest::is_php_model_class($parent_class)) {
|
||||||
|
$this->add_violation(
|
||||||
|
$file,
|
||||||
|
1,
|
||||||
|
"Class '{$metadata['class']}' extends PHP model '{$parent_class}' directly. " .
|
||||||
|
"JS model classes are monoprogenic - they cannot be extended further.",
|
||||||
|
"extends {$parent_class}",
|
||||||
|
"JavaScript model classes must extend the Base_ stub instead:\n" .
|
||||||
|
" - Change: class {$metadata['class']} extends Base_{$parent_class}\n" .
|
||||||
|
" - The Base_ stub is auto-generated and properly inherits from Rsx_Js_Model",
|
||||||
|
'critical'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,6 +305,17 @@ class InstanceMethods_CodeQualityRule extends CodeQualityRule_Abstract
|
|||||||
*/
|
*/
|
||||||
private function is_js_class_instantiatable(string $class_name, array $files): bool
|
private function is_js_class_instantiatable(string $class_name, array $files): bool
|
||||||
{
|
{
|
||||||
|
// HACK #2 - JS Model instantiatable bypass: If this is a PHP model class name or
|
||||||
|
// a Base_ stub for a PHP model, it's automatically instantiatable because model
|
||||||
|
// stubs are generated with @Instantiatable during bundle compilation.
|
||||||
|
$model_check_name = $class_name;
|
||||||
|
if (str_starts_with($class_name, 'Base_')) {
|
||||||
|
$model_check_name = substr($class_name, 5); // Remove "Base_" prefix
|
||||||
|
}
|
||||||
|
if (\App\RSpade\Core\Manifest\Manifest::is_php_model_class($model_check_name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Find the class metadata
|
// Find the class metadata
|
||||||
$class_metadata = null;
|
$class_metadata = null;
|
||||||
foreach ($files as $file => $metadata) {
|
foreach ($files as $file => $metadata) {
|
||||||
|
|||||||
150
app/RSpade/CodeQuality/Rules/Models/ModelAjaxFetchAttribute_CodeQualityRule.php
Executable file
150
app/RSpade/CodeQuality/Rules/Models/ModelAjaxFetchAttribute_CodeQualityRule.php
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\CodeQuality\Rules\Models;
|
||||||
|
|
||||||
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates #[Ajax_Endpoint_Model_Fetch] attribute placement
|
||||||
|
*
|
||||||
|
* This attribute can ONLY be placed on:
|
||||||
|
* 1. Methods marked with #[Relationship] - exposes relationship to JavaScript
|
||||||
|
* 2. The static fetch($id) method - enables Model.fetch() in JavaScript
|
||||||
|
*
|
||||||
|
* For custom server-side methods that need JavaScript access:
|
||||||
|
* - Create a JS class extending Base_{Model}
|
||||||
|
* - Create an Ajax endpoint on an appropriate controller
|
||||||
|
* - Add the method to the JS class calling the Ajax endpoint
|
||||||
|
*/
|
||||||
|
class ModelAjaxFetchAttribute_CodeQualityRule extends CodeQualityRule_Abstract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the unique rule identifier
|
||||||
|
*/
|
||||||
|
public function get_id(): string
|
||||||
|
{
|
||||||
|
return 'MODEL-AJAX-FETCH-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rule name
|
||||||
|
*/
|
||||||
|
public function get_name(): string
|
||||||
|
{
|
||||||
|
return 'Ajax Endpoint Model Fetch Attribute Validation';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rule description
|
||||||
|
*/
|
||||||
|
public function get_description(): string
|
||||||
|
{
|
||||||
|
return 'Validates #[Ajax_Endpoint_Model_Fetch] is only used on relationships or static fetch($id)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file patterns this rule applies to
|
||||||
|
*/
|
||||||
|
public function get_file_patterns(): array
|
||||||
|
{
|
||||||
|
return ['*.php'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default severity
|
||||||
|
*/
|
||||||
|
public function get_default_severity(): string
|
||||||
|
{
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this rule runs during manifest scan
|
||||||
|
*/
|
||||||
|
public function is_called_during_manifest_scan(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the rule check
|
||||||
|
*/
|
||||||
|
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||||
|
{
|
||||||
|
// Only check PHP files
|
||||||
|
if (!str_ends_with($file_path, '.php')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a model class (extends Rsx_Model_Abstract or related)
|
||||||
|
$extends = $metadata['extends'] ?? null;
|
||||||
|
$class_name = $metadata['class'] ?? null;
|
||||||
|
|
||||||
|
if (!$class_name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all methods for the attribute
|
||||||
|
$all_methods = array_merge(
|
||||||
|
$metadata['static_methods'] ?? [],
|
||||||
|
$metadata['public_instance_methods'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
$lines = explode("\n", $contents);
|
||||||
|
|
||||||
|
foreach ($all_methods as $method_name => $method_data) {
|
||||||
|
// Check if method has Ajax_Endpoint_Model_Fetch attribute
|
||||||
|
$attributes = $method_data['attributes'] ?? [];
|
||||||
|
if (!isset($attributes['Ajax_Endpoint_Model_Fetch'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid case 1: Method is static fetch($id)
|
||||||
|
if ($method_name === 'fetch') {
|
||||||
|
$is_static = isset($metadata['static_methods']['fetch']);
|
||||||
|
if ($is_static) {
|
||||||
|
continue; // Valid use
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid case 2: Method has #[Relationship] attribute
|
||||||
|
if (isset($attributes['Relationship'])) {
|
||||||
|
continue; // Valid use
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid use - find line number
|
||||||
|
$line_number = $this->_find_method_line($lines, $method_name);
|
||||||
|
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_number,
|
||||||
|
"#[Ajax_Endpoint_Model_Fetch] can only be applied to:\n" .
|
||||||
|
" 1. Methods marked with #[Relationship] (exposes relationship to JavaScript)\n" .
|
||||||
|
" 2. The static fetch(\$id) method (enables Model.fetch() in JavaScript)\n\n" .
|
||||||
|
"For custom server-side methods that need JavaScript access:\n" .
|
||||||
|
" 1. Create a JavaScript class with the same name as the PHP model, extending Base_{ModelName}\n" .
|
||||||
|
" 2. Create an Ajax endpoint on an appropriate controller\n" .
|
||||||
|
" 3. Add the method to the JS class and have it call the controller Ajax endpoint",
|
||||||
|
"#[Ajax_Endpoint_Model_Fetch] on {$method_name}()",
|
||||||
|
"Remove #[Ajax_Endpoint_Model_Fetch] and follow the custom method pattern above",
|
||||||
|
'critical'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the line number of a method declaration
|
||||||
|
*/
|
||||||
|
private function _find_method_line(array $lines, string $method_name): int
|
||||||
|
{
|
||||||
|
$pattern = '/function\s+' . preg_quote($method_name, '/') . '\s*\(/';
|
||||||
|
|
||||||
|
for ($i = 0; $i < count($lines); $i++) {
|
||||||
|
if (preg_match($pattern, $lines[$i])) {
|
||||||
|
return $i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1; // Default to first line if not found
|
||||||
|
}
|
||||||
|
}
|
||||||
316
app/RSpade/CodeQuality/Rules/PHP/EndpointAuthCheck_CodeQualityRule.php
Executable file
316
app/RSpade/CodeQuality/Rules/PHP/EndpointAuthCheck_CodeQualityRule.php
Executable file
@@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\CodeQuality\Rules\PHP;
|
||||||
|
|
||||||
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EndpointAuthCheckRule - Validates that controller endpoints have auth checks
|
||||||
|
*
|
||||||
|
* This rule ensures that all #[Route], #[SPA], and #[Ajax_Endpoint] methods
|
||||||
|
* have authentication checks, either:
|
||||||
|
* - In the method body itself, OR
|
||||||
|
* - In a pre_dispatch() method in the same controller
|
||||||
|
*
|
||||||
|
* Valid auth check patterns:
|
||||||
|
* - Session::is_logged_in()
|
||||||
|
* - Session::get_user()
|
||||||
|
* - Permission::has_permission()
|
||||||
|
* - Permission::has_role()
|
||||||
|
* - response_unauthorized()
|
||||||
|
*
|
||||||
|
* Exemption:
|
||||||
|
* - Add @auth-exempt comment with reason to skip check for public endpoints
|
||||||
|
*/
|
||||||
|
class EndpointAuthCheck_CodeQualityRule extends CodeQualityRule_Abstract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the unique rule identifier
|
||||||
|
*/
|
||||||
|
public function get_id(): string
|
||||||
|
{
|
||||||
|
return 'PHP-AUTH-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable rule name
|
||||||
|
*/
|
||||||
|
public function get_name(): string
|
||||||
|
{
|
||||||
|
return 'Endpoint Authentication Check';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rule description
|
||||||
|
*/
|
||||||
|
public function get_description(): string
|
||||||
|
{
|
||||||
|
return 'Validates that controller endpoints (#[Route], #[SPA], #[Ajax_Endpoint]) have authentication checks';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file patterns this rule applies to
|
||||||
|
*/
|
||||||
|
public function get_file_patterns(): array
|
||||||
|
{
|
||||||
|
return ['*.php'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this rule is called during manifest scan
|
||||||
|
*/
|
||||||
|
public function is_called_during_manifest_scan(): bool
|
||||||
|
{
|
||||||
|
return false; // Only run during rsx:check
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default severity for this rule
|
||||||
|
*/
|
||||||
|
public function get_default_severity(): string
|
||||||
|
{
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns that indicate an auth check is present
|
||||||
|
*/
|
||||||
|
private const AUTH_CHECK_PATTERNS = [
|
||||||
|
'Session::is_logged_in',
|
||||||
|
'Session::get_user',
|
||||||
|
'Session::get_user_id',
|
||||||
|
'Permission::has_permission',
|
||||||
|
'Permission::has_role',
|
||||||
|
'Permission::authenticated',
|
||||||
|
'Permission::require_permission',
|
||||||
|
'Permission::require_role',
|
||||||
|
'response_unauthorized',
|
||||||
|
'->has_permission(',
|
||||||
|
'->has_role(',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a file for violations
|
||||||
|
*/
|
||||||
|
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||||
|
{
|
||||||
|
// Read original file content (not sanitized) for comment checking
|
||||||
|
$original_contents = file_get_contents($file_path);
|
||||||
|
|
||||||
|
// Skip if file-level exception comment is present
|
||||||
|
if (strpos($original_contents, '@' . $this->get_id() . '-EXCEPTION') !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if class-level @auth-exempt comment is present (all endpoints public)
|
||||||
|
if (strpos($original_contents, '@auth-exempt') !== false) {
|
||||||
|
// Check if @auth-exempt appears before class definition (in class docblock)
|
||||||
|
// Use regex to find actual class definition, not 'class' in use statements
|
||||||
|
if (preg_match('/^(abstract\s+)?class\s+\w+/m', $original_contents, $matches, PREG_OFFSET_CAPTURE)) {
|
||||||
|
$class_pos = $matches[0][1];
|
||||||
|
$exempt_pos = strpos($original_contents, '@auth-exempt');
|
||||||
|
if ($exempt_pos !== false && $exempt_pos < $class_pos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check controller files (must extend Rsx_Controller_Abstract)
|
||||||
|
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Rsx_Controller_Abstract') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip archived files
|
||||||
|
if (str_contains($file_path, '/archive/') || str_contains($file_path, '/archived/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the class name
|
||||||
|
$class_name = $metadata['class'] ?? null;
|
||||||
|
if (!$class_name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pre_dispatch has auth check
|
||||||
|
$pre_dispatch_has_auth = $this->pre_dispatch_has_auth_check($contents, $metadata);
|
||||||
|
|
||||||
|
// Get public static methods from metadata
|
||||||
|
$methods = $metadata['public_static_methods'] ?? [];
|
||||||
|
|
||||||
|
foreach ($methods as $method_name => $method_info) {
|
||||||
|
// Skip pre_dispatch itself
|
||||||
|
if ($method_name === 'pre_dispatch') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if method has endpoint attributes
|
||||||
|
$has_endpoint_attr = false;
|
||||||
|
$endpoint_type = null;
|
||||||
|
$attributes = $method_info['attributes'] ?? [];
|
||||||
|
|
||||||
|
foreach ($attributes as $attr_name => $attr_data) {
|
||||||
|
$short_name = basename(str_replace('\\', '/', $attr_name));
|
||||||
|
if (in_array($short_name, ['Route', 'SPA', 'Ajax_Endpoint'])) {
|
||||||
|
$has_endpoint_attr = true;
|
||||||
|
$endpoint_type = $short_name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip methods without endpoint attributes
|
||||||
|
if (!$has_endpoint_attr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get line number for this method
|
||||||
|
$line_number = $method_info['line'] ?? 1;
|
||||||
|
|
||||||
|
// Check if method has @auth-exempt comment
|
||||||
|
if ($this->method_has_auth_exempt($original_contents, $method_name, $line_number)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pre_dispatch has auth check, this endpoint is covered
|
||||||
|
if ($pre_dispatch_has_auth) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if method body has auth check
|
||||||
|
$method_body = $this->extract_method_body($contents, $method_name);
|
||||||
|
if ($method_body && $this->body_has_auth_check($method_body)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Violation found - no auth check
|
||||||
|
$code_snippet = "#[{$endpoint_type}]\npublic static function {$method_name}(...)";
|
||||||
|
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_number,
|
||||||
|
"Endpoint '{$method_name}' has no authentication check",
|
||||||
|
$code_snippet,
|
||||||
|
$this->build_suggestion($method_name, $class_name),
|
||||||
|
'high'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pre_dispatch method has an auth check
|
||||||
|
*/
|
||||||
|
private function pre_dispatch_has_auth_check(string $contents, array $metadata): bool
|
||||||
|
{
|
||||||
|
$methods = $metadata['public_static_methods'] ?? [];
|
||||||
|
|
||||||
|
if (!isset($methods['pre_dispatch'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pre_dispatch_body = $this->extract_method_body($contents, 'pre_dispatch');
|
||||||
|
if (!$pre_dispatch_body) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->body_has_auth_check($pre_dispatch_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a code body has an auth check pattern
|
||||||
|
*/
|
||||||
|
private function body_has_auth_check(string $body): bool
|
||||||
|
{
|
||||||
|
foreach (self::AUTH_CHECK_PATTERNS as $pattern) {
|
||||||
|
if (str_contains($body, $pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a method has @auth-exempt comment
|
||||||
|
*/
|
||||||
|
private function method_has_auth_exempt(string $contents, string $method_name, int $method_line): bool
|
||||||
|
{
|
||||||
|
$lines = explode("\n", $contents);
|
||||||
|
|
||||||
|
// Check the 10 lines before the method definition for @auth-exempt
|
||||||
|
$start_line = max(0, $method_line - 11);
|
||||||
|
$end_line = $method_line - 1;
|
||||||
|
|
||||||
|
for ($i = $start_line; $i <= $end_line && $i < count($lines); $i++) {
|
||||||
|
$line = $lines[$i];
|
||||||
|
if (str_contains($line, '@auth-exempt')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Stop if we hit another method definition
|
||||||
|
if (preg_match('/^\s*public\s+static\s+function\s+/', $line) && !str_contains($line, $method_name)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract method body from file contents
|
||||||
|
*/
|
||||||
|
private function extract_method_body(string $contents, string $method_name): ?string
|
||||||
|
{
|
||||||
|
// Pattern to match method definition
|
||||||
|
$pattern = '/public\s+static\s+function\s+' . preg_quote($method_name, '/') . '\s*\([^)]*\)[^{]*\{/s';
|
||||||
|
|
||||||
|
if (!preg_match($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start_pos = $matches[0][1] + strlen($matches[0][0]) - 1;
|
||||||
|
$brace_count = 1;
|
||||||
|
$pos = $start_pos + 1;
|
||||||
|
$length = strlen($contents);
|
||||||
|
|
||||||
|
while ($pos < $length && $brace_count > 0) {
|
||||||
|
$char = $contents[$pos];
|
||||||
|
if ($char === '{') {
|
||||||
|
$brace_count++;
|
||||||
|
} elseif ($char === '}') {
|
||||||
|
$brace_count--;
|
||||||
|
}
|
||||||
|
$pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($contents, $start_pos, $pos - $start_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build suggestion for fixing the violation
|
||||||
|
*/
|
||||||
|
private function build_suggestion(string $method_name, string $class_name): string
|
||||||
|
{
|
||||||
|
$suggestions = [];
|
||||||
|
$suggestions[] = "Endpoint '{$method_name}' needs an authentication check.";
|
||||||
|
$suggestions[] = "";
|
||||||
|
$suggestions[] = "Option 1: Add auth check to pre_dispatch() (recommended for all endpoints in controller):";
|
||||||
|
$suggestions[] = " public static function pre_dispatch(Request \$request, array \$params = [])";
|
||||||
|
$suggestions[] = " {";
|
||||||
|
$suggestions[] = " if (!Session::is_logged_in()) {";
|
||||||
|
$suggestions[] = " return response_unauthorized();";
|
||||||
|
$suggestions[] = " }";
|
||||||
|
$suggestions[] = " return null;";
|
||||||
|
$suggestions[] = " }";
|
||||||
|
$suggestions[] = "";
|
||||||
|
$suggestions[] = "Option 2: Add auth check at start of method body:";
|
||||||
|
$suggestions[] = " if (!Session::is_logged_in()) {";
|
||||||
|
$suggestions[] = " return response_unauthorized();";
|
||||||
|
$suggestions[] = " }";
|
||||||
|
$suggestions[] = "";
|
||||||
|
$suggestions[] = "Option 3: Mark as public endpoint with @auth-exempt comment:";
|
||||||
|
$suggestions[] = " /**";
|
||||||
|
$suggestions[] = " * @auth-exempt Public endpoint for webhook receivers";
|
||||||
|
$suggestions[] = " */";
|
||||||
|
$suggestions[] = " #[Ajax_Endpoint]";
|
||||||
|
$suggestions[] = " public static function {$method_name}(...)";
|
||||||
|
|
||||||
|
return implode("\n", $suggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
276
app/RSpade/CodeQuality/Rules/PHP/ModelFetchAuthCheck_CodeQualityRule.php
Executable file
276
app/RSpade/CodeQuality/Rules/PHP/ModelFetchAuthCheck_CodeQualityRule.php
Executable file
@@ -0,0 +1,276 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\CodeQuality\Rules\PHP;
|
||||||
|
|
||||||
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelFetchAuthCheckRule - Validates that model fetch() methods have auth checks
|
||||||
|
*
|
||||||
|
* When a model has a fetch() method with the #[Ajax_Endpoint_Model_Fetch] attribute,
|
||||||
|
* it becomes callable from JavaScript via Orm_Controller. The fetch() method is
|
||||||
|
* responsible for implementing its own authorization checks.
|
||||||
|
*
|
||||||
|
* This rule ensures that fetch() methods in Rsx_Model_Abstract subclasses
|
||||||
|
* contain auth check patterns when they have the Ajax_Endpoint_Model_Fetch attribute.
|
||||||
|
*
|
||||||
|
* Valid auth check patterns:
|
||||||
|
* - Session::is_logged_in()
|
||||||
|
* - Session::get_user()
|
||||||
|
* - Permission::has_permission()
|
||||||
|
* - Permission::has_role()
|
||||||
|
* - response_unauthorized()
|
||||||
|
* - ->site_id check (verifies ownership)
|
||||||
|
*
|
||||||
|
* Exemption:
|
||||||
|
* - Add @auth-exempt comment with reason for public data fetch
|
||||||
|
*/
|
||||||
|
class ModelFetchAuthCheck_CodeQualityRule extends CodeQualityRule_Abstract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the unique rule identifier
|
||||||
|
*/
|
||||||
|
public function get_id(): string
|
||||||
|
{
|
||||||
|
return 'PHP-MODEL-FETCH-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable rule name
|
||||||
|
*/
|
||||||
|
public function get_name(): string
|
||||||
|
{
|
||||||
|
return 'Model Fetch Authentication Check';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rule description
|
||||||
|
*/
|
||||||
|
public function get_description(): string
|
||||||
|
{
|
||||||
|
return 'Validates that model fetch() methods with #[Ajax_Endpoint_Model_Fetch] have authentication checks';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file patterns this rule applies to
|
||||||
|
*/
|
||||||
|
public function get_file_patterns(): array
|
||||||
|
{
|
||||||
|
return ['*_model.php', '*_Model.php'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this rule is called during manifest scan
|
||||||
|
*/
|
||||||
|
public function is_called_during_manifest_scan(): bool
|
||||||
|
{
|
||||||
|
return false; // Only run during rsx:check
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default severity for this rule
|
||||||
|
*/
|
||||||
|
public function get_default_severity(): string
|
||||||
|
{
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns that indicate an auth check is present
|
||||||
|
*/
|
||||||
|
private const AUTH_CHECK_PATTERNS = [
|
||||||
|
'Session::is_logged_in',
|
||||||
|
'Session::get_user',
|
||||||
|
'Session::get_user_id',
|
||||||
|
'Session::get_site_id',
|
||||||
|
'Permission::has_permission',
|
||||||
|
'Permission::has_role',
|
||||||
|
'response_unauthorized',
|
||||||
|
'->has_permission(',
|
||||||
|
'->has_role(',
|
||||||
|
'->site_id', // Checking ownership via site_id
|
||||||
|
'get_site_id()', // Session site check
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a file for violations
|
||||||
|
*/
|
||||||
|
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||||
|
{
|
||||||
|
// Read original file content (not sanitized) for comment checking
|
||||||
|
$original_contents = file_get_contents($file_path);
|
||||||
|
|
||||||
|
// Skip if file-level exception comment is present
|
||||||
|
if (strpos($original_contents, '@' . $this->get_id() . '-EXCEPTION') !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check model files (must extend Rsx_Model_Abstract)
|
||||||
|
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Rsx_Model_Abstract') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip archived files
|
||||||
|
if (str_contains($file_path, '/archive/') || str_contains($file_path, '/archived/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the class name
|
||||||
|
$class_name = $metadata['class'] ?? null;
|
||||||
|
if (!$class_name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get public static methods from metadata
|
||||||
|
$methods = $metadata['public_static_methods'] ?? [];
|
||||||
|
|
||||||
|
// Check if fetch() method exists
|
||||||
|
if (!isset($methods['fetch'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetch_info = $methods['fetch'];
|
||||||
|
$attributes = $fetch_info['attributes'] ?? [];
|
||||||
|
|
||||||
|
// Check if fetch has Ajax_Endpoint_Model_Fetch attribute
|
||||||
|
$has_fetch_attribute = false;
|
||||||
|
foreach ($attributes as $attr_name => $attr_data) {
|
||||||
|
$short_name = basename(str_replace('\\', '/', $attr_name));
|
||||||
|
if ($short_name === 'Ajax_Endpoint_Model_Fetch') {
|
||||||
|
$has_fetch_attribute = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if fetch doesn't have the attribute (not exposed via ORM)
|
||||||
|
if (!$has_fetch_attribute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get line number for fetch method
|
||||||
|
$line_number = $fetch_info['line'] ?? 1;
|
||||||
|
|
||||||
|
// Check if method has @auth-exempt comment
|
||||||
|
if ($this->method_has_auth_exempt($original_contents, 'fetch', $line_number)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if method body has auth check
|
||||||
|
$method_body = $this->extract_method_body($contents, 'fetch');
|
||||||
|
if ($method_body && $this->body_has_auth_check($method_body)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Violation found - no auth check
|
||||||
|
$code_snippet = "#[Ajax_Endpoint_Model_Fetch]\npublic static function fetch(\$id)";
|
||||||
|
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_number,
|
||||||
|
"Model fetch() method has no authentication check",
|
||||||
|
$code_snippet,
|
||||||
|
$this->build_suggestion($class_name),
|
||||||
|
'high'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a code body has an auth check pattern
|
||||||
|
*/
|
||||||
|
private function body_has_auth_check(string $body): bool
|
||||||
|
{
|
||||||
|
foreach (self::AUTH_CHECK_PATTERNS as $pattern) {
|
||||||
|
if (str_contains($body, $pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a method has @auth-exempt comment
|
||||||
|
*/
|
||||||
|
private function method_has_auth_exempt(string $contents, string $method_name, int $method_line): bool
|
||||||
|
{
|
||||||
|
$lines = explode("\n", $contents);
|
||||||
|
|
||||||
|
// Check the 10 lines before the method definition for @auth-exempt
|
||||||
|
$start_line = max(0, $method_line - 11);
|
||||||
|
$end_line = $method_line - 1;
|
||||||
|
|
||||||
|
for ($i = $start_line; $i <= $end_line && $i < count($lines); $i++) {
|
||||||
|
$line = $lines[$i];
|
||||||
|
if (str_contains($line, '@auth-exempt')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Stop if we hit another method definition
|
||||||
|
if (preg_match('/^\s*public\s+static\s+function\s+/', $line) && !str_contains($line, $method_name)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract method body from file contents
|
||||||
|
*/
|
||||||
|
private function extract_method_body(string $contents, string $method_name): ?string
|
||||||
|
{
|
||||||
|
// Pattern to match method definition
|
||||||
|
$pattern = '/public\s+static\s+function\s+' . preg_quote($method_name, '/') . '\s*\([^)]*\)[^{]*\{/s';
|
||||||
|
|
||||||
|
if (!preg_match($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start_pos = $matches[0][1] + strlen($matches[0][0]) - 1;
|
||||||
|
$brace_count = 1;
|
||||||
|
$pos = $start_pos + 1;
|
||||||
|
$length = strlen($contents);
|
||||||
|
|
||||||
|
while ($pos < $length && $brace_count > 0) {
|
||||||
|
$char = $contents[$pos];
|
||||||
|
if ($char === '{') {
|
||||||
|
$brace_count++;
|
||||||
|
} elseif ($char === '}') {
|
||||||
|
$brace_count--;
|
||||||
|
}
|
||||||
|
$pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($contents, $start_pos, $pos - $start_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build suggestion for fixing the violation
|
||||||
|
*/
|
||||||
|
private function build_suggestion(string $class_name): string
|
||||||
|
{
|
||||||
|
$suggestions = [];
|
||||||
|
$suggestions[] = "Model fetch() method needs an authentication/authorization check.";
|
||||||
|
$suggestions[] = "";
|
||||||
|
$suggestions[] = "Option 1: Check user is logged in and verify ownership:";
|
||||||
|
$suggestions[] = " #[Ajax_Endpoint_Model_Fetch]";
|
||||||
|
$suggestions[] = " public static function fetch(\$id)";
|
||||||
|
$suggestions[] = " {";
|
||||||
|
$suggestions[] = " if (!Session::is_logged_in()) {";
|
||||||
|
$suggestions[] = " return null; // or response_unauthorized()";
|
||||||
|
$suggestions[] = " }";
|
||||||
|
$suggestions[] = " \$record = static::find(\$id);";
|
||||||
|
$suggestions[] = " if (!\$record || \$record->site_id !== Session::get_site_id()) {";
|
||||||
|
$suggestions[] = " return null; // Wrong site or not found";
|
||||||
|
$suggestions[] = " }";
|
||||||
|
$suggestions[] = " return \$record;";
|
||||||
|
$suggestions[] = " }";
|
||||||
|
$suggestions[] = "";
|
||||||
|
$suggestions[] = "Option 2: If this is intentionally public data, add @auth-exempt:";
|
||||||
|
$suggestions[] = " /**";
|
||||||
|
$suggestions[] = " * @auth-exempt Public reference data (countries, etc.)";
|
||||||
|
$suggestions[] = " */";
|
||||||
|
$suggestions[] = " #[Ajax_Endpoint_Model_Fetch]";
|
||||||
|
$suggestions[] = " public static function fetch(\$id) { ... }";
|
||||||
|
|
||||||
|
return implode("\n", $suggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,3 +55,56 @@ After RPC: Single Node.js process, reused across all sanitizations (~1-2s startu
|
|||||||
|
|
||||||
### Parallel to JS Parser
|
### Parallel to JS Parser
|
||||||
This architecture mirrors the JS parser RPC server pattern. See `/app/RSpade/Core/JavaScript/CLAUDE.md` for detailed RPC pattern documentation.
|
This architecture mirrors the JS parser RPC server pattern. See `/app/RSpade/Core/JavaScript/CLAUDE.md` for detailed RPC pattern documentation.
|
||||||
|
|
||||||
|
## Js_CodeQuality_Rpc - RPC Server Architecture
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
JavaScript linting and this-usage analysis use a long-running Node.js RPC server via Unix socket to avoid spawning thousands of Node processes during code quality checks.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- `Js_CodeQuality_Rpc.php` - PHP client, manages server lifecycle
|
||||||
|
- `js-code-quality-server.js` - Node.js RPC server, processes lint and this-usage analysis requests
|
||||||
|
|
||||||
|
### Server Lifecycle
|
||||||
|
1. **Lazy start:** Server spawns on first lint or analyze_this call during code quality checks
|
||||||
|
2. **Startup:** Checks for stale socket, force-kills if found, starts fresh server
|
||||||
|
3. **Wait:** Polls socket with ping (50ms intervals, 10s max), fatal error if timeout
|
||||||
|
4. **Usage:** All JS linting and this-usage analysis goes through RPC
|
||||||
|
5. **Shutdown:** Graceful shutdown when code quality checks complete (registered shutdown handler)
|
||||||
|
|
||||||
|
### Socket
|
||||||
|
- **Path:** `storage/rsx-tmp/js-code-quality-server.sock`
|
||||||
|
- **Protocol:** Line-delimited JSON over Unix domain socket
|
||||||
|
|
||||||
|
### RPC Methods
|
||||||
|
- `ping` → `"pong"` - Health check
|
||||||
|
- `lint` → `{file: {status, error}, ...}` - Check JavaScript syntax using Babel parser
|
||||||
|
- `analyze_this` → `{file: {status, violations}, ...}` - Analyze 'this' usage patterns using Acorn
|
||||||
|
- `shutdown` → Graceful server termination
|
||||||
|
|
||||||
|
### PHP API
|
||||||
|
```php
|
||||||
|
Js_CodeQuality_Rpc::lint($file_path); // Returns error array or null
|
||||||
|
Js_CodeQuality_Rpc::analyze_this($file_path); // Returns violations array
|
||||||
|
Js_CodeQuality_Rpc::start_rpc_server(); // Lazy init, auto-called
|
||||||
|
Js_CodeQuality_Rpc::stop_rpc_server($force); // Clean shutdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force Parameter
|
||||||
|
`stop_rpc_server($force = false)`:
|
||||||
|
- `false` (default): Send shutdown command, return immediately
|
||||||
|
- `true`: Send shutdown + wait + SIGTERM if needed (used for stale server cleanup)
|
||||||
|
|
||||||
|
### Cache Integration
|
||||||
|
Both lint and analyze_this have their own caching layers:
|
||||||
|
- **Lint cache:** Flag files in `storage/rsx-tmp/cache/js-lint-passed/` (mtime-based)
|
||||||
|
- **This-usage cache:** JSON files in `storage/rsx-tmp/cache/code-quality/js-this/` (mtime-based)
|
||||||
|
|
||||||
|
Cache is checked before RPC call - only files with stale cache are sent to the server.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
Server failure → fatal error for lint, silent failure for analyze_this.
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
Before RPC: Thousands of Node.js process spawns during rsx:check (~20+ seconds on first run)
|
||||||
|
After RPC: Single Node.js process, reused across all operations (~1-2s startup overhead)
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class InitializationSuggestions
|
|||||||
} elseif ($is_user_code) {
|
} elseif ($is_user_code) {
|
||||||
return "Use ES6 class lifecycle methods:\n" .
|
return "Use ES6 class lifecycle methods:\n" .
|
||||||
" - on_app_ready() - For final initialization (most common)\n" .
|
" - on_app_ready() - For final initialization (most common)\n" .
|
||||||
" - on_jqhtml_ready() - After all JQHTML components loaded\n" .
|
|
||||||
" - on_app_init() - For app-level setup\n" .
|
" - on_app_init() - For app-level setup\n" .
|
||||||
" - on_modules_init() - For module initialization\n" .
|
" - on_modules_init() - For module initialization\n" .
|
||||||
" - on_modules_define() - For module metadata registration\n" .
|
" - on_modules_define() - For module metadata registration\n" .
|
||||||
@@ -61,7 +60,6 @@ class InitializationSuggestions
|
|||||||
{
|
{
|
||||||
return "Use user code lifecycle methods instead:\n" .
|
return "Use user code lifecycle methods instead:\n" .
|
||||||
" - on_app_ready() - For final initialization (most common)\n" .
|
" - on_app_ready() - For final initialization (most common)\n" .
|
||||||
" - on_jqhtml_ready() - After all JQHTML components loaded\n" .
|
|
||||||
" - on_app_init() - For app-level setup\n" .
|
" - on_app_init() - For app-level setup\n" .
|
||||||
" - on_modules_init() - For module initialization\n" .
|
" - on_modules_init() - For module initialization\n" .
|
||||||
" - on_modules_define() - For module metadata registration";
|
" - on_modules_define() - For module metadata registration";
|
||||||
|
|||||||
352
app/RSpade/CodeQuality/Support/Js_CodeQuality_Rpc.php
Executable file
352
app/RSpade/CodeQuality/Support/Js_CodeQuality_Rpc.php
Executable file
@@ -0,0 +1,352 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\CodeQuality\Support;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaScript Code Quality RPC Client
|
||||||
|
*
|
||||||
|
* Manages a persistent Node.js RPC server for JavaScript linting and
|
||||||
|
* this-usage analysis. This avoids spawning thousands of Node processes
|
||||||
|
* during code quality checks.
|
||||||
|
*
|
||||||
|
* RPC Methods:
|
||||||
|
* - lint: Check JavaScript syntax using Babel parser
|
||||||
|
* - analyze_this: Analyze 'this' usage patterns using Acorn
|
||||||
|
*/
|
||||||
|
class Js_CodeQuality_Rpc
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Node.js RPC server script path
|
||||||
|
*/
|
||||||
|
protected const RPC_SERVER_SCRIPT = 'app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unix socket path for RPC server
|
||||||
|
*/
|
||||||
|
protected const RPC_SOCKET = 'storage/rsx-tmp/js-code-quality-server.sock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC server process
|
||||||
|
*/
|
||||||
|
protected static $rpc_server_process = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request ID counter
|
||||||
|
*/
|
||||||
|
protected static $request_id = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lint a JavaScript file for syntax errors
|
||||||
|
*
|
||||||
|
* @param string $file_path Path to the JavaScript file
|
||||||
|
* @return array|null Error info array or null if no errors
|
||||||
|
*/
|
||||||
|
public static function lint(string $file_path): ?array
|
||||||
|
{
|
||||||
|
// Start RPC server on first use (lazy initialization)
|
||||||
|
if (static::$rpc_server_process === null) {
|
||||||
|
static::start_rpc_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::_lint_via_rpc($file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze a JavaScript file for 'this' usage violations
|
||||||
|
*
|
||||||
|
* @param string $file_path Path to the JavaScript file
|
||||||
|
* @return array Violations array (may be empty)
|
||||||
|
*/
|
||||||
|
public static function analyze_this(string $file_path): array
|
||||||
|
{
|
||||||
|
// Start RPC server on first use (lazy initialization)
|
||||||
|
if (static::$rpc_server_process === null) {
|
||||||
|
static::start_rpc_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::_analyze_this_via_rpc($file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lint via RPC server
|
||||||
|
*/
|
||||||
|
protected static function _lint_via_rpc(string $file_path): ?array
|
||||||
|
{
|
||||||
|
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
|
||||||
|
$socket_path = $base_path . '/' . self::RPC_SOCKET;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
|
||||||
|
if (!$sock) {
|
||||||
|
throw new \RuntimeException("Failed to connect to RPC server: {$errstr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set blocking mode for reliable reads
|
||||||
|
stream_set_blocking($sock, true);
|
||||||
|
|
||||||
|
// Send lint request
|
||||||
|
$request = [
|
||||||
|
'id' => ++static::$request_id,
|
||||||
|
'method' => 'lint',
|
||||||
|
'files' => [$file_path]
|
||||||
|
];
|
||||||
|
|
||||||
|
fwrite($sock, json_encode($request) . "\n");
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
$response = fgets($sock);
|
||||||
|
fclose($sock);
|
||||||
|
|
||||||
|
if (!$response) {
|
||||||
|
throw new \RuntimeException("No response from RPC server");
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if (!$data || !is_array($data)) {
|
||||||
|
throw new \RuntimeException("Invalid JSON response from RPC server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['error'])) {
|
||||||
|
throw new \RuntimeException("RPC error: " . $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['results'][$file_path])) {
|
||||||
|
throw new \RuntimeException("No result for file in RPC response");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $data['results'][$file_path];
|
||||||
|
|
||||||
|
if ($result['status'] === 'success') {
|
||||||
|
// Return the error info if present, null if no errors
|
||||||
|
return $result['error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error response
|
||||||
|
if ($result['status'] === 'error' && isset($result['error'])) {
|
||||||
|
throw new \RuntimeException("Lint error: " . ($result['error']['message'] ?? 'Unknown error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"JavaScript lint RPC error for {$file_path}: " . $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze this-usage via RPC server
|
||||||
|
*/
|
||||||
|
protected static function _analyze_this_via_rpc(string $file_path): array
|
||||||
|
{
|
||||||
|
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
|
||||||
|
$socket_path = $base_path . '/' . self::RPC_SOCKET;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
|
||||||
|
if (!$sock) {
|
||||||
|
throw new \RuntimeException("Failed to connect to RPC server: {$errstr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set blocking mode for reliable reads
|
||||||
|
stream_set_blocking($sock, true);
|
||||||
|
|
||||||
|
// Send analyze_this request
|
||||||
|
$request = [
|
||||||
|
'id' => ++static::$request_id,
|
||||||
|
'method' => 'analyze_this',
|
||||||
|
'files' => [$file_path]
|
||||||
|
];
|
||||||
|
|
||||||
|
fwrite($sock, json_encode($request) . "\n");
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
$response = fgets($sock);
|
||||||
|
fclose($sock);
|
||||||
|
|
||||||
|
if (!$response) {
|
||||||
|
throw new \RuntimeException("No response from RPC server");
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if (!$data || !is_array($data)) {
|
||||||
|
throw new \RuntimeException("Invalid JSON response from RPC server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['error'])) {
|
||||||
|
throw new \RuntimeException("RPC error: " . $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['results'][$file_path])) {
|
||||||
|
throw new \RuntimeException("No result for file in RPC response");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $data['results'][$file_path];
|
||||||
|
|
||||||
|
if ($result['status'] === 'success') {
|
||||||
|
return $result['violations'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error response - return empty violations, don't fail
|
||||||
|
return [];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Log error but don't fail the check
|
||||||
|
if (function_exists('console_debug')) {
|
||||||
|
console_debug('JS_CODE_QUALITY', "RPC error for {$file_path}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the RPC server
|
||||||
|
*/
|
||||||
|
public static function start_rpc_server(): void
|
||||||
|
{
|
||||||
|
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
|
||||||
|
$socket_path = $base_path . '/' . self::RPC_SOCKET;
|
||||||
|
|
||||||
|
if (file_exists($socket_path)) {
|
||||||
|
// Server might be running, force stop it
|
||||||
|
if (function_exists('console_debug')) {
|
||||||
|
console_debug('JS_CODE_QUALITY', 'Found existing socket, forcing shutdown');
|
||||||
|
}
|
||||||
|
static::stop_rpc_server(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new server
|
||||||
|
$server_script = $base_path . '/' . self::RPC_SERVER_SCRIPT;
|
||||||
|
|
||||||
|
if (function_exists('console_debug')) {
|
||||||
|
console_debug('JS_CODE_QUALITY', 'Starting RPC server: ' . $server_script);
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process([
|
||||||
|
'node',
|
||||||
|
$server_script,
|
||||||
|
'--socket=' . $socket_path
|
||||||
|
]);
|
||||||
|
|
||||||
|
$process->start();
|
||||||
|
static::$rpc_server_process = $process;
|
||||||
|
|
||||||
|
// Register shutdown handler
|
||||||
|
register_shutdown_function([self::class, 'stop_rpc_server']);
|
||||||
|
|
||||||
|
// Wait for server to be ready (ping/pong up to 10 seconds)
|
||||||
|
if (function_exists('console_debug')) {
|
||||||
|
console_debug('JS_CODE_QUALITY', 'Waiting for RPC server to be ready...');
|
||||||
|
}
|
||||||
|
|
||||||
|
$max_attempts = 200; // 10 seconds (50ms * 200)
|
||||||
|
$ready = false;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $max_attempts; $i++) {
|
||||||
|
usleep(50000); // 50ms
|
||||||
|
|
||||||
|
if (file_exists($socket_path)) {
|
||||||
|
// Try to ping
|
||||||
|
if (static::ping_rpc_server()) {
|
||||||
|
$ready = true;
|
||||||
|
if (function_exists('console_debug')) {
|
||||||
|
console_debug('JS_CODE_QUALITY', 'RPC server ready after ' . ($i * 50) . 'ms');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$ready) {
|
||||||
|
static::stop_rpc_server();
|
||||||
|
throw new \RuntimeException('Failed to start JS Code Quality RPC server - timeout after 10 seconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('console_debug')) {
|
||||||
|
console_debug('JS_CODE_QUALITY', 'RPC server started successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping the RPC server
|
||||||
|
*/
|
||||||
|
protected static function ping_rpc_server(): bool
|
||||||
|
{
|
||||||
|
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
|
||||||
|
$socket_path = $base_path . '/' . self::RPC_SOCKET;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
|
||||||
|
if (!$sock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set blocking mode for reliable reads
|
||||||
|
stream_set_blocking($sock, true);
|
||||||
|
|
||||||
|
// Send ping
|
||||||
|
fwrite($sock, json_encode(['id' => ++static::$request_id, 'method' => 'ping']) . "\n");
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
$response = fgets($sock);
|
||||||
|
fclose($sock);
|
||||||
|
|
||||||
|
if (!$response) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
return isset($data['result']) && $data['result'] === 'pong';
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the RPC server
|
||||||
|
*/
|
||||||
|
public static function stop_rpc_server(bool $force = false): void
|
||||||
|
{
|
||||||
|
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
|
||||||
|
$socket_path = $base_path . '/' . self::RPC_SOCKET;
|
||||||
|
|
||||||
|
// Try graceful shutdown
|
||||||
|
if (file_exists($socket_path)) {
|
||||||
|
try {
|
||||||
|
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
|
||||||
|
if ($sock) {
|
||||||
|
fwrite($sock, json_encode(['id' => 0, 'method' => 'shutdown']) . "\n");
|
||||||
|
fclose($sock);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only wait and force kill if $force = true
|
||||||
|
if ($force && static::$rpc_server_process && static::$rpc_server_process->isRunning()) {
|
||||||
|
if (function_exists('console_debug')) {
|
||||||
|
console_debug('JS_CODE_QUALITY', 'Force stopping RPC server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for graceful shutdown
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// Force kill if still running
|
||||||
|
if (static::$rpc_server_process->isRunning()) {
|
||||||
|
static::$rpc_server_process->stop(3, SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up socket file
|
||||||
|
if (file_exists($socket_path)) {
|
||||||
|
@unlink($socket_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
570
app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js
Executable file
570
app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js
Executable file
@@ -0,0 +1,570 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaScript Code Quality RPC Server
|
||||||
|
*
|
||||||
|
* Combines JavaScript linting (Babel parser) and this-usage analysis (Acorn)
|
||||||
|
* into a single persistent server to avoid spawning thousands of Node processes.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Server mode: node js-code-quality-server.js --socket=/path/to/socket
|
||||||
|
*
|
||||||
|
* RPC Methods:
|
||||||
|
* - ping: Health check
|
||||||
|
* - lint: Check JavaScript syntax using Babel parser
|
||||||
|
* - analyze_this: Analyze 'this' usage patterns using Acorn
|
||||||
|
* - shutdown: Graceful server termination
|
||||||
|
*
|
||||||
|
* @FILENAME-CONVENTION-EXCEPTION - Node.js RPC server script
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
|
// Resolve to system/node_modules since that's where packages are installed
|
||||||
|
const systemDir = path.resolve(__dirname, '../../../../..');
|
||||||
|
const babelParser = require(path.join(systemDir, 'node_modules', '@babel', 'parser'));
|
||||||
|
const acorn = require(path.join(systemDir, 'node_modules', 'acorn'));
|
||||||
|
const walk = require(path.join(systemDir, 'node_modules', 'acorn-walk'));
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
let socketPath = null;
|
||||||
|
|
||||||
|
for (let i = 2; i < process.argv.length; i++) {
|
||||||
|
const arg = process.argv[i];
|
||||||
|
if (arg.startsWith('--socket=')) {
|
||||||
|
socketPath = arg.substring('--socket='.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!socketPath) {
|
||||||
|
console.error('Usage: node js-code-quality-server.js --socket=/path/to/socket');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// LINTING LOGIC (Babel Parser)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lint JavaScript file for syntax errors using Babel parser
|
||||||
|
* @param {string} content - File content
|
||||||
|
* @param {string} filePath - File path for error reporting
|
||||||
|
* @returns {object|null} Error object or null if no errors
|
||||||
|
*/
|
||||||
|
function lintFile(content, filePath) {
|
||||||
|
try {
|
||||||
|
babelParser.parse(content, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: [
|
||||||
|
'decorators-legacy',
|
||||||
|
'classProperties',
|
||||||
|
'classPrivateProperties',
|
||||||
|
'classPrivateMethods',
|
||||||
|
'optionalChaining',
|
||||||
|
'nullishCoalescingOperator',
|
||||||
|
'asyncGenerators',
|
||||||
|
'bigInt',
|
||||||
|
'dynamicImport',
|
||||||
|
'exportDefaultFrom',
|
||||||
|
'exportNamespaceFrom',
|
||||||
|
'objectRestSpread',
|
||||||
|
'topLevelAwait'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// No syntax errors
|
||||||
|
return null;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
line: error.loc ? error.loc.line : null,
|
||||||
|
column: error.loc ? error.loc.column : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// THIS-USAGE ANALYSIS LOGIC (Acorn Parser)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Known jQuery callback methods - used for better remediation messages
|
||||||
|
const JQUERY_CALLBACKS = new Set([
|
||||||
|
'click', 'dblclick', 'mouseenter', 'mouseleave', 'mousedown', 'mouseup',
|
||||||
|
'mousemove', 'mouseover', 'mouseout', 'change', 'submit', 'focus', 'blur',
|
||||||
|
'keydown', 'keyup', 'keypress', 'resize', 'scroll', 'select', 'load',
|
||||||
|
'on', 'off', 'one', 'each', 'map', 'filter',
|
||||||
|
'fadeIn', 'fadeOut', 'slideDown', 'slideUp', 'animate',
|
||||||
|
'done', 'fail', 'always', 'then', 'ready', 'hover'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze JavaScript file for 'this' usage violations
|
||||||
|
* @param {string} content - File content
|
||||||
|
* @param {string} filePath - File path for error reporting
|
||||||
|
* @returns {object} Result with violations array or error
|
||||||
|
*/
|
||||||
|
function analyzeThisUsage(content, filePath) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
let ast;
|
||||||
|
try {
|
||||||
|
ast = acorn.parse(content, {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
locations: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Parse error - return empty violations
|
||||||
|
return { violations: [], error: `Parse error: ${e.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations = [];
|
||||||
|
const classInfo = new Map();
|
||||||
|
|
||||||
|
// First pass: identify all classes and their types
|
||||||
|
walk.simple(ast, {
|
||||||
|
ClassDeclaration(node) {
|
||||||
|
const hasStaticInit = node.body.body.some(member =>
|
||||||
|
member.static && member.key?.name === 'init'
|
||||||
|
);
|
||||||
|
classInfo.set(node.id.name, {
|
||||||
|
isStatic: hasStaticInit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to check if first line of function has valid pattern
|
||||||
|
function checkFirstLinePattern(funcNode) {
|
||||||
|
if (!funcNode.body || !funcNode.body.body || funcNode.body.body.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let checkIndex = 0;
|
||||||
|
const firstStmt = funcNode.body.body[0];
|
||||||
|
|
||||||
|
// Check if first statement is e.preventDefault() or similar
|
||||||
|
if (firstStmt.type === 'ExpressionStatement' &&
|
||||||
|
firstStmt.expression?.type === 'CallExpression' &&
|
||||||
|
firstStmt.expression?.callee?.type === 'MemberExpression' &&
|
||||||
|
firstStmt.expression?.callee?.property?.name === 'preventDefault') {
|
||||||
|
checkIndex = 1;
|
||||||
|
if (funcNode.body.body.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStmt = funcNode.body.body[checkIndex];
|
||||||
|
if (targetStmt.type !== 'VariableDeclaration') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstDecl = targetStmt.declarations[0];
|
||||||
|
if (!firstDecl || !firstDecl.init) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const varKind = targetStmt.kind;
|
||||||
|
|
||||||
|
// Check for 'that = this' pattern
|
||||||
|
if (firstDecl.id.name === 'that' &&
|
||||||
|
firstDecl.init.type === 'ThisExpression') {
|
||||||
|
if (varKind !== 'const') {
|
||||||
|
return 'that-pattern-wrong-kind';
|
||||||
|
}
|
||||||
|
return 'that-pattern';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 'CurrentClass = this' pattern
|
||||||
|
if (firstDecl.id.name === 'CurrentClass' &&
|
||||||
|
firstDecl.init.type === 'ThisExpression') {
|
||||||
|
if (varKind !== 'const') {
|
||||||
|
return 'currentclass-pattern-wrong-kind';
|
||||||
|
}
|
||||||
|
return 'currentclass-pattern';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for '$var = $(this)' pattern
|
||||||
|
if (firstDecl.id.name.startsWith('$') &&
|
||||||
|
firstDecl.init.type === 'CallExpression' &&
|
||||||
|
firstDecl.init.callee.name === '$' &&
|
||||||
|
firstDecl.init.arguments.length === 1 &&
|
||||||
|
firstDecl.init.arguments[0].type === 'ThisExpression') {
|
||||||
|
if (varKind !== 'const') {
|
||||||
|
return 'jquery-pattern-wrong-kind';
|
||||||
|
}
|
||||||
|
return 'jquery-pattern';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to detect if we're in a jQuery callback
|
||||||
|
function isLikelyJQueryCallback(ancestors) {
|
||||||
|
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||||
|
const node = ancestors[i];
|
||||||
|
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
|
||||||
|
const methodName = node.callee.property.name;
|
||||||
|
if (JQUERY_CALLBACKS.has(methodName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the AST looking for 'this' usage
|
||||||
|
walk.ancestor(ast, {
|
||||||
|
ThisExpression(node, ancestors) {
|
||||||
|
// Skip arrow functions - they inherit 'this'
|
||||||
|
for (const ancestor of ancestors) {
|
||||||
|
if (ancestor.type === 'ArrowFunctionExpression') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find containing function and class
|
||||||
|
let containingFunc = null;
|
||||||
|
let containingClass = null;
|
||||||
|
let isAnonymousFunc = false;
|
||||||
|
let isStaticMethod = false;
|
||||||
|
let isConstructor = false;
|
||||||
|
let isInstanceMethod = false;
|
||||||
|
let hasMethodDefinition = false;
|
||||||
|
|
||||||
|
// First pass: check if we're in a MethodDefinition
|
||||||
|
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||||
|
const ancestor = ancestors[i];
|
||||||
|
if (ancestor.type === 'MethodDefinition') {
|
||||||
|
hasMethodDefinition = true;
|
||||||
|
isStaticMethod = ancestor.static;
|
||||||
|
isConstructor = ancestor.kind === 'constructor';
|
||||||
|
isInstanceMethod = !ancestor.static && ancestor.kind !== 'constructor';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: find function and class
|
||||||
|
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||||
|
const ancestor = ancestors[i];
|
||||||
|
|
||||||
|
if (!containingFunc && (
|
||||||
|
ancestor.type === 'FunctionExpression' ||
|
||||||
|
ancestor.type === 'FunctionDeclaration'
|
||||||
|
)) {
|
||||||
|
containingFunc = ancestor;
|
||||||
|
isAnonymousFunc = ancestor.type === 'FunctionExpression' && !hasMethodDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containingClass && (
|
||||||
|
ancestor.type === 'ClassDeclaration' ||
|
||||||
|
ancestor.type === 'ClassExpression'
|
||||||
|
)) {
|
||||||
|
containingClass = ancestor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containingFunc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip constructors and instance methods
|
||||||
|
if (isConstructor || isInstanceMethod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is part of the allowed first-line pattern
|
||||||
|
const parent = ancestors[ancestors.length - 2];
|
||||||
|
const firstStmt = containingFunc.body?.body?.[0];
|
||||||
|
let checkIndex = 0;
|
||||||
|
|
||||||
|
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
|
||||||
|
firstStmt?.expression?.type === 'CallExpression' &&
|
||||||
|
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
|
||||||
|
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
|
||||||
|
|
||||||
|
if (hasPreventDefault) {
|
||||||
|
checkIndex = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStmt = containingFunc.body?.body?.[checkIndex];
|
||||||
|
const isTargetConst = targetStmt?.type === 'VariableDeclaration' && targetStmt?.kind === 'const';
|
||||||
|
|
||||||
|
// Check if this 'this' is inside $(this) on the first or second line
|
||||||
|
if (parent && parent.type === 'CallExpression' && parent.callee?.name === '$') {
|
||||||
|
if (isTargetConst &&
|
||||||
|
targetStmt?.declarations?.[0]?.init === parent &&
|
||||||
|
targetStmt?.declarations?.[0]?.id?.name?.startsWith('$')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this 'this' is the 'const that = this' in correct position
|
||||||
|
if (parent && parent.type === 'VariableDeclarator' &&
|
||||||
|
parent.id?.name === 'that' &&
|
||||||
|
isTargetConst &&
|
||||||
|
targetStmt?.declarations?.[0]?.id?.name === 'that') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this 'this' is the 'const CurrentClass = this' in correct position
|
||||||
|
if (parent && parent.type === 'VariableDeclarator' &&
|
||||||
|
parent.id?.name === 'CurrentClass' &&
|
||||||
|
isTargetConst &&
|
||||||
|
targetStmt?.declarations?.[0]?.id?.name === 'CurrentClass') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check what pattern is used
|
||||||
|
const pattern = checkFirstLinePattern(containingFunc);
|
||||||
|
|
||||||
|
// Determine the violation and remediation
|
||||||
|
let message = '';
|
||||||
|
let remediation = '';
|
||||||
|
const lineNum = node.loc.start.line;
|
||||||
|
const codeSnippet = lines[lineNum - 1].trim();
|
||||||
|
const className = containingClass?.id?.name || 'unknown';
|
||||||
|
const isJQueryContext = isLikelyJQueryCallback(ancestors);
|
||||||
|
|
||||||
|
if (isAnonymousFunc) {
|
||||||
|
if (!pattern) {
|
||||||
|
const firstStmt = containingFunc.body?.body?.[0];
|
||||||
|
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
|
||||||
|
firstStmt?.expression?.type === 'CallExpression' &&
|
||||||
|
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
|
||||||
|
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
|
||||||
|
|
||||||
|
if (isJQueryContext) {
|
||||||
|
message = `'this' in jQuery callback should be aliased for clarity.`;
|
||||||
|
if (hasPreventDefault) {
|
||||||
|
remediation = `Add 'const $element = $(this);' as the second line (after preventDefault), then use $element instead of 'this'.`;
|
||||||
|
} else {
|
||||||
|
remediation = `Add 'const $element = $(this);' as the first line of this function, then use $element instead of 'this'.`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = `Ambiguous 'this' usage in anonymous function.`;
|
||||||
|
if (hasPreventDefault) {
|
||||||
|
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as the second line (after preventDefault).\n` +
|
||||||
|
`If this is an instance context: Add 'const that = this;' as the second line.\n` +
|
||||||
|
`Then use the aliased variable instead of 'this'.`;
|
||||||
|
} else {
|
||||||
|
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as first line.\n` +
|
||||||
|
`If this is an instance context: Add 'const that = this;' as first line.\n` +
|
||||||
|
`Then use the aliased variable instead of 'this'.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pattern === 'that-pattern' || pattern === 'jquery-pattern') {
|
||||||
|
message = `'this' used after aliasing. Use the aliased variable instead.`;
|
||||||
|
let varDeclIndex = 0;
|
||||||
|
const firstStmt = containingFunc.body?.body?.[0];
|
||||||
|
if (firstStmt?.type === 'ExpressionStatement' &&
|
||||||
|
firstStmt?.expression?.callee?.property?.name === 'preventDefault') {
|
||||||
|
varDeclIndex = 1;
|
||||||
|
}
|
||||||
|
const varName = containingFunc.body?.body?.[varDeclIndex]?.declarations?.[0]?.id?.name;
|
||||||
|
remediation = pattern === 'jquery-pattern'
|
||||||
|
? `You already have 'const ${varName} = $(this)'. Use that variable instead of 'this'.`
|
||||||
|
: `You already have 'const that = this'. Use 'that' instead of 'this'.`;
|
||||||
|
} else if (pattern === 'that-pattern-wrong-kind') {
|
||||||
|
message = `Instance alias must use 'const', not 'let' or 'var'.`;
|
||||||
|
remediation = `Change to 'const that = this;' - the instance reference should never be reassigned.`;
|
||||||
|
} else if (pattern === 'jquery-pattern-wrong-kind') {
|
||||||
|
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
|
||||||
|
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
|
||||||
|
}
|
||||||
|
} else if (isStaticMethod) {
|
||||||
|
if (!pattern) {
|
||||||
|
message = `Static method in '${className}' should not use naked 'this'.`;
|
||||||
|
remediation = `Static methods have two options:\n` +
|
||||||
|
`1. If you need the exact class (no polymorphism): Replace 'this' with '${className}'\n` +
|
||||||
|
`2. If you need polymorphism (inherited classes): Add 'const CurrentClass = this;' as first line\n` +
|
||||||
|
` Then use CurrentClass.name or CurrentClass.property for polymorphic access.\n` +
|
||||||
|
`Consider: Does this.name need to work for child classes? If yes, use CurrentClass pattern.`;
|
||||||
|
} else if (pattern === 'currentclass-pattern') {
|
||||||
|
message = `'this' used after aliasing to CurrentClass. Use 'CurrentClass' instead.`;
|
||||||
|
remediation = `You already have 'const CurrentClass = this;'. Use 'CurrentClass' for all access, not naked 'this'.`;
|
||||||
|
} else if (pattern === 'currentclass-pattern-wrong-kind') {
|
||||||
|
message = `CurrentClass pattern must use 'const', not 'let' or 'var'.`;
|
||||||
|
remediation = `Change to 'const CurrentClass = this;' - the CurrentClass reference should never be reassigned.`;
|
||||||
|
} else if (pattern === 'jquery-pattern') {
|
||||||
|
return;
|
||||||
|
} else if (pattern === 'jquery-pattern-wrong-kind') {
|
||||||
|
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
|
||||||
|
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAnonymousFunc && !pattern) {
|
||||||
|
remediation += `\nException: If this is a jQuery callback, add 'const $element = $(this);' as the first line.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
violations.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: message,
|
||||||
|
codeSnippet: codeSnippet,
|
||||||
|
remediation: remediation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { violations: violations };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RPC SERVER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Remove socket if exists
|
||||||
|
if (fs.existsSync(socketPath)) {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequest(data) {
|
||||||
|
try {
|
||||||
|
const request = JSON.parse(data);
|
||||||
|
|
||||||
|
switch (request.method) {
|
||||||
|
case 'ping':
|
||||||
|
return JSON.stringify({
|
||||||
|
id: request.id,
|
||||||
|
result: 'pong'
|
||||||
|
}) + '\n';
|
||||||
|
|
||||||
|
case 'lint':
|
||||||
|
const lintResults = {};
|
||||||
|
for (const file of request.files) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(file, 'utf8');
|
||||||
|
const error = lintFile(content, file);
|
||||||
|
lintResults[file] = {
|
||||||
|
status: 'success',
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lintResults[file] = {
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'FileReadError',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
id: request.id,
|
||||||
|
results: lintResults
|
||||||
|
}) + '\n';
|
||||||
|
|
||||||
|
case 'analyze_this':
|
||||||
|
const thisResults = {};
|
||||||
|
for (const file of request.files) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(file, 'utf8');
|
||||||
|
const result = analyzeThisUsage(content, file);
|
||||||
|
thisResults[file] = {
|
||||||
|
status: 'success',
|
||||||
|
violations: result.violations,
|
||||||
|
error: result.error || null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
thisResults[file] = {
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'FileReadError',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
id: request.id,
|
||||||
|
results: thisResults
|
||||||
|
}) + '\n';
|
||||||
|
|
||||||
|
case 'shutdown':
|
||||||
|
return JSON.stringify({
|
||||||
|
id: request.id,
|
||||||
|
result: 'shutting down'
|
||||||
|
}) + '\n';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return JSON.stringify({
|
||||||
|
id: request.id,
|
||||||
|
error: 'Unknown method: ' + request.method
|
||||||
|
}) + '\n';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return JSON.stringify({
|
||||||
|
error: 'Invalid JSON request: ' + error.message
|
||||||
|
}) + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
|
||||||
|
let newlineIndex;
|
||||||
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = buffer.substring(0, newlineIndex);
|
||||||
|
buffer = buffer.substring(newlineIndex + 1);
|
||||||
|
|
||||||
|
if (line.trim()) {
|
||||||
|
const response = handleRequest(line);
|
||||||
|
socket.write(response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = JSON.parse(line);
|
||||||
|
if (request.method === 'shutdown') {
|
||||||
|
socket.end();
|
||||||
|
server.close(() => {
|
||||||
|
if (fs.existsSync(socketPath)) {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
console.error('Socket error:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(socketPath, () => {
|
||||||
|
console.log('JS Code Quality RPC server listening on ' + socketPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
console.error('Server error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
server.close(() => {
|
||||||
|
if (fs.existsSync(socketPath)) {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
server.close(() => {
|
||||||
|
if (fs.existsSync(socketPath)) {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,12 +49,14 @@ class Bundle_Compile_Command extends Command
|
|||||||
|
|
||||||
$bundle_arg = $this->argument('bundle');
|
$bundle_arg = $this->argument('bundle');
|
||||||
|
|
||||||
// Get all bundle classes from manifest
|
// Get all MODULE bundle classes from manifest (not asset bundles)
|
||||||
|
// Module bundles are the top-level page bundles that get compiled
|
||||||
|
// Asset bundles are dependency declarations auto-discovered during compilation
|
||||||
$manifest_data = Manifest::get_all();
|
$manifest_data = Manifest::get_all();
|
||||||
$bundle_classes = [];
|
$bundle_classes = [];
|
||||||
|
|
||||||
foreach ($manifest_data as $file_info) {
|
foreach ($manifest_data as $file_info) {
|
||||||
if (isset($file_info['extends']) && $file_info['extends'] === 'Rsx_Bundle_Abstract') {
|
if (isset($file_info['extends']) && $file_info['extends'] === 'Rsx_Module_Bundle_Abstract') {
|
||||||
$fqcn = $file_info['fqcn'] ?? $file_info['class'] ?? null;
|
$fqcn = $file_info['fqcn'] ?? $file_info['class'] ?? null;
|
||||||
if ($fqcn) {
|
if ($fqcn) {
|
||||||
$bundle_classes[$fqcn] = $file_info['class'] ?? $fqcn;
|
$bundle_classes[$fqcn] = $file_info['class'] ?? $fqcn;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ use App\RSpade\Core\Debug\Debugger;
|
|||||||
* KEY FEATURES:
|
* KEY FEATURES:
|
||||||
* - Backdoor authentication: Use --user-id to bypass login and test as any user
|
* - Backdoor authentication: Use --user-id to bypass login and test as any user
|
||||||
* - Plain text error output: Errors returned as plain text with stack traces
|
* - Plain text error output: Errors returned as plain text with stack traces
|
||||||
* - Console capture: JavaScript errors and logs captured (--console-log for all)
|
* - Console capture: JavaScript errors and logs captured (--console for all)
|
||||||
* - XHR/fetch tracking: Monitor API calls with --xhr-dump or --xhr-list
|
* - XHR/fetch tracking: Monitor API calls with --xhr-dump or --xhr-list
|
||||||
* - Element verification: Check DOM elements with --expect-element
|
* - Element verification: Check DOM elements with --expect-element
|
||||||
* - HTML extraction: Get element HTML with --dump-element
|
* - HTML extraction: Get element HTML with --dump-element
|
||||||
@@ -103,7 +103,7 @@ use App\RSpade\Core\Debug\Debugger;
|
|||||||
* - Redirect chain (if --follow-redirects used)
|
* - Redirect chain (if --follow-redirects used)
|
||||||
* - Response headers (if --headers used)
|
* - Response headers (if --headers used)
|
||||||
* - Console errors (always shown if present)
|
* - Console errors (always shown if present)
|
||||||
* - Console logs (if --console-log used)
|
* - Console logs (if --console used)
|
||||||
* - XHR/fetch requests (if --xhr-dump or --xhr-list used)
|
* - XHR/fetch requests (if --xhr-dump or --xhr-list used)
|
||||||
* - Input elements (if --input-elements used)
|
* - Input elements (if --input-elements used)
|
||||||
* - Cookies (if --cookies used)
|
* - Cookies (if --cookies used)
|
||||||
@@ -133,7 +133,8 @@ class Route_Debug_Command extends Command
|
|||||||
{--no-body : Suppress HTTP response body (show headers/status only)}
|
{--no-body : Suppress HTTP response body (show headers/status only)}
|
||||||
{--follow-redirects : Follow HTTP redirects and show full redirect chain}
|
{--follow-redirects : Follow HTTP redirects and show full redirect chain}
|
||||||
{--headers : Display all HTTP response headers}
|
{--headers : Display all HTTP response headers}
|
||||||
{--console-log : Display all browser console output (not just errors) and console_debug() output even if SHOW_CONSOLE_DEBUG_HTTP=false}
|
{--console : Display all browser console output (not just errors) and console_debug() output even if SHOW_CONSOLE_DEBUG_HTTP=false}
|
||||||
|
{--console-log : Alias for --console}
|
||||||
{--xhr-dump : Capture full details of XHR/fetch requests (URL, headers, body, response)}
|
{--xhr-dump : Capture full details of XHR/fetch requests (URL, headers, body, response)}
|
||||||
{--input-elements : List all form input elements with values and attributes}
|
{--input-elements : List all form input elements with values and attributes}
|
||||||
{--post= : Send POST request with JSON data (e.g., --post=\'{"key":"value"}\')}
|
{--post= : Send POST request with JSON data (e.g., --post=\'{"key":"value"}\')}
|
||||||
@@ -189,7 +190,8 @@ class Route_Debug_Command extends Command
|
|||||||
|
|
||||||
// Check if console_debug is disabled globally and user didn't override
|
// Check if console_debug is disabled globally and user didn't override
|
||||||
$console_debug_enabled = config('rsx.console_debug.enabled', false) || env('CONSOLE_DEBUG_ENABLED') === 'true';
|
$console_debug_enabled = config('rsx.console_debug.enabled', false) || env('CONSOLE_DEBUG_ENABLED') === 'true';
|
||||||
$console_debug_override = $this->option('console-log') ||
|
$console_debug_override = $this->option('console') ||
|
||||||
|
$this->option('console-log') ||
|
||||||
$this->option('console-list') ||
|
$this->option('console-list') ||
|
||||||
$this->option('console-debug-all') ||
|
$this->option('console-debug-all') ||
|
||||||
$this->option('console-debug-filter') ||
|
$this->option('console-debug-filter') ||
|
||||||
@@ -224,8 +226,8 @@ class Route_Debug_Command extends Command
|
|||||||
// Get headers flag
|
// Get headers flag
|
||||||
$headers = $this->option('headers');
|
$headers = $this->option('headers');
|
||||||
|
|
||||||
// Get console-log flag
|
// Get console flag (--console or --console-log alias)
|
||||||
$console_log = $this->option('console-log');
|
$console_log = $this->option('console') || $this->option('console-log');
|
||||||
|
|
||||||
// Get xhr-dump flag
|
// Get xhr-dump flag
|
||||||
$xhr_dump = $this->option('xhr-dump');
|
$xhr_dump = $this->option('xhr-dump');
|
||||||
@@ -539,8 +541,8 @@ class Route_Debug_Command extends Command
|
|||||||
$this->line('');
|
$this->line('');
|
||||||
|
|
||||||
$this->comment('DEBUGGING OUTPUT:');
|
$this->comment('DEBUGGING OUTPUT:');
|
||||||
$this->line(' php artisan rsx:debug / --console-log # All console output');
|
$this->line(' php artisan rsx:debug / --console # All console output');
|
||||||
$this->line(' php artisan rsx:debug / --console-list # Alias for --console-log');
|
$this->line(' php artisan rsx:debug / --console-log # Alias for --console');
|
||||||
$this->line(' php artisan rsx:debug / --console-debug-filter=AUTH # Filter console_debug');
|
$this->line(' php artisan rsx:debug / --console-debug-filter=AUTH # Filter console_debug');
|
||||||
$this->line(' php artisan rsx:debug / --console-debug-all # Show all console_debug channels');
|
$this->line(' php artisan rsx:debug / --console-debug-all # Show all console_debug channels');
|
||||||
$this->line(' php artisan rsx:debug / --console-debug-benchmark # With timing');
|
$this->line(' php artisan rsx:debug / --console-debug-benchmark # With timing');
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* CODING CONVENTION:
|
|
||||||
* This file follows the coding convention where variable_names and function_names
|
|
||||||
* use snake_case (underscore_wherever_possible).
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace App\RSpade\Commands\Rsx;
|
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
use Illuminate\Support\Facades\Redis;
|
|
||||||
use App\RSpade\Core\Bootstrap\ManifestKernel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RSX SSR Full Page Cache (FPC) Create Command
|
|
||||||
* ==============================================
|
|
||||||
*
|
|
||||||
* PURPOSE:
|
|
||||||
* Generates static, pre-rendered HTML cache for routes marked with #[Static_Page] attribute.
|
|
||||||
* Uses Playwright to render pages in headless Chrome, capturing the fully-rendered DOM state
|
|
||||||
* for optimal SEO and performance.
|
|
||||||
*
|
|
||||||
* HOW IT WORKS:
|
|
||||||
* 1. Launches headless Chromium browser via Playwright
|
|
||||||
* 2. Navigates to the specified route with FPC generation headers
|
|
||||||
* 3. Waits for _debug_ready event (all components initialized)
|
|
||||||
* 4. Waits for network idle + 10ms buffer
|
|
||||||
* 5. Captures full DOM or redirect response
|
|
||||||
* 6. Generates ETag from build_key + URL + content hash
|
|
||||||
* 7. Stores in Redis: ssr_fpc:{build_key}:{url_hash}
|
|
||||||
*
|
|
||||||
* KEY FEATURES:
|
|
||||||
* - Exclusive lock (GENERATE_STATIC_CACHE) prevents concurrent generation
|
|
||||||
* - Strips GET parameters from URLs (static pages ignore query strings)
|
|
||||||
* - Handles redirect responses (caches 302 location without following)
|
|
||||||
* - Build key integration (auto-invalidates cache on deployment)
|
|
||||||
* - Comprehensive error logging to storage/logs/ssr-fpc-errors.log
|
|
||||||
*
|
|
||||||
* DESIGN GOALS:
|
|
||||||
* - SEO-optimized static pages for unauthenticated users
|
|
||||||
* - Fail-loud approach (fatal exception if generation fails)
|
|
||||||
* - Cache key includes build_key for automatic invalidation
|
|
||||||
* - Development only tool (production uses pre-generated cache)
|
|
||||||
*
|
|
||||||
* USAGE EXAMPLES:
|
|
||||||
* php artisan rsx:ssr_fpc:create / # Cache homepage
|
|
||||||
* php artisan rsx:ssr_fpc:create /about # Cache about page
|
|
||||||
* php artisan rsx:ssr_fpc:create /products/view/123 # Cache dynamic route
|
|
||||||
*
|
|
||||||
* FUTURE ROADMAP:
|
|
||||||
* - Support for --from-sitemap to generate all pages from sitemap.xml
|
|
||||||
* - Shared private key for FPC client authentication
|
|
||||||
* - External service for distributed cache generation
|
|
||||||
* - Parallelization for faster multi-page generation
|
|
||||||
* - Programmatic cache reset hooks (CMS updates, blog posts)
|
|
||||||
*
|
|
||||||
* IMPLEMENTATION DETAILS:
|
|
||||||
* - Uses X-RSpade-FPC-Client header to identify cache generation requests
|
|
||||||
* - Playwright script located in resource/playwright/generate-static-cache.js
|
|
||||||
* - Cache stored as JSON: {url, code, page_dom/redirect, build_key, etag, generated_at}
|
|
||||||
* - ETag is first 30 chars of SHA1(build_key + url + content)
|
|
||||||
* - Redis key format: ssr_fpc:{build_key}:{sha1(url)}
|
|
||||||
*
|
|
||||||
* SECURITY:
|
|
||||||
* - Only available in local/development/testing environments
|
|
||||||
* - Throws fatal error if attempted in production
|
|
||||||
* - FPC bypass header prevents cache serving during generation
|
|
||||||
*/
|
|
||||||
class Ssr_Fpc_Create_Command extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'rsx:ssr_fpc:create
|
|
||||||
{url : The URL to generate static cache for (e.g., /about, /products)}';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = 'Generate static page cache for SSR FPC system (development only)';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle()
|
|
||||||
{
|
|
||||||
// Check environment - throw fatal error in production
|
|
||||||
if (app()->environment('production')) {
|
|
||||||
throw new \RuntimeException('FATAL: rsx:ssr_fpc:create command is not available in production environment. This is a development-only tool.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if SSR FPC is enabled
|
|
||||||
if (!config('rsx.ssr_fpc.enabled', false)) {
|
|
||||||
$this->error('SSR FPC is disabled. Enable it in config/rsx.php or set SSR_FPC_ENABLED=true in .env');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the URL to generate cache for
|
|
||||||
$url = $this->argument('url');
|
|
||||||
|
|
||||||
// Strip query parameters from URL (static pages ignore query strings)
|
|
||||||
$url = parse_url($url, PHP_URL_PATH) ?: $url;
|
|
||||||
|
|
||||||
// Ensure URL starts with /
|
|
||||||
if (!str_starts_with($url, '/')) {
|
|
||||||
$url = '/' . $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Generating static cache for: {$url}");
|
|
||||||
|
|
||||||
// Check if Playwright script exists
|
|
||||||
$playwright_script = base_path('app/RSpade/Commands/Rsx/resource/playwright/generate-static-cache.js');
|
|
||||||
if (!file_exists($playwright_script)) {
|
|
||||||
$this->error("❌ Playwright script not found: {$playwright_script}");
|
|
||||||
$this->error('Please create the script or check your installation');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if node/npm is available
|
|
||||||
$node_check = new Process(['node', '--version']);
|
|
||||||
$node_check->run();
|
|
||||||
|
|
||||||
if (!$node_check->isSuccessful()) {
|
|
||||||
$this->error('❌ Node.js is not installed or not in PATH');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if playwright is installed
|
|
||||||
$playwright_check = new Process(['node', '-e', "require('playwright')"], base_path());
|
|
||||||
$playwright_check->run();
|
|
||||||
|
|
||||||
if (!$playwright_check->isSuccessful()) {
|
|
||||||
$this->warn('⚠️ Playwright not installed. Installing now...');
|
|
||||||
$npm_install = new Process(['npm', 'install', 'playwright'], base_path());
|
|
||||||
$npm_install->run(function ($type, $buffer) {
|
|
||||||
echo $buffer;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!$npm_install->isSuccessful()) {
|
|
||||||
$this->error('❌ Failed to install Playwright');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$this->info('✅ Playwright installed');
|
|
||||||
$this->info('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if chromium browser is installed and up to date
|
|
||||||
$browser_check_script = "const {chromium} = require('playwright'); chromium.launch({headless:true}).then(b => {b.close(); process.exit(0);}).catch(e => {console.error(e.message); process.exit(1);});";
|
|
||||||
$browser_check = new Process(['node', '-e', $browser_check_script], base_path(), $_ENV, null, 10);
|
|
||||||
$browser_check->run();
|
|
||||||
|
|
||||||
if (!$browser_check->isSuccessful()) {
|
|
||||||
$error_output = $browser_check->getErrorOutput() . $browser_check->getOutput();
|
|
||||||
|
|
||||||
// Check if it's a browser not installed or out of date error
|
|
||||||
if (str_contains($error_output, "Executable doesn't exist") ||
|
|
||||||
str_contains($error_output, "browserType.launch") ||
|
|
||||||
str_contains($error_output, "Playwright was just installed or updated")) {
|
|
||||||
|
|
||||||
$this->info('Installing/updating Chromium browser...');
|
|
||||||
$browser_install = new Process(['npx', 'playwright', 'install', 'chromium'], base_path());
|
|
||||||
$browser_install->setTimeout(300); // 5 minute timeout for download
|
|
||||||
$browser_install->run(function ($type, $buffer) {
|
|
||||||
// Silent - downloads can be verbose
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!$browser_install->isSuccessful()) {
|
|
||||||
$this->error('❌ Failed to install Chromium browser');
|
|
||||||
$this->error('Run manually: npx playwright install chromium');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$this->info('✅ Chromium browser installed/updated');
|
|
||||||
$this->info('');
|
|
||||||
} else {
|
|
||||||
$this->error('❌ Browser check failed: ' . trim($error_output));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get timeout from config
|
|
||||||
$timeout = config('rsx.ssr_fpc.generation_timeout', 30000);
|
|
||||||
|
|
||||||
// Build command arguments
|
|
||||||
$command_args = ['node', $playwright_script, $url, "--timeout={$timeout}"];
|
|
||||||
|
|
||||||
$env = array_merge($_ENV, [
|
|
||||||
'BASE_URL' => config('app.url')
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Convert timeout from milliseconds to seconds for Process timeout
|
|
||||||
// Add 10 seconds buffer to the Process timeout to allow Playwright to timeout first
|
|
||||||
$process_timeout = ($timeout / 1000) + 10;
|
|
||||||
|
|
||||||
// Release the application lock before running Playwright to prevent lock contention
|
|
||||||
// The artisan command holds a WRITE lock which would block the web request's READ lock
|
|
||||||
\App\RSpade\Core\Bootstrap\RsxBootstrap::temporarily_release_lock();
|
|
||||||
|
|
||||||
$process = new Process(
|
|
||||||
$command_args,
|
|
||||||
base_path(),
|
|
||||||
$env,
|
|
||||||
null,
|
|
||||||
$process_timeout
|
|
||||||
);
|
|
||||||
|
|
||||||
$output = '';
|
|
||||||
$process->run(function ($type, $buffer) use (&$output) {
|
|
||||||
echo $buffer;
|
|
||||||
$output .= $buffer;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!$process->isSuccessful()) {
|
|
||||||
$this->error('❌ Failed to generate static cache');
|
|
||||||
$this->error('Check storage/logs/ssr-fpc-errors.log for details');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON output from Playwright script
|
|
||||||
try {
|
|
||||||
$result = json_decode($output, true);
|
|
||||||
if (!$result) {
|
|
||||||
throw new \Exception('Invalid JSON response from Playwright');
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error('❌ Failed to parse Playwright output: ' . $e->getMessage());
|
|
||||||
$this->error('Raw output: ' . $output);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get build key from manifest
|
|
||||||
$build_key = \App\RSpade\Core\Manifest\Manifest::get_build_key();
|
|
||||||
|
|
||||||
// Generate Redis cache key
|
|
||||||
$url_hash = sha1($url);
|
|
||||||
$redis_key = "ssr_fpc:{$build_key}:{$url_hash}";
|
|
||||||
|
|
||||||
// Generate ETag (first 30 chars of SHA1)
|
|
||||||
$content_for_etag = $build_key . $url . ($result['page_dom'] ?? $result['redirect'] ?? '');
|
|
||||||
$etag = substr(sha1($content_for_etag), 0, 30);
|
|
||||||
|
|
||||||
// Build cache entry
|
|
||||||
$cache_entry = [
|
|
||||||
'url' => $url,
|
|
||||||
'code' => $result['code'],
|
|
||||||
'build_key' => $build_key,
|
|
||||||
'etag' => $etag,
|
|
||||||
'generated_at' => time(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($result['code'] >= 300 && $result['code'] < 400) {
|
|
||||||
// Redirect response
|
|
||||||
$cache_entry['redirect'] = $result['redirect'];
|
|
||||||
$cache_entry['page_dom'] = null;
|
|
||||||
} else {
|
|
||||||
// Normal response
|
|
||||||
$cache_entry['page_dom'] = $result['page_dom'];
|
|
||||||
$cache_entry['redirect'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in Redis as JSON
|
|
||||||
try {
|
|
||||||
Redis::set($redis_key, json_encode($cache_entry));
|
|
||||||
$this->info("✅ Static cache generated successfully");
|
|
||||||
$this->info(" Redis key: {$redis_key}");
|
|
||||||
$this->info(" ETag: {$etag}");
|
|
||||||
$this->info(" Status: {$result['code']}");
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error('❌ Failed to store cache in Redis: ' . $e->getMessage());
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* CODING CONVENTION:
|
|
||||||
* This file follows the coding convention where variable_names and function_names
|
|
||||||
* use snake_case (underscore_wherever_possible).
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace App\RSpade\Commands\Rsx;
|
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\Redis;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RSX SSR Full Page Cache (FPC) Reset Command
|
|
||||||
* ============================================
|
|
||||||
*
|
|
||||||
* PURPOSE:
|
|
||||||
* Clears all SSR FPC caches from Redis. This is useful when you want to force
|
|
||||||
* regeneration of all static pages, such as after a major content update or
|
|
||||||
* when troubleshooting caching issues.
|
|
||||||
*
|
|
||||||
* HOW IT WORKS:
|
|
||||||
* 1. Scans Redis for all keys matching the pattern: ssr_fpc:*
|
|
||||||
* 2. Deletes all matching keys
|
|
||||||
* 3. Reports count of cleared cache entries
|
|
||||||
*
|
|
||||||
* USAGE:
|
|
||||||
* php artisan rsx:ssr_fpc:reset
|
|
||||||
*
|
|
||||||
* WHEN TO USE:
|
|
||||||
* - After major content updates across the site
|
|
||||||
* - When troubleshooting caching issues
|
|
||||||
* - Before deployment to ensure fresh cache generation
|
|
||||||
* - When the build_key has changed (auto-invalidation should handle this)
|
|
||||||
*
|
|
||||||
* SECURITY:
|
|
||||||
* - Available in all environments (local, staging, production)
|
|
||||||
* - Safe to run - only affects SSR FPC caches, not other Redis data
|
|
||||||
* - Does NOT affect:
|
|
||||||
* - Application caches
|
|
||||||
* - Session data
|
|
||||||
* - Queue jobs
|
|
||||||
* - Other Redis namespaced data
|
|
||||||
*/
|
|
||||||
class Ssr_Fpc_Reset_Command extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'rsx:ssr_fpc:reset';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = 'Clear all SSR Full Page Cache entries from Redis';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle()
|
|
||||||
{
|
|
||||||
$this->info('Clearing SSR FPC caches...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all keys matching the SSR FPC pattern
|
|
||||||
$keys = Redis::keys('ssr_fpc:*');
|
|
||||||
|
|
||||||
if (empty($keys)) {
|
|
||||||
$this->info('No SSR FPC cache entries found.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all FPC cache keys
|
|
||||||
$deleted = Redis::del($keys);
|
|
||||||
|
|
||||||
$this->info("✅ Cleared {$deleted} SSR FPC cache entries");
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error('❌ Failed to clear SSR FPC caches: ' . $e->getMessage());
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
app/RSpade/Commands/Rsx/resource/playwright/.placeholder
Executable file
0
app/RSpade/Commands/Rsx/resource/playwright/.placeholder
Executable file
@@ -1,260 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SSR FPC (Full Page Cache) Generation Script
|
|
||||||
* Generates static pre-rendered HTML cache using Playwright
|
|
||||||
*
|
|
||||||
* Usage: node generate-static-cache.js <route> [options]
|
|
||||||
*
|
|
||||||
* Arguments:
|
|
||||||
* route The route to generate cache for (e.g., /about)
|
|
||||||
*
|
|
||||||
* Options:
|
|
||||||
* --timeout=<ms> Navigation timeout in milliseconds (default 30000ms)
|
|
||||||
* --help Show this help message
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { chromium } = require('playwright');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
function parse_args() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (args.length === 0 || args.includes('--help')) {
|
|
||||||
console.log('SSR FPC Generation - Generate static pre-rendered HTML cache');
|
|
||||||
console.log('');
|
|
||||||
console.log('Usage: node generate-static-cache.js <route> [options]');
|
|
||||||
console.log('');
|
|
||||||
console.log('Arguments:');
|
|
||||||
console.log(' route The route to generate cache for (e.g., /about)');
|
|
||||||
console.log('');
|
|
||||||
console.log('Options:');
|
|
||||||
console.log(' --timeout=<ms> Navigation timeout in milliseconds (default 30000ms)');
|
|
||||||
console.log(' --help Show this help message');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
route: null,
|
|
||||||
timeout: 30000,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const arg of args) {
|
|
||||||
if (arg.startsWith('--timeout=')) {
|
|
||||||
options.timeout = parseInt(arg.substring(10));
|
|
||||||
if (options.timeout < 30000) {
|
|
||||||
console.error('Error: Timeout value is in milliseconds and must be no less than 30000 milliseconds (30 seconds)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else if (!arg.startsWith('--')) {
|
|
||||||
options.route = arg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.route) {
|
|
||||||
console.error('Error: Route argument is required');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure route starts with /
|
|
||||||
if (!options.route.startsWith('/')) {
|
|
||||||
options.route = '/' + options.route;
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log error to file
|
|
||||||
function log_error(url, error, details = {}) {
|
|
||||||
const log_dir = path.join(process.cwd(), 'storage', 'logs');
|
|
||||||
const log_file = path.join(log_dir, 'ssr-fpc-errors.log');
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const log_entry = {
|
|
||||||
timestamp,
|
|
||||||
url,
|
|
||||||
error: error.message || String(error),
|
|
||||||
stack: error.stack || null,
|
|
||||||
...details
|
|
||||||
};
|
|
||||||
|
|
||||||
const log_line = JSON.stringify(log_entry) + '\n';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(log_dir)) {
|
|
||||||
fs.mkdirSync(log_dir, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.appendFileSync(log_file, log_line);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to write error log:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main execution
|
|
||||||
(async () => {
|
|
||||||
const options = parse_args();
|
|
||||||
|
|
||||||
const baseUrl = process.env.BASE_URL || 'http://localhost';
|
|
||||||
const fullUrl = baseUrl + options.route;
|
|
||||||
|
|
||||||
let browser;
|
|
||||||
let page;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Launch browser (always headless)
|
|
||||||
browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
ignoreHTTPSErrors: true
|
|
||||||
});
|
|
||||||
|
|
||||||
page = await context.newPage();
|
|
||||||
|
|
||||||
// Set up headers for FPC generation request
|
|
||||||
const extraHeaders = {
|
|
||||||
'X-RSpade-FPC-Client': '1', // Identifies this as FPC generation request
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use route interception to add headers
|
|
||||||
await page.route('**/*', async (route, request) => {
|
|
||||||
const url = request.url();
|
|
||||||
// Only add headers to local requests
|
|
||||||
if (url.startsWith(baseUrl)) {
|
|
||||||
await route.continue({
|
|
||||||
headers: {
|
|
||||||
...request.headers(),
|
|
||||||
...extraHeaders
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await route.continue();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to the route
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await page.goto(fullUrl, {
|
|
||||||
waitUntil: 'networkidle',
|
|
||||||
timeout: options.timeout
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log_error(fullUrl, error, {
|
|
||||||
phase: 'navigation',
|
|
||||||
timeout: options.timeout
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
const error = new Error('Navigation failed - no response');
|
|
||||||
log_error(fullUrl, error, { phase: 'navigation' });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for redirect (300-399 status codes)
|
|
||||||
const status = response.status();
|
|
||||||
|
|
||||||
if (status >= 300 && status < 400) {
|
|
||||||
// Redirect response - cache the redirect
|
|
||||||
const redirect_location = response.headers()['location'];
|
|
||||||
if (!redirect_location) {
|
|
||||||
const error = new Error(`Redirect response (${status}) but no Location header found`);
|
|
||||||
log_error(fullUrl, error, {
|
|
||||||
phase: 'redirect_check',
|
|
||||||
status,
|
|
||||||
headers: response.headers()
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output redirect response as JSON
|
|
||||||
const result = {
|
|
||||||
url: options.route,
|
|
||||||
code: status,
|
|
||||||
redirect: redirect_location,
|
|
||||||
page_dom: null
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
await browser.close();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for RSX framework and jqhtml components to complete initialization
|
|
||||||
try {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
// Check if RSX framework with _debug_ready event is available
|
|
||||||
if (window.Rsx && window.Rsx.on) {
|
|
||||||
// Use Rsx._debug_ready event which fires after all jqhtml components complete lifecycle
|
|
||||||
window.Rsx.on('_debug_ready', function() {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback for non-RSX pages: wait for DOMContentLoaded + 200ms
|
|
||||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
|
||||||
setTimeout(function() { resolve(); }, 200);
|
|
||||||
} else {
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
setTimeout(function() { resolve(); }, 200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log_error(fullUrl, error, {
|
|
||||||
phase: 'wait_for_ready',
|
|
||||||
has_rsx: await page.evaluate(() => typeof window.Rsx !== 'undefined')
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional 10ms wait for final network settle
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
|
|
||||||
// Get the fully rendered DOM
|
|
||||||
let page_dom;
|
|
||||||
try {
|
|
||||||
page_dom = await page.content();
|
|
||||||
} catch (error) {
|
|
||||||
log_error(fullUrl, error, { phase: 'get_dom' });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output success response as JSON
|
|
||||||
const result = {
|
|
||||||
url: options.route,
|
|
||||||
code: status,
|
|
||||||
page_dom: page_dom,
|
|
||||||
redirect: null
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
await browser.close();
|
|
||||||
process.exit(0);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (browser) {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log error if not already logged
|
|
||||||
if (!error.logged) {
|
|
||||||
log_error(fullUrl, error, { phase: 'unknown' });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('FATAL: SSR FPC generation failed');
|
|
||||||
console.error(error.message);
|
|
||||||
if (error.stack) {
|
|
||||||
console.error(error.stack);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
29
app/RSpade/Components/JS_Tree_Debug_Component.jqhtml
Executable file
29
app/RSpade/Components/JS_Tree_Debug_Component.jqhtml
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
<%--
|
||||||
|
JS_Tree_Debug_Component
|
||||||
|
|
||||||
|
A universal "var_dump" style component for debugging JavaScript values.
|
||||||
|
Renders any JavaScript value as an expandable/collapsible tree, similar to browser DevTools.
|
||||||
|
Useful for debugging, displaying error metadata, and inspecting ORM model instances.
|
||||||
|
|
||||||
|
$data - The JavaScript value to display. Pass directly (unquoted) for objects/arrays:
|
||||||
|
$data=this.data.myObject (correct - passes object reference)
|
||||||
|
$data="<%= this.data.myObject %>" (wrong - stringifies the object)
|
||||||
|
$expand_depth - How many levels deep to expand by default (default: 1)
|
||||||
|
$root_label - Optional label for the root element
|
||||||
|
$show_class_names - If true, display class names for object instances in a small
|
||||||
|
bordered badge (default: false). Shown after { when expanded,
|
||||||
|
after } when collapsed. Only for named class instances, not
|
||||||
|
generic Object or Array.
|
||||||
|
--%>
|
||||||
|
<Define:JS_Tree_Debug_Component tag="div" class="js-tree-debug">
|
||||||
|
<% if (JS_Tree_Debug_Node.get_type(this.args.data) !== 'object' && JS_Tree_Debug_Node.get_type(this.args.data) !== 'array') { %>
|
||||||
|
<span class="js-tree-debug-value js-tree-debug-<%= JS_Tree_Debug_Node.get_type(this.args.data) %>"><%= JS_Tree_Debug_Node.format_value(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
|
||||||
|
<% } else { %>
|
||||||
|
<JS_Tree_Debug_Node
|
||||||
|
$data=this.args.data
|
||||||
|
$expand_depth=(this.args.expand_depth ?? 1)
|
||||||
|
$label=(this.args.root_label || null)
|
||||||
|
$show_class_names=(this.args.show_class_names ?? false)
|
||||||
|
/>
|
||||||
|
<% } %>
|
||||||
|
</Define:JS_Tree_Debug_Component>
|
||||||
3
app/RSpade/Components/JS_Tree_Debug_Component.js
Executable file
3
app/RSpade/Components/JS_Tree_Debug_Component.js
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
class JS_Tree_Debug_Component extends Component {
|
||||||
|
// No special logic needed at root level - just passes data to JS_Tree_Debug_Node
|
||||||
|
}
|
||||||
68
app/RSpade/Components/JS_Tree_Debug_Node.jqhtml
Executable file
68
app/RSpade/Components/JS_Tree_Debug_Node.jqhtml
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
<%--
|
||||||
|
JS_Tree_Debug_Node (Internal component for JS_Tree_Debug_Component)
|
||||||
|
|
||||||
|
Renders a single expandable node in the debug tree.
|
||||||
|
Not intended for direct use - use JS_Tree_Debug_Component instead.
|
||||||
|
|
||||||
|
$data - The object or array to render
|
||||||
|
$expand_depth - How many levels deep to expand
|
||||||
|
$label - Optional key/index label for this node
|
||||||
|
$show_class_names - If true, display class names for named object instances
|
||||||
|
--%>
|
||||||
|
<Define:JS_Tree_Debug_Node tag="div" class="js-tree-debug-node">
|
||||||
|
<%
|
||||||
|
const class_name = this.args.show_class_names ? JS_Tree_Debug_Node.get_class_name(this.args.data) : null;
|
||||||
|
const relationships = JS_Tree_Debug_Node.get_object_relationships(this.args.data);
|
||||||
|
%>
|
||||||
|
<span class="js-tree-debug-toggle<%= this.is_expanded ? '' : ' js-tree-debug-collapsed' %>" $sid="toggle" @click=this.toggle>
|
||||||
|
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
|
||||||
|
</span>
|
||||||
|
<% if (this.args.label !== null && this.args.label !== undefined) { %>
|
||||||
|
<span class="js-tree-debug-key"><%= this.args.label %></span><span class="js-tree-debug-colon">: </span>
|
||||||
|
<% } %>
|
||||||
|
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? '[' : '{' %></span>
|
||||||
|
<% if (class_name) { %>
|
||||||
|
<span class="js-tree-debug-class-badge"><span class="js-tree-debug-class-name"><%= class_name %></span><% if (this.args.data && this.args.data.id !== undefined) { %><span class="js-tree-debug-class-paren">(</span><span class="js-tree-debug-class-id"><%= this.args.data.id %></span><span class="js-tree-debug-class-paren">)</span><% } %></span>
|
||||||
|
<% } %>
|
||||||
|
<span class="js-tree-debug-preview-collapsed" $sid="preview_collapsed" style="<%= this.is_expanded ? 'display:none' : '' %>"><%= JS_Tree_Debug_Node.get_preview(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
|
||||||
|
|
||||||
|
<div class="js-tree-debug-children" $sid="children" style="<%= this.is_expanded ? '' : 'display:none' %>">
|
||||||
|
<%-- Regular data entries --%>
|
||||||
|
<% for (const [key, value] of JS_Tree_Debug_Node.get_entries(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data))) {
|
||||||
|
const val_type = JS_Tree_Debug_Node.get_type(value);
|
||||||
|
const is_expandable = val_type === 'object' || val_type === 'array';
|
||||||
|
%>
|
||||||
|
<% if (is_expandable) { %>
|
||||||
|
<JS_Tree_Debug_Node
|
||||||
|
$data=value
|
||||||
|
$expand_depth=((this.args.expand_depth ?? 1) - 1)
|
||||||
|
$label=key
|
||||||
|
$show_class_names=(this.args.show_class_names ?? false)
|
||||||
|
/>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="js-tree-debug-leaf">
|
||||||
|
<span class="js-tree-debug-key"><%= key %></span><span class="js-tree-debug-colon">: </span>
|
||||||
|
<span class="js-tree-debug-value js-tree-debug-<%= val_type %>"><%= JS_Tree_Debug_Node.format_value(value, val_type) %></span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%-- Relationship nodes (lazy-loaded) --%>
|
||||||
|
<% for (const rel_name of relationships) { %>
|
||||||
|
<div class="js-tree-debug-node js-tree-debug-relationship" $sid="rel_<%= rel_name %>">
|
||||||
|
<%
|
||||||
|
this.handler_toggle_rel = () => this.toggle_relationship(rel_name);
|
||||||
|
%>
|
||||||
|
<span class="js-tree-debug-toggle js-tree-debug-collapsed" @click=this.handler_toggle_rel>
|
||||||
|
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
|
||||||
|
</span>
|
||||||
|
<span class="js-tree-debug-key js-tree-debug-rel-key"><%= rel_name %>()</span><span class="js-tree-debug-colon">: </span>
|
||||||
|
<span class="js-tree-debug-preview-collapsed">...</span>
|
||||||
|
<div class="js-tree-debug-rel-children js-tree-debug-children" style="display:none">
|
||||||
|
<div class="js-tree-debug-leaf js-tree-debug-pending">(click to load)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? ']' : '}' %></span>
|
||||||
|
</Define:JS_Tree_Debug_Node>
|
||||||
257
app/RSpade/Components/JS_Tree_Debug_Node.js
Executable file
257
app/RSpade/Components/JS_Tree_Debug_Node.js
Executable file
@@ -0,0 +1,257 @@
|
|||||||
|
class JS_Tree_Debug_Node extends Component {
|
||||||
|
|
||||||
|
on_create() {
|
||||||
|
this.is_expanded = (this.args.expand_depth ?? 1) > 0;
|
||||||
|
// Track relationship loading states: { rel_name: 'pending'|'loading'|'loaded'|'error' }
|
||||||
|
this.state.rel_states = {};
|
||||||
|
// Store loaded relationship data: { rel_name: data }
|
||||||
|
this.state.rel_data = {};
|
||||||
|
// Store error messages: { rel_name: error_message }
|
||||||
|
this.state.rel_errors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
on_ready() {
|
||||||
|
// Relationships are never auto-loaded - they only load when explicitly expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.is_expanded = !this.is_expanded;
|
||||||
|
this.$sid('children').toggle(this.is_expanded);
|
||||||
|
this.$sid('toggle').toggleClass('js-tree-debug-collapsed', !this.is_expanded);
|
||||||
|
this.$sid('preview_collapsed').toggle(!this.is_expanded);
|
||||||
|
// Note: Relationships are NOT auto-loaded here - they have their own toggle handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a relationship node and load its data if not already loaded
|
||||||
|
*/
|
||||||
|
toggle_relationship(rel_name) {
|
||||||
|
const $container = this.$sid('rel_' + rel_name);
|
||||||
|
const $toggle = $container.find('.js-tree-debug-toggle').first();
|
||||||
|
const $children = $container.find('.js-tree-debug-rel-children').first();
|
||||||
|
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
|
||||||
|
|
||||||
|
const is_expanded = !$toggle.hasClass('js-tree-debug-collapsed');
|
||||||
|
|
||||||
|
if (is_expanded) {
|
||||||
|
// Collapse
|
||||||
|
$toggle.addClass('js-tree-debug-collapsed');
|
||||||
|
$children.hide();
|
||||||
|
$preview.show();
|
||||||
|
} else {
|
||||||
|
// Expand
|
||||||
|
$toggle.removeClass('js-tree-debug-collapsed');
|
||||||
|
$children.show();
|
||||||
|
$preview.hide();
|
||||||
|
|
||||||
|
// Load if not already loaded
|
||||||
|
if (!this.state.rel_states[rel_name] || this.state.rel_states[rel_name] === 'pending') {
|
||||||
|
this._load_relationship(rel_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a relationship and update the UI
|
||||||
|
*/
|
||||||
|
async _load_relationship(rel_name) {
|
||||||
|
// Validate the relationship function exists
|
||||||
|
const obj = this.args.data;
|
||||||
|
if (!obj || typeof obj[rel_name] !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.rel_states[rel_name] = 'loading';
|
||||||
|
this._update_relationship_ui(rel_name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await obj[rel_name]();
|
||||||
|
this.state.rel_states[rel_name] = 'loaded';
|
||||||
|
this.state.rel_data[rel_name] = result;
|
||||||
|
this._update_relationship_ui(rel_name);
|
||||||
|
} catch (e) {
|
||||||
|
this.state.rel_states[rel_name] = 'error';
|
||||||
|
this.state.rel_errors[rel_name] = e.message || 'Error loading relationship';
|
||||||
|
this._update_relationship_ui(rel_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the UI for a relationship after loading
|
||||||
|
*/
|
||||||
|
_update_relationship_ui(rel_name) {
|
||||||
|
const $container = this.$sid('rel_' + rel_name);
|
||||||
|
const $children = $container.find('.js-tree-debug-rel-children').first();
|
||||||
|
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
|
||||||
|
|
||||||
|
const state = this.state.rel_states[rel_name];
|
||||||
|
const data = this.state.rel_data[rel_name];
|
||||||
|
const error = this.state.rel_errors[rel_name];
|
||||||
|
|
||||||
|
// Update preview text
|
||||||
|
if (state === 'loading') {
|
||||||
|
$preview.html('<span class="js-tree-debug-loading">loading...</span>');
|
||||||
|
} else if (state === 'error') {
|
||||||
|
$preview.html('<span class="js-tree-debug-error">error</span>');
|
||||||
|
} else if (state === 'loaded') {
|
||||||
|
const type = JS_Tree_Debug_Node.get_type(data);
|
||||||
|
$preview.text(JS_Tree_Debug_Node.get_preview(data, type) || JS_Tree_Debug_Node.format_value(data, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update children content
|
||||||
|
$children.empty();
|
||||||
|
|
||||||
|
if (state === 'loading') {
|
||||||
|
$children.html('<div class="js-tree-debug-leaf js-tree-debug-loading">Loading...</div>');
|
||||||
|
} else if (state === 'error') {
|
||||||
|
$children.html('<div class="js-tree-debug-leaf js-tree-debug-error">"' + this._escape_html(error) + '"</div>');
|
||||||
|
} else if (state === 'loaded') {
|
||||||
|
this._render_relationship_result($children, data, rel_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the result of a relationship fetch into the container
|
||||||
|
* Renders entries directly (not wrapped in another node) so user doesn't have to double-expand
|
||||||
|
*/
|
||||||
|
_render_relationship_result($container, data, rel_name) {
|
||||||
|
const type = JS_Tree_Debug_Node.get_type(data);
|
||||||
|
|
||||||
|
// Handle null/undefined
|
||||||
|
if (type === 'null' || type === 'undefined') {
|
||||||
|
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(no data)</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty array
|
||||||
|
if (type === 'array' && data.length === 0) {
|
||||||
|
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty array)</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty object
|
||||||
|
if (type === 'object' && Object.keys(data).length === 0) {
|
||||||
|
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty object)</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle primitive values (shouldn't happen but be safe)
|
||||||
|
if (type !== 'array' && type !== 'object') {
|
||||||
|
$container.html('<div class="js-tree-debug-leaf"><span class="js-tree-debug-value js-tree-debug-' + type + '">' +
|
||||||
|
this._escape_html(JS_Tree_Debug_Node.format_value(data, type)) + '</span></div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render entries directly into container (no wrapper node)
|
||||||
|
const entries = JS_Tree_Debug_Node.get_entries(data, type);
|
||||||
|
const expand_depth = Math.max(0, (this.args.expand_depth ?? 1) - 1);
|
||||||
|
const show_class_names = this.args.show_class_names ?? false;
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const val_type = JS_Tree_Debug_Node.get_type(value);
|
||||||
|
const is_expandable = val_type === 'object' || val_type === 'array';
|
||||||
|
|
||||||
|
if (is_expandable) {
|
||||||
|
// Create a node for expandable values
|
||||||
|
const $node = $('<div>');
|
||||||
|
$container.append($node);
|
||||||
|
$node.component('JS_Tree_Debug_Node', {
|
||||||
|
data: value,
|
||||||
|
expand_depth: expand_depth,
|
||||||
|
label: key,
|
||||||
|
show_class_names: show_class_names
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Render leaf value directly
|
||||||
|
$container.append(
|
||||||
|
'<div class="js-tree-debug-leaf">' +
|
||||||
|
'<span class="js-tree-debug-key">' + this._escape_html(String(key)) + '</span>' +
|
||||||
|
'<span class="js-tree-debug-colon">: </span>' +
|
||||||
|
'<span class="js-tree-debug-value js-tree-debug-' + val_type + '">' +
|
||||||
|
this._escape_html(JS_Tree_Debug_Node.format_value(value, val_type)) +
|
||||||
|
'</span></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_escape_html(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static helpers for template use
|
||||||
|
static get_type(val) {
|
||||||
|
if (val === null) return 'null';
|
||||||
|
if (val === undefined) return 'undefined';
|
||||||
|
if (is_array(val)) return 'array';
|
||||||
|
return typeof val;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get_preview(val, type) {
|
||||||
|
if (type === 'array') return 'Array(' + val.length + ')';
|
||||||
|
if (type === 'object') {
|
||||||
|
const keys = Object.keys(val);
|
||||||
|
if (keys.length === 0) return '{}';
|
||||||
|
if (keys.length <= 3) return '{' + keys.join(', ') + '}';
|
||||||
|
return '{' + keys.slice(0, 3).join(', ') + ', ...}';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get_entries(data, type) {
|
||||||
|
if (type === 'array') return data.map((v, i) => [i, v]);
|
||||||
|
return Object.entries(data || {}).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
static format_value(val, type) {
|
||||||
|
if (type === 'string') return '"' + val + '"';
|
||||||
|
if (type === 'null') return 'null';
|
||||||
|
if (type === 'undefined') return 'undefined';
|
||||||
|
return str(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the class name of an object if it's a named class instance (not generic Object/Array)
|
||||||
|
* @param {*} val - Value to check
|
||||||
|
* @returns {string|null} - Class name or null if generic/primitive
|
||||||
|
*/
|
||||||
|
static get_class_name(val) {
|
||||||
|
if (val === null || val === undefined) return null;
|
||||||
|
if (Array.isArray(val)) return null;
|
||||||
|
if (typeof val !== 'object') return null;
|
||||||
|
const name = val.constructor?.name;
|
||||||
|
if (!name || name === 'Object') return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fetchable relationships for an object
|
||||||
|
* Returns array of relationship names if object's class has get_relationships()
|
||||||
|
* @param {*} obj - Object to check
|
||||||
|
* @returns {string[]} - Array of relationship names, or empty array
|
||||||
|
*/
|
||||||
|
static get_object_relationships(obj) {
|
||||||
|
try {
|
||||||
|
if (!obj || typeof obj !== 'object') return [];
|
||||||
|
if (Array.isArray(obj)) return [];
|
||||||
|
|
||||||
|
// Check if constructor has get_relationships static method
|
||||||
|
const ctor = obj.constructor;
|
||||||
|
if (!ctor || typeof ctor.get_relationships !== 'function') return [];
|
||||||
|
|
||||||
|
// Get relationships and validate it returns an array
|
||||||
|
const relationships = ctor.get_relationships();
|
||||||
|
if (!Array.isArray(relationships)) return [];
|
||||||
|
|
||||||
|
// Filter to only relationships that are actually functions on the object
|
||||||
|
return relationships.filter(name => {
|
||||||
|
return typeof name === 'string' && typeof obj[name] === 'function';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Any error, just return empty array
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
app/RSpade/Components/js_tree_debug_component.scss
Executable file
150
app/RSpade/Components/js_tree_debug_component.scss
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
.js-tree-debug {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
width: 800px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
overflow: auto;
|
||||||
|
resize: both;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #777;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.js-tree-debug-node {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-children {
|
||||||
|
margin-left: 20px;
|
||||||
|
border-left: 1px solid #e0e0e0;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-leaf {
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.js-tree-debug-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
transition: transform 0.15s ease; // @SCSS-ANIM-01-EXCEPTION
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.js-tree-debug-collapsed) .js-tree-debug-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .js-tree-debug-arrow {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-key {
|
||||||
|
color: #881391;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-colon {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-preview {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-preview-collapsed {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-bracket-close {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value type colors
|
||||||
|
.js-tree-debug-string {
|
||||||
|
color: #c41a16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-number {
|
||||||
|
color: #1c00cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-boolean {
|
||||||
|
color: #0d22aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-null,
|
||||||
|
.js-tree-debug-undefined {
|
||||||
|
color: #808080;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-value {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-class-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
.js-tree-debug-class-name {
|
||||||
|
color: #881391; // Same as keys
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-class-paren {
|
||||||
|
color: #666; // Same as colons/symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-tree-debug-class-id {
|
||||||
|
color: #1c00cf; // Same as numbers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship nodes (lazy-loaded)
|
||||||
|
.js-tree-debug-relationship {
|
||||||
|
.js-tree-debug-rel-key {
|
||||||
|
color: #0066cc;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
.js-tree-debug-loading {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
.js-tree-debug-error {
|
||||||
|
color: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty/no data state
|
||||||
|
.js-tree-debug-empty {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending (not yet loaded)
|
||||||
|
.js-tree-debug-pending {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
|||||||
* "C_1": {success: false, error_type: "...", reason: "..."},
|
* "C_1": {success: false, error_type: "...", reason: "..."},
|
||||||
* ...
|
* ...
|
||||||
* }
|
* }
|
||||||
|
*
|
||||||
|
* @auth-exempt Auth is handled per-call inside Ajax::internal()
|
||||||
*/
|
*/
|
||||||
class Ajax_Batch_Controller extends Rsx_Controller_Abstract
|
class Ajax_Batch_Controller extends Rsx_Controller_Abstract
|
||||||
{
|
{
|
||||||
@@ -34,7 +36,6 @@ class Ajax_Batch_Controller extends Rsx_Controller_Abstract
|
|||||||
* @param array $params
|
* @param array $params
|
||||||
* @return \Illuminate\Http\JsonResponse
|
* @return \Illuminate\Http\JsonResponse
|
||||||
*/
|
*/
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
#[Route('/_ajax/_batch', methods: ['POST'])]
|
#[Route('/_ajax/_batch', methods: ['POST'])]
|
||||||
public static function batch(Request $request, array $params = [])
|
public static function batch(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
|
|||||||
180
app/RSpade/Core/Api/Api_Key_Model.php
Executable file
180
app/RSpade/Core/Api/Api_Key_Model.php
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\Core\Api;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use App\RSpade\Core\Database\Models\Rsx_System_Model_Abstract;
|
||||||
|
use App\RSpade\Core\Models\User_Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Api_Key_Model - System model for API key management
|
||||||
|
*
|
||||||
|
* API keys are system-level records that authenticate external API requests.
|
||||||
|
* Keys are tied to users and establish session-like contexts for API access.
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - Keys are hashed before storage (plaintext never stored)
|
||||||
|
* - Key prefix stored for identification without exposing full key
|
||||||
|
* - Keys can be revoked (soft disable) or have expiration dates
|
||||||
|
*
|
||||||
|
* @see external_api.txt for full documentation
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property string $name
|
||||||
|
* @property string $key_hash
|
||||||
|
* @property string $key_prefix
|
||||||
|
* @property int|null $user_role_id
|
||||||
|
* @property \Carbon\Carbon|null $last_used_at
|
||||||
|
* @property \Carbon\Carbon|null $expires_at
|
||||||
|
* @property bool $is_revoked
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class Api_Key_Model extends Rsx_System_Model_Abstract
|
||||||
|
{
|
||||||
|
protected $table = '_api_keys';
|
||||||
|
|
||||||
|
public static $enums = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_revoked' => 'boolean',
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new API key for a user
|
||||||
|
*
|
||||||
|
* Returns the plaintext key (only time it's available) and the model.
|
||||||
|
* The plaintext key must be shown to the user immediately as it cannot
|
||||||
|
* be recovered after this method returns.
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID to create key for
|
||||||
|
* @param string $name Human-readable key name
|
||||||
|
* @param string $environment 'live' or 'test'
|
||||||
|
* @param int|null $user_role_id Optional role override
|
||||||
|
* @param \Carbon\Carbon|null $expires_at Optional expiration date
|
||||||
|
* @return array{key: string, model: Api_Key_Model} Plaintext key and saved model
|
||||||
|
*/
|
||||||
|
public static function generate(
|
||||||
|
int $user_id,
|
||||||
|
string $name,
|
||||||
|
string $environment = 'live',
|
||||||
|
?int $user_role_id = null,
|
||||||
|
?\Carbon\Carbon $expires_at = null
|
||||||
|
): array {
|
||||||
|
// Generate random key: rsk_{env}_{32 random chars}
|
||||||
|
$random = Str::random(32);
|
||||||
|
$plaintext_key = "rsk_{$environment}_{$random}";
|
||||||
|
$prefix = "rsk_{$environment}_" . substr($random, 0, 4) . '...';
|
||||||
|
|
||||||
|
$model = new self();
|
||||||
|
$model->user_id = $user_id;
|
||||||
|
$model->name = $name;
|
||||||
|
$model->key_hash = hash('sha256', $plaintext_key);
|
||||||
|
$model->key_prefix = $prefix;
|
||||||
|
$model->user_role_id = $user_role_id;
|
||||||
|
$model->expires_at = $expires_at;
|
||||||
|
$model->is_revoked = false;
|
||||||
|
$model->save();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => $plaintext_key,
|
||||||
|
'model' => $model,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an API key by its plaintext value
|
||||||
|
*
|
||||||
|
* Hashes the provided key and looks up by hash.
|
||||||
|
* Returns null if not found, revoked, or expired.
|
||||||
|
*
|
||||||
|
* @param string $plaintext_key The API key from request
|
||||||
|
* @return Api_Key_Model|null The key model if valid, null otherwise
|
||||||
|
*/
|
||||||
|
public static function find_by_key(string $plaintext_key): ?self
|
||||||
|
{
|
||||||
|
$hash = hash('sha256', $plaintext_key);
|
||||||
|
|
||||||
|
$key = self::where('key_hash', $hash)
|
||||||
|
->where('is_revoked', false)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if ($key->expires_at && $key->expires_at->isPast()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active (non-revoked) keys for a user
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public static function get_for_user(int $user_id)
|
||||||
|
{
|
||||||
|
return self::where('user_id', $user_id)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke this API key
|
||||||
|
*
|
||||||
|
* Soft-disables the key. It remains in the database but can no longer
|
||||||
|
* be used for authentication.
|
||||||
|
*/
|
||||||
|
public function revoke(): void
|
||||||
|
{
|
||||||
|
$this->is_revoked = true;
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last_used_at timestamp
|
||||||
|
*
|
||||||
|
* Called when the key is used for authentication.
|
||||||
|
*/
|
||||||
|
public function touch_last_used(): void
|
||||||
|
{
|
||||||
|
$this->last_used_at = now();
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user this key belongs to
|
||||||
|
*
|
||||||
|
* @return User_Model|null
|
||||||
|
*/
|
||||||
|
public function get_user(): ?User_Model
|
||||||
|
{
|
||||||
|
return User_Model::find($this->user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this key is currently valid
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function is_valid(): bool
|
||||||
|
{
|
||||||
|
if ($this->is_revoked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,20 +62,10 @@ class RsxBootstrap
|
|||||||
* Artisan commands and always_write_lock mode use WRITE lock.
|
* Artisan commands and always_write_lock mode use WRITE lock.
|
||||||
* This can be upgraded to WRITE for exclusive operations.
|
* This can be upgraded to WRITE for exclusive operations.
|
||||||
*
|
*
|
||||||
* FPC clients (Playwright spawned for SSR cache generation) skip lock acquisition
|
|
||||||
* to avoid deadlock with the parent request that spawned them.
|
|
||||||
*
|
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function __acquire_application_lock(): void
|
private static function __acquire_application_lock(): void
|
||||||
{
|
{
|
||||||
// Skip lock acquisition for FPC clients to avoid deadlock
|
|
||||||
// FPC clients are identified by X-RSpade-FPC-Client: 1 header
|
|
||||||
if (isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1') {
|
|
||||||
console_debug('CONCURRENCY', 'Skipping application lock for FPC client');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$always_write = config('rsx.locking.always_write_lock', false);
|
$always_write = config('rsx.locking.always_write_lock', false);
|
||||||
|
|
||||||
// Detect artisan commands by checking if running from CLI and the script name contains 'artisan'
|
// Detect artisan commands by checking if running from CLI and the script name contains 'artisan'
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use RecursiveCallbackFilterIterator;
|
|||||||
use RecursiveDirectoryIterator;
|
use RecursiveDirectoryIterator;
|
||||||
use RecursiveIteratorIterator;
|
use RecursiveIteratorIterator;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||||
|
use App\RSpade\Core\Bundle\Rsx_Module_Bundle_Abstract;
|
||||||
use App\RSpade\Core\Locks\RsxLocks;
|
use App\RSpade\Core\Locks\RsxLocks;
|
||||||
use App\RSpade\Core\Manifest\Manifest;
|
use App\RSpade\Core\Manifest\Manifest;
|
||||||
|
|
||||||
@@ -91,6 +93,12 @@ class BundleCompiler
|
|||||||
*/
|
*/
|
||||||
protected array $resolved_includes = [];
|
protected array $resolved_includes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root module bundle class being compiled
|
||||||
|
* Used for validation error messages
|
||||||
|
*/
|
||||||
|
protected string $root_bundle_class = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compiled jqhtml files (separated during ordering for special placement)
|
* Compiled jqhtml files (separated during ordering for special placement)
|
||||||
*/
|
*/
|
||||||
@@ -129,6 +137,7 @@ class BundleCompiler
|
|||||||
|
|
||||||
// Step 2: Mark the bundle we're compiling as already resolved
|
// Step 2: Mark the bundle we're compiling as already resolved
|
||||||
$this->resolved_includes[$bundle_class] = true;
|
$this->resolved_includes[$bundle_class] = true;
|
||||||
|
$this->root_bundle_class = $bundle_class;
|
||||||
|
|
||||||
// Step 3: Process required bundles first
|
// Step 3: Process required bundles first
|
||||||
$this->_process_required_bundles();
|
$this->_process_required_bundles();
|
||||||
@@ -467,18 +476,73 @@ class BundleCompiler
|
|||||||
$this->_process_include_item($bundle_aliases[$alias]);
|
$this->_process_include_item($bundle_aliases[$alias]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include custom JS model base class if configured
|
||||||
|
// This allows users to define application-wide model functionality
|
||||||
|
$js_model_base_class = config('rsx.js_model_base_class');
|
||||||
|
if ($js_model_base_class) {
|
||||||
|
$this->_include_js_model_base_class($js_model_base_class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include the custom JS model base class file in the bundle
|
||||||
|
*
|
||||||
|
* Finds the JS file by class name in the manifest and adds it to the bundle.
|
||||||
|
* Validates that the class extends Rsx_Js_Model.
|
||||||
|
*/
|
||||||
|
protected function _include_js_model_base_class(string $class_name): void
|
||||||
|
{
|
||||||
|
// Find the JS file in the manifest by class name
|
||||||
|
try {
|
||||||
|
$file_path = Manifest::js_find_class($class_name);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"JavaScript model base class '{$class_name}' configured in rsx.js_model_base_class not found in manifest.\n" .
|
||||||
|
"Ensure the class is defined in a .js file within your application (e.g., rsx/lib/{$class_name}.js)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata to verify it extends Rsx_Js_Model
|
||||||
|
$metadata = Manifest::get_file($file_path);
|
||||||
|
$extends = $metadata['extends'] ?? null;
|
||||||
|
|
||||||
|
if ($extends !== 'Rsx_Js_Model') {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"JavaScript model base class '{$class_name}' must extend Rsx_Js_Model.\n" .
|
||||||
|
"Found: extends {$extends}\n" .
|
||||||
|
"File: {$file_path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the file to the bundle by processing it as a path
|
||||||
|
$this->_process_include_item($file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve bundle and all its includes
|
* Resolve bundle and all its includes
|
||||||
|
*
|
||||||
|
* @param string $bundle_class The bundle class to resolve
|
||||||
|
* @param bool $discovered_via_scan Whether this bundle was discovered via directory scan
|
||||||
*/
|
*/
|
||||||
protected function _resolve_bundle(string $bundle_class): void
|
protected function _resolve_bundle(string $bundle_class, bool $discovered_via_scan = false): void
|
||||||
{
|
{
|
||||||
// Get bundle definition
|
// Get bundle definition
|
||||||
if (!method_exists($bundle_class, 'define')) {
|
if (!method_exists($bundle_class, 'define')) {
|
||||||
throw new Exception("Bundle {$bundle_class} missing define() method");
|
throw new Exception("Bundle {$bundle_class} missing define() method");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate module bundle doesn't include another module bundle
|
||||||
|
if (Manifest::php_is_subclass_of($bundle_class, 'Rsx_Module_Bundle_Abstract') &&
|
||||||
|
$bundle_class !== $this->root_bundle_class) {
|
||||||
|
Rsx_Module_Bundle_Abstract::validate_include($bundle_class, $this->root_bundle_class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate asset bundles discovered via scan don't have directory paths
|
||||||
|
if ($discovered_via_scan && Manifest::php_is_subclass_of($bundle_class, 'Rsx_Asset_Bundle_Abstract')) {
|
||||||
|
Rsx_Asset_Bundle_Abstract::validate_no_directory_scanning($bundle_class, $this->root_bundle_class);
|
||||||
|
}
|
||||||
|
|
||||||
$definition = $bundle_class::define();
|
$definition = $bundle_class::define();
|
||||||
|
|
||||||
// Process bundle includes
|
// Process bundle includes
|
||||||
@@ -732,6 +796,8 @@ class BundleCompiler
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add all files from a directory
|
* Add all files from a directory
|
||||||
|
*
|
||||||
|
* Also auto-discovers Asset Bundles in the directory and processes them.
|
||||||
*/
|
*/
|
||||||
protected function _add_directory(string $path): void
|
protected function _add_directory(string $path): void
|
||||||
{
|
{
|
||||||
@@ -742,6 +808,9 @@ class BundleCompiler
|
|||||||
// Get excluded directories from config
|
// Get excluded directories from config
|
||||||
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
|
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
|
||||||
|
|
||||||
|
// Track discovered asset bundles to process after file collection
|
||||||
|
$discovered_bundles = [];
|
||||||
|
|
||||||
// Create a recursive directory iterator with filtering
|
// Create a recursive directory iterator with filtering
|
||||||
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
|
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||||
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
|
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
|
||||||
@@ -763,7 +832,42 @@ class BundleCompiler
|
|||||||
|
|
||||||
foreach ($iterator as $file) {
|
foreach ($iterator as $file) {
|
||||||
if ($file->isFile()) {
|
if ($file->isFile()) {
|
||||||
$this->_add_file($file->getPathname());
|
$filepath = $file->getPathname();
|
||||||
|
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
// For PHP files, check if it's an asset bundle via manifest
|
||||||
|
if ($extension === 'php') {
|
||||||
|
$relative_path = str_replace(base_path() . '/', '', $filepath);
|
||||||
|
|
||||||
|
// Get file metadata from manifest to check if it's an asset bundle
|
||||||
|
try {
|
||||||
|
$file_meta = Manifest::get_file($relative_path);
|
||||||
|
$class_name = $file_meta['class'] ?? null;
|
||||||
|
|
||||||
|
// Use manifest to check if this PHP class is an asset bundle
|
||||||
|
if ($class_name && Manifest::php_is_subclass_of($class_name, 'Rsx_Asset_Bundle_Abstract')) {
|
||||||
|
$fqcn = $file_meta['fqcn'] ?? null;
|
||||||
|
if ($fqcn && !isset($this->resolved_includes[$fqcn])) {
|
||||||
|
$discovered_bundles[] = $fqcn;
|
||||||
|
console_debug('BUNDLE', "Auto-discovered asset bundle: {$fqcn}");
|
||||||
|
}
|
||||||
|
// Don't add bundle file itself to file list - we'll process it as a bundle
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
// File not in manifest, just add it normally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->_add_file($filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process discovered asset bundles (marked as discovered via scan)
|
||||||
|
foreach ($discovered_bundles as $bundle_fqcn) {
|
||||||
|
if (!isset($this->resolved_includes[$bundle_fqcn])) {
|
||||||
|
$this->resolved_includes[$bundle_fqcn] = true;
|
||||||
|
$this->_resolve_bundle($bundle_fqcn, true); // true = discovered via scan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1163,154 @@ class BundleCompiler
|
|||||||
return array_unique($stubs);
|
return array_unique($stubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate concrete model classes for PHP models in the bundle
|
||||||
|
*
|
||||||
|
* For each PHP model (subclass of Rsx_Model_Abstract) in the bundle:
|
||||||
|
* 1. Check if a user-defined JS class with the same name exists
|
||||||
|
* 2. If user-defined class exists:
|
||||||
|
* - Validate it extends Base_{ModelName} directly
|
||||||
|
* - If it exists in manifest but not in bundle, throw error
|
||||||
|
* 3. If no user-defined class exists:
|
||||||
|
* - Auto-generate: class ModelName extends Base_ModelName {}
|
||||||
|
*
|
||||||
|
* @param array $current_js_files JS files already in the bundle (to check for user classes)
|
||||||
|
* @return string|null Path to temp file containing generated classes, or null if none needed
|
||||||
|
*/
|
||||||
|
protected function _generate_concrete_model_classes(array $current_js_files): ?string
|
||||||
|
{
|
||||||
|
$manifest = Manifest::get_full_manifest();
|
||||||
|
$manifest_files = $manifest['data']['files'] ?? [];
|
||||||
|
|
||||||
|
// Get all files from all bundles to find PHP models
|
||||||
|
$all_bundle_files = [];
|
||||||
|
foreach ($this->bundle_files as $type => $files) {
|
||||||
|
if (is_array($files)) {
|
||||||
|
$all_bundle_files = array_merge($all_bundle_files, $files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a set of JS class names currently in the bundle for quick lookup
|
||||||
|
$js_classes_in_bundle = [];
|
||||||
|
foreach ($current_js_files as $js_file) {
|
||||||
|
$relative = str_replace(base_path() . '/', '', $js_file);
|
||||||
|
if (isset($manifest_files[$relative]['class'])) {
|
||||||
|
$js_classes_in_bundle[$manifest_files[$relative]['class']] = $relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all PHP models in the bundle
|
||||||
|
$models_in_bundle = [];
|
||||||
|
foreach ($all_bundle_files as $file) {
|
||||||
|
$relative = str_replace(base_path() . '/', '', $file);
|
||||||
|
|
||||||
|
// Check if this is a PHP file with a class
|
||||||
|
if (!isset($manifest_files[$relative]['class'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$class_name = $manifest_files[$relative]['class'];
|
||||||
|
|
||||||
|
// Check if this class is a subclass of Rsx_Model_Abstract (but not system models)
|
||||||
|
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Model_Abstract')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip abstract model classes - only concrete models get JS stubs
|
||||||
|
if (Manifest::php_is_abstract($class_name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$models_in_bundle[$class_name] = $relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($models_in_bundle)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console_debug('BUNDLE', 'Found ' . count($models_in_bundle) . ' PHP models in bundle: ' . implode(', ', array_keys($models_in_bundle)));
|
||||||
|
|
||||||
|
// Process each model
|
||||||
|
$generated_classes = [];
|
||||||
|
$base_class_name = config('rsx.js_model_base_class');
|
||||||
|
|
||||||
|
foreach ($models_in_bundle as $model_name => $model_path) {
|
||||||
|
$expected_base_class = 'Base_' . $model_name;
|
||||||
|
|
||||||
|
// Check if user has defined a JS class with this model name
|
||||||
|
$user_js_class_path = null;
|
||||||
|
foreach ($manifest_files as $file_path => $meta) {
|
||||||
|
if (isset($meta['class']) && $meta['class'] === $model_name && isset($meta['extension']) && $meta['extension'] === 'js') {
|
||||||
|
// Make sure it's not a generated stub
|
||||||
|
if (!isset($meta['is_model_stub']) && !isset($meta['is_stub'])) {
|
||||||
|
$user_js_class_path = $file_path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user_js_class_path) {
|
||||||
|
// User has defined a JS class for this model - validate it
|
||||||
|
console_debug('BUNDLE', "Found user-defined JS class for {$model_name} at {$user_js_class_path}");
|
||||||
|
|
||||||
|
// Check if it's in the bundle
|
||||||
|
if (!isset($js_classes_in_bundle[$model_name])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"PHP model '{$model_name}' is included in bundle (at {$model_path}) " .
|
||||||
|
"but its custom JavaScript implementation exists at '{$user_js_class_path}' " .
|
||||||
|
"and is NOT included in the bundle.\n\n" .
|
||||||
|
"Either:\n" .
|
||||||
|
"1. Add the JS file's directory to the bundle's include paths, or\n" .
|
||||||
|
"2. Remove the custom JS implementation to use auto-generated class"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate it extends the Base_ class directly
|
||||||
|
$user_meta = $manifest_files[$user_js_class_path] ?? [];
|
||||||
|
$user_extends = $user_meta['extends'] ?? null;
|
||||||
|
|
||||||
|
if ($user_extends !== $expected_base_class) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"JavaScript model class '{$model_name}' at '{$user_js_class_path}' " .
|
||||||
|
"must extend '{$expected_base_class}' directly.\n" .
|
||||||
|
"Found: extends " . ($user_extends ?: '(nothing)') . "\n\n" .
|
||||||
|
"Correct usage:\n" .
|
||||||
|
"class {$model_name} extends {$expected_base_class} {\n" .
|
||||||
|
" // Your custom model methods\n" .
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console_debug('BUNDLE', "Validated {$model_name} extends {$expected_base_class}");
|
||||||
|
} else {
|
||||||
|
// No user-defined class - auto-generate one
|
||||||
|
console_debug('BUNDLE', "Auto-generating concrete class for {$model_name}");
|
||||||
|
$generated_classes[] = "class {$model_name} extends {$expected_base_class} {}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($generated_classes)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write all generated classes to a single temp file using standard temp file pattern
|
||||||
|
$content = "/**\n";
|
||||||
|
$content .= " * Auto-generated concrete model classes\n";
|
||||||
|
$content .= " * These classes extend the Base_* stubs to provide usable model classes\n";
|
||||||
|
$content .= " * when no custom implementation is defined by the developer.\n";
|
||||||
|
$content .= " */\n\n";
|
||||||
|
$content .= implode("\n\n", $generated_classes) . "\n";
|
||||||
|
|
||||||
|
// Use content hash for idempotent file naming, with recognizable prefix for detection
|
||||||
|
$hash = substr(md5($content), 0, 8);
|
||||||
|
$temp_file = storage_path('rsx-tmp/bundle_generated_models_' . $this->bundle_name . '_' . $hash . '.js');
|
||||||
|
file_put_contents($temp_file, $content);
|
||||||
|
|
||||||
|
console_debug('BUNDLE', 'Generated ' . count($generated_classes) . ' concrete model classes');
|
||||||
|
|
||||||
|
return $temp_file;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order JavaScript files by class dependency
|
* Order JavaScript files by class dependency
|
||||||
*
|
*
|
||||||
@@ -1109,6 +1361,29 @@ class BundleCompiler
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a JS stub file (not in manifest, needs parsing)
|
||||||
|
// Stub files are in storage/rsx-build/js-stubs/ or storage/rsx-build/js-model-stubs/
|
||||||
|
if (str_contains($file, 'storage/rsx-build/js-stubs/') || str_contains($file, 'storage/rsx-build/js-model-stubs/')) {
|
||||||
|
// Use simple regex extraction - stub files have known format and can't use
|
||||||
|
// the strict JS parser (stubs may have code after class declaration)
|
||||||
|
$stub_content = file_get_contents($file);
|
||||||
|
$stub_metadata = $this->_extract_stub_class_info($stub_content);
|
||||||
|
|
||||||
|
if (!empty($stub_metadata['class'])) {
|
||||||
|
$class_files[] = $file;
|
||||||
|
$class_info[$file] = [
|
||||||
|
'class' => $stub_metadata['class'],
|
||||||
|
'extends' => $stub_metadata['extends'],
|
||||||
|
'decorators' => [],
|
||||||
|
'method_decorators' => [],
|
||||||
|
];
|
||||||
|
console_debug('BUNDLE_SORT', "Parsed stub file: {$stub_metadata['class']} extends " . ($stub_metadata['extends'] ?? 'nothing'));
|
||||||
|
} else {
|
||||||
|
$non_class_files[] = $file;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Get file info from manifest
|
// Get file info from manifest
|
||||||
$relative = str_replace(base_path() . '/', '', $file);
|
$relative = str_replace(base_path() . '/', '', $file);
|
||||||
$file_data = $manifest_files[$relative] ?? null;
|
$file_data = $manifest_files[$relative] ?? null;
|
||||||
@@ -1211,6 +1486,35 @@ class BundleCompiler
|
|||||||
return $decorators;
|
return $decorators;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract class name and extends from JS stub file content
|
||||||
|
*
|
||||||
|
* Uses simple regex extraction since stub files have a known format and may
|
||||||
|
* have code after the class declaration that the strict JS parser rejects.
|
||||||
|
*
|
||||||
|
* @param string $content The stub file content
|
||||||
|
* @return array ['class' => string|null, 'extends' => string|null]
|
||||||
|
*/
|
||||||
|
protected function _extract_stub_class_info(string $content): array
|
||||||
|
{
|
||||||
|
// Remove single-line comments
|
||||||
|
$content = preg_replace('#//.*$#m', '', $content);
|
||||||
|
|
||||||
|
// Remove multi-line comments (including JSDoc)
|
||||||
|
$content = preg_replace('#/\*.*?\*/#s', '', $content);
|
||||||
|
|
||||||
|
// Match: class ClassName or class ClassName extends ParentClass
|
||||||
|
// The first match wins - we only care about the class declaration
|
||||||
|
if (preg_match('/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+extends\s+([A-Za-z_][A-Za-z0-9_]*))?/', $content, $matches)) {
|
||||||
|
return [
|
||||||
|
'class' => $matches[1],
|
||||||
|
'extends' => $matches[2] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['class' => null, 'extends' => null];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Topological sort for class dependencies with decorator support
|
* Topological sort for class dependencies with decorator support
|
||||||
*
|
*
|
||||||
@@ -1419,7 +1723,13 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
|||||||
* Here we:
|
* Here we:
|
||||||
* 1. Filter to only .js and .css files
|
* 1. Filter to only .js and .css files
|
||||||
* 2. Order JS files by class dependency
|
* 2. Order JS files by class dependency
|
||||||
* 3. Add framework code (stubs, manifest, runner)
|
* 3. Add framework code:
|
||||||
|
* a. JS stubs (Base_* model classes, controller stubs, etc.)
|
||||||
|
* b. Compiled jqhtml templates
|
||||||
|
* c. Concrete model classes (auto-generated or validated user-defined)
|
||||||
|
* d. Manifest definitions (registers all JS classes)
|
||||||
|
* e. Route definitions
|
||||||
|
* f. Initialization runner (LAST - starts the application)
|
||||||
* 4. Generate final compiled output
|
* 4. Generate final compiled output
|
||||||
*/
|
*/
|
||||||
protected function _compile_outputs(array $types_to_compile = []): array
|
protected function _compile_outputs(array $types_to_compile = []): array
|
||||||
@@ -1456,7 +1766,16 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
|||||||
// Other extensions are ignored - they should have been processed into JS/CSS
|
// Other extensions are ignored - they should have been processed into JS/CSS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order JavaScript files by class dependency BEFORE adding framework code
|
// Add JS stubs to app bundle only (they depend on Rsx_Js_Model which is in app)
|
||||||
|
// Add them BEFORE dependency ordering so they're properly sorted
|
||||||
|
if ($type === 'app') {
|
||||||
|
$stub_files = $this->_get_js_stubs();
|
||||||
|
foreach ($stub_files as $stub) {
|
||||||
|
$files['js'][] = $stub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order JavaScript files by class dependency BEFORE adding other framework code
|
||||||
if (!empty($files['js'])) {
|
if (!empty($files['js'])) {
|
||||||
$files['js'] = $this->_order_javascript_files_by_dependency($files['js']);
|
$files['js'] = $this->_order_javascript_files_by_dependency($files['js']);
|
||||||
}
|
}
|
||||||
@@ -1482,7 +1801,8 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add JS stubs and framework code to app JS
|
// Add framework code to app JS
|
||||||
|
// Note: JS stubs are already added before dependency ordering above
|
||||||
if ($type === 'app') {
|
if ($type === 'app') {
|
||||||
// Add NPM import declarations at the very beginning
|
// Add NPM import declarations at the very beginning
|
||||||
if (!empty($this->npm_includes)) {
|
if (!empty($this->npm_includes)) {
|
||||||
@@ -1492,19 +1812,19 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALWAYS get JS stubs for ALL included files that have them
|
// Add compiled jqhtml files
|
||||||
// ANY file type can have a js_stub - controllers, models, custom types, etc.
|
|
||||||
$stub_files = $this->_get_js_stubs();
|
|
||||||
foreach ($stub_files as $stub) {
|
|
||||||
$files['js'][] = $stub;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add compiled jqhtml files AFTER JS stubs
|
|
||||||
// These are JavaScript files generated from .jqhtml templates
|
// These are JavaScript files generated from .jqhtml templates
|
||||||
foreach ($this->jqhtml_compiled_files as $jqhtml_file) {
|
foreach ($this->jqhtml_compiled_files as $jqhtml_file) {
|
||||||
$files['js'][] = $jqhtml_file;
|
$files['js'][] = $jqhtml_file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate concrete model classes for PHP models in the bundle
|
||||||
|
// This validates user-defined JS model classes and auto-generates missing ones
|
||||||
|
$concrete_models_file = $this->_generate_concrete_model_classes($files['js']);
|
||||||
|
if ($concrete_models_file) {
|
||||||
|
$files['js'][] = $concrete_models_file;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate manifest definitions for all JS classes
|
// Generate manifest definitions for all JS classes
|
||||||
$manifest_file = $this->_create_javascript_manifest($files['js'] ?? []);
|
$manifest_file = $this->_create_javascript_manifest($files['js'] ?? []);
|
||||||
if ($manifest_file) {
|
if ($manifest_file) {
|
||||||
@@ -2020,8 +2340,38 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
|||||||
|
|
||||||
// Analyze each JavaScript file for class information
|
// Analyze each JavaScript file for class information
|
||||||
foreach ($js_files as $file) {
|
foreach ($js_files as $file) {
|
||||||
// Skip temp files
|
// Skip most temp files, but handle auto-generated model classes
|
||||||
if (str_contains($file, 'storage/rsx-tmp/')) {
|
if (str_contains($file, 'storage/rsx-tmp/')) {
|
||||||
|
// Check if this is the auto-generated model classes file
|
||||||
|
if (str_contains($file, 'bundle_generated_models_')) {
|
||||||
|
// Parse simple class declarations: class Foo extends Bar {}
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
if (preg_match_all('/class\s+([A-Za-z_][A-Za-z0-9_]*)\s+extends\s+([A-Za-z_][A-Za-z0-9_]*)/', $content, $matches, PREG_SET_ORDER)) {
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$class_definitions[$match[1]] = [
|
||||||
|
'name' => $match[1],
|
||||||
|
'extends' => $match[2],
|
||||||
|
'decorators' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a JS stub file (not in PHP manifest, needs direct parsing)
|
||||||
|
// Stub files are in storage/rsx-build/js-stubs/ or storage/rsx-build/js-model-stubs/
|
||||||
|
if (str_contains($file, 'storage/rsx-build/js-stubs/') || str_contains($file, 'storage/rsx-build/js-model-stubs/')) {
|
||||||
|
$stub_content = file_get_contents($file);
|
||||||
|
$stub_metadata = $this->_extract_stub_class_info($stub_content);
|
||||||
|
|
||||||
|
if (!empty($stub_metadata['class'])) {
|
||||||
|
$class_definitions[$stub_metadata['class']] = [
|
||||||
|
'name' => $stub_metadata['class'],
|
||||||
|
'extends' => $stub_metadata['extends'],
|
||||||
|
'decorators' => null, // Stubs don't have method decorators
|
||||||
|
];
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,26 @@
|
|||||||
|
# Bundle System
|
||||||
|
|
||||||
|
## Two-Tier Bundle Architecture
|
||||||
|
|
||||||
|
**Module Bundles** (`Rsx_Module_Bundle_Abstract`)
|
||||||
|
- Top-level entry point bundles rendered on pages
|
||||||
|
- Can scan directories via `include` paths
|
||||||
|
- Auto-discovers Asset Bundles in scanned directories
|
||||||
|
- Cannot include other Module Bundles (fatal error)
|
||||||
|
- Built via `rsx:bundle:build`
|
||||||
|
|
||||||
|
**Asset Bundles** (`Rsx_Asset_Bundle_Abstract`)
|
||||||
|
- Dependency declaration bundles co-located with components
|
||||||
|
- NO directory scanning - only CDN assets, NPM modules, direct file paths
|
||||||
|
- Auto-discovered when Module Bundles scan directories containing them
|
||||||
|
- Never built standalone - metadata consumed by Module Bundles
|
||||||
|
- Can use `watch` directories for cache invalidation (e.g., Bootstrap SCSS)
|
||||||
|
|
||||||
|
**Auto-Discovery Rules:**
|
||||||
|
- Asset Bundles discovered via directory scan cannot have directory paths in `include`
|
||||||
|
- If Asset Bundle needs directory scanning, include it explicitly by class name
|
||||||
|
- Discovery uses `Manifest::php_is_subclass_of()` (NOT filename patterns)
|
||||||
|
|
||||||
# Bundle Processor System
|
# Bundle Processor System
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ class Core_Bundle extends Rsx_Bundle_Abstract
|
|||||||
__DIR__,
|
__DIR__,
|
||||||
'app/RSpade/Core/Js',
|
'app/RSpade/Core/Js',
|
||||||
'app/RSpade/Core/Data',
|
'app/RSpade/Core/Data',
|
||||||
|
'app/RSpade/Core/Database',
|
||||||
'app/RSpade/Core/SPA',
|
'app/RSpade/Core/SPA',
|
||||||
'app/RSpade/Lib',
|
'app/RSpade/Lib',
|
||||||
|
'app/RSpade/Components',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
139
app/RSpade/Core/Bundle/Rsx_Asset_Bundle_Abstract.php
Executable file
139
app/RSpade/Core/Bundle/Rsx_Asset_Bundle_Abstract.php
Executable file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\Core\Bundle;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rsx_Asset_Bundle_Abstract - Base class for dependency declaration bundles
|
||||||
|
*
|
||||||
|
* Asset bundles declare external dependencies (CDN assets, npm modules) and are
|
||||||
|
* automatically discovered when a Module Bundle scans directories containing them.
|
||||||
|
* They serve as co-located dependency manifests for components.
|
||||||
|
*
|
||||||
|
* CAPABILITIES:
|
||||||
|
* - CDN asset declarations (cdn_assets)
|
||||||
|
* - NPM module declarations (npm)
|
||||||
|
* - Watch directories for cache invalidation (watch)
|
||||||
|
* - Direct file path includes (no directory scanning)
|
||||||
|
* - Can include other Asset Bundles by class name
|
||||||
|
* - Config declarations
|
||||||
|
*
|
||||||
|
* RESTRICTIONS:
|
||||||
|
* - NO directory scanning in 'include' array
|
||||||
|
* - Never built standalone - metadata consumed by Module Bundles
|
||||||
|
* - Cannot include Module Bundles
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* class Tom_Select_Bundle extends Rsx_Asset_Bundle_Abstract {
|
||||||
|
* public static function define(): array {
|
||||||
|
* return [
|
||||||
|
* 'npm' => ['tom-select'],
|
||||||
|
* 'include' => [
|
||||||
|
* // Direct file paths only, no directories
|
||||||
|
* 'rsx/theme/components/inputs/select/tom_select_config.js',
|
||||||
|
* ],
|
||||||
|
* ];
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* For source compilation (like Bootstrap SCSS):
|
||||||
|
* class Bootstrap5_Src_Bundle extends Rsx_Asset_Bundle_Abstract {
|
||||||
|
* public static function define(): array {
|
||||||
|
* return [
|
||||||
|
* 'include' => [
|
||||||
|
* 'rsx/theme/vendor/bootstrap_custom.scss', // Direct file
|
||||||
|
* 'rsx/theme/vendor/bootstrap5/dist/js/bootstrap.bundle.js',
|
||||||
|
* ],
|
||||||
|
* 'watch' => [
|
||||||
|
* 'rsx/theme/vendor/bootstrap5/scss', // Watch for changes
|
||||||
|
* ],
|
||||||
|
* 'cdn_assets' => [
|
||||||
|
* 'css' => [['url' => 'https://cdn.jsdelivr.net/...']],
|
||||||
|
* ],
|
||||||
|
* ];
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @see Rsx_Module_Bundle_Abstract for top-level compiled bundles
|
||||||
|
*/
|
||||||
|
abstract class Rsx_Asset_Bundle_Abstract extends Rsx_Bundle_Abstract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Track whether this bundle was discovered via directory scan
|
||||||
|
* Used for validation - bundles discovered via scan have stricter rules
|
||||||
|
*/
|
||||||
|
protected static bool $_discovered_via_scan = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that this asset bundle's include array contains no directory scans
|
||||||
|
*
|
||||||
|
* Called by BundleCompiler when this bundle is discovered via directory scan.
|
||||||
|
*
|
||||||
|
* @param string $bundle_class The asset bundle class being validated
|
||||||
|
* @param string $parent_bundle_class The module bundle that discovered it
|
||||||
|
* @throws RuntimeException if include array contains directory paths
|
||||||
|
*/
|
||||||
|
public static function validate_no_directory_scanning(string $bundle_class, string $parent_bundle_class): void
|
||||||
|
{
|
||||||
|
if (!method_exists($bundle_class, 'define')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $bundle_class::define();
|
||||||
|
$include = $definition['include'] ?? [];
|
||||||
|
|
||||||
|
foreach ($include as $item) {
|
||||||
|
if (!is_string($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip CDN urls, npm: prefixes, /public/ prefixes
|
||||||
|
if (str_starts_with($item, 'http://') ||
|
||||||
|
str_starts_with($item, 'https://') ||
|
||||||
|
str_starts_with($item, 'npm:') ||
|
||||||
|
str_starts_with($item, 'cdn:') ||
|
||||||
|
str_starts_with($item, '/public/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip bundle class references (contains no path separators or dots before extension)
|
||||||
|
if (strpos($item, '/') === false && strpos($item, '.') === false) {
|
||||||
|
// Could be a bundle class name - that's allowed
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a directory path (not a file)
|
||||||
|
$resolved_path = base_path($item);
|
||||||
|
|
||||||
|
// If it resolves to a directory, that's not allowed for discovered asset bundles
|
||||||
|
if (is_dir($resolved_path)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Asset bundle discovered via directory scan cannot have directory paths in 'include'.\n\n" .
|
||||||
|
"Bundle: {$bundle_class}\n" .
|
||||||
|
"Discovered by: {$parent_bundle_class}\n" .
|
||||||
|
"Invalid include: {$item}\n\n" .
|
||||||
|
"Asset bundles discovered via directory scan can only include:\n" .
|
||||||
|
" - Direct file paths (e.g., 'rsx/lib/file.js')\n" .
|
||||||
|
" - CDN assets (cdn_assets key)\n" .
|
||||||
|
" - NPM modules (npm key)\n" .
|
||||||
|
" - Watch directories (watch key - for cache invalidation only)\n" .
|
||||||
|
" - Other Asset Bundles by class name\n\n" .
|
||||||
|
"If this bundle requires directory scanning, include it explicitly\n" .
|
||||||
|
"in the parent module bundle's include array:\n\n" .
|
||||||
|
" class {$parent_bundle_class} extends Rsx_Module_Bundle_Abstract {\n" .
|
||||||
|
" public static function define(): array {\n" .
|
||||||
|
" return [\n" .
|
||||||
|
" 'include' => [\n" .
|
||||||
|
" '{$bundle_class}', // Explicit include\n" .
|
||||||
|
" // ... other includes\n" .
|
||||||
|
" ],\n" .
|
||||||
|
" ];\n" .
|
||||||
|
" }\n" .
|
||||||
|
" }"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/RSpade/Core/Bundle/Rsx_Module_Bundle_Abstract.php
Executable file
73
app/RSpade/Core/Bundle/Rsx_Module_Bundle_Abstract.php
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\Core\Bundle;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rsx_Module_Bundle_Abstract - Base class for top-level module bundles
|
||||||
|
*
|
||||||
|
* Module bundles are the entry point bundles that get compiled and rendered
|
||||||
|
* on pages. They can scan directories and include other bundles (asset bundles
|
||||||
|
* only - not other module bundles).
|
||||||
|
*
|
||||||
|
* CAPABILITIES:
|
||||||
|
* - Directory scanning via 'include' paths
|
||||||
|
* - Explicit inclusion of Asset Bundles by class name
|
||||||
|
* - Auto-discovery of Asset Bundles in scanned directories
|
||||||
|
* - Gets built via rsx:bundle:build
|
||||||
|
*
|
||||||
|
* RESTRICTIONS:
|
||||||
|
* - Cannot include other Module Bundles (fatal error)
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* class Frontend_Bundle extends Rsx_Module_Bundle_Abstract {
|
||||||
|
* public static function define(): array {
|
||||||
|
* return [
|
||||||
|
* 'include' => [
|
||||||
|
* __DIR__, // Directory scan
|
||||||
|
* 'rsx/theme', // Directory scan (auto-discovers asset bundles)
|
||||||
|
* 'Bootstrap5_Src_Bundle', // Explicit asset bundle
|
||||||
|
* ],
|
||||||
|
* ];
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @see Rsx_Asset_Bundle_Abstract for dependency declaration bundles
|
||||||
|
*/
|
||||||
|
abstract class Rsx_Module_Bundle_Abstract extends Rsx_Bundle_Abstract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate that this bundle doesn't include other module bundles
|
||||||
|
*
|
||||||
|
* Called by BundleCompiler when resolving includes.
|
||||||
|
*
|
||||||
|
* @param string $included_bundle_class The bundle class being included
|
||||||
|
* @param string $parent_bundle_class The module bundle doing the including
|
||||||
|
* @throws RuntimeException if trying to include another module bundle
|
||||||
|
*/
|
||||||
|
public static function validate_include(string $included_bundle_class, string $parent_bundle_class): void
|
||||||
|
{
|
||||||
|
// Check if the included bundle is a module bundle
|
||||||
|
if (is_subclass_of($included_bundle_class, self::class)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Module bundle cannot include another module bundle.\n\n" .
|
||||||
|
"Parent bundle: {$parent_bundle_class}\n" .
|
||||||
|
"Attempted to include: {$included_bundle_class}\n\n" .
|
||||||
|
"Module bundles are top-level entry points and cannot be nested.\n" .
|
||||||
|
"If you need shared code between module bundles, create an Asset Bundle\n" .
|
||||||
|
"that both module bundles can include.\n\n" .
|
||||||
|
"Example:\n" .
|
||||||
|
" class Shared_Assets_Bundle extends Rsx_Asset_Bundle_Abstract {\n" .
|
||||||
|
" public static function define(): array {\n" .
|
||||||
|
" return [\n" .
|
||||||
|
" 'cdn_assets' => [...],\n" .
|
||||||
|
" 'npm' => [...],\n" .
|
||||||
|
" ];\n" .
|
||||||
|
" }\n" .
|
||||||
|
" }"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ The framework provides application-wide middleware hooks via `Main_Abstract`:
|
|||||||
|
|
||||||
These classes are ALWAYS available - never check for their existence:
|
These classes are ALWAYS available - never check for their existence:
|
||||||
- `Rsx_Manifest` - Manifest management
|
- `Rsx_Manifest` - Manifest management
|
||||||
- `Rsx_Cache` - Client-side caching
|
- `Rsx_Storage` - Browser storage (session/local) with scoping
|
||||||
- `Rsx` - Core framework utilities
|
- `Rsx` - Core framework utilities
|
||||||
- All classes in `/app/RSpade/Core/Js/`
|
- All classes in `/app/RSpade/Core/Js/`
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,4 @@ class {{ js_class }} {
|
|||||||
static on_app_ready() {
|
static on_app_ready() {
|
||||||
{{ js_class }}.init();
|
{{ js_class }}.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// static on_jqhtml_ready() {
|
|
||||||
// // Called after all JQHTML components have loaded and rendered
|
|
||||||
// // Use this if you need to interact with JQHTML components
|
|
||||||
// // Otherwise, use on_app_ready() for most initialization
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,8 @@ use App\RSpade\Core\Models\Region_Model;
|
|||||||
* Usage from widgets:
|
* Usage from widgets:
|
||||||
* let countries = await Rsx_Reference_Data_Controller.countries();
|
* let countries = await Rsx_Reference_Data_Controller.countries();
|
||||||
* let states = await Rsx_Reference_Data_Controller.states({country: 'US'});
|
* let states = await Rsx_Reference_Data_Controller.states({country: 'US'});
|
||||||
|
*
|
||||||
|
* @auth-exempt Public reference data - needed for unauthenticated forms (e.g., user signup with state selector)
|
||||||
*/
|
*/
|
||||||
class Rsx_Reference_Data_Controller extends Rsx_Controller_Abstract
|
class Rsx_Reference_Data_Controller extends Rsx_Controller_Abstract
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -110,13 +110,8 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
|||||||
$metadata = isset($manifest_data['data']['files'][$file_path]) ? $manifest_data['data']['files'][$file_path] : [];
|
$metadata = isset($manifest_data['data']['files'][$file_path]) ? $manifest_data['data']['files'][$file_path] : [];
|
||||||
|
|
||||||
// Generate stub filename and paths
|
// Generate stub filename and paths
|
||||||
$stub_filename = static::_sanitize_model_stub_filename($class_name) . '.js';
|
// Always use Base_ prefix - concrete classes are handled at bundle compilation time
|
||||||
|
$stub_class_name = 'Base_' . $class_name;
|
||||||
// Check if user has created their own JS class
|
|
||||||
$user_class_exists = static::_check_user_model_class_exists($class_name, $manifest_data);
|
|
||||||
|
|
||||||
// Use Base_ prefix if user class exists
|
|
||||||
$stub_class_name = $user_class_exists ? 'Base_' . $class_name : $class_name;
|
|
||||||
$stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js';
|
$stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js';
|
||||||
|
|
||||||
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename;
|
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename;
|
||||||
@@ -267,26 +262,6 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user has created a JavaScript model class
|
|
||||||
*/
|
|
||||||
private static function _check_user_model_class_exists(string $model_name, array $manifest_data): bool
|
|
||||||
{
|
|
||||||
// Check if there's a JS file with this class name in the manifest
|
|
||||||
foreach ($manifest_data['data']['files'] as $file_path => $metadata) {
|
|
||||||
if (isset($metadata['extension']) && $metadata['extension'] === 'js') {
|
|
||||||
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
|
|
||||||
// Don't consider our own stubs
|
|
||||||
if (!isset($metadata['is_stub']) && !isset($metadata['is_model_stub'])) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize model name for use as filename
|
* Sanitize model name for use as filename
|
||||||
*/
|
*/
|
||||||
@@ -311,8 +286,19 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
|||||||
// Get model instance to introspect
|
// Get model instance to introspect
|
||||||
$model = new $fqcn();
|
$model = new $fqcn();
|
||||||
|
|
||||||
// Get relationships
|
// Get relationships that are Ajax-fetchable
|
||||||
$relationships = $fqcn::get_relationships();
|
// Only include relationships with BOTH #[Relationship] AND #[Ajax_Endpoint_Model_Fetch]
|
||||||
|
$all_relationships = $fqcn::get_relationships();
|
||||||
|
$model_metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_fqcn($fqcn);
|
||||||
|
$fetchable_relationships = [];
|
||||||
|
|
||||||
|
foreach ($all_relationships as $rel_name) {
|
||||||
|
$method_data = $model_metadata['public_instance_methods'][$rel_name] ?? [];
|
||||||
|
if (isset($method_data['attributes']['Ajax_Endpoint_Model_Fetch'])) {
|
||||||
|
$fetchable_relationships[] = $rel_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$relationships = $fetchable_relationships;
|
||||||
|
|
||||||
// Get enums
|
// Get enums
|
||||||
$enums = $fqcn::$enums ?? [];
|
$enums = $fqcn::$enums ?? [];
|
||||||
@@ -323,18 +309,22 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
|||||||
$columns = $manifest_data['data']['models'][$class_name]['columns'];
|
$columns = $manifest_data['data']['models'][$class_name]['columns'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine the base class to extend
|
||||||
|
// User can configure a custom base class that sits between stubs and Rsx_Js_Model
|
||||||
|
$js_model_base_class = config('rsx.js_model_base_class');
|
||||||
|
$extends_class = $js_model_base_class ?: 'Rsx_Js_Model';
|
||||||
|
|
||||||
// Start building the stub content
|
// Start building the stub content
|
||||||
$content = "/**\n";
|
$content = "/**\n";
|
||||||
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
|
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
|
||||||
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
|
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
|
||||||
$content .= " */\n\n";
|
$content .= " * @Instantiatable\n";
|
||||||
|
$content .= " */\n";
|
||||||
|
|
||||||
$content .= "class {$stub_class_name} extends Rsx_Js_Model {\n";
|
$content .= "class {$stub_class_name} extends {$extends_class} {\n";
|
||||||
|
|
||||||
// Add static model name for API calls
|
// Add static __MODEL property for PHP model name resolution
|
||||||
$content .= " static get name() {\n";
|
$content .= " static __MODEL = '{$class_name}';\n\n";
|
||||||
$content .= " return '{$class_name}';\n";
|
|
||||||
$content .= " }\n\n";
|
|
||||||
|
|
||||||
// Generate enum constants and methods
|
// Generate enum constants and methods
|
||||||
foreach ($enums as $column => $enum_values) {
|
foreach ($enums as $column => $enum_values) {
|
||||||
@@ -440,25 +430,31 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
|||||||
$content .= " }\n\n";
|
$content .= " }\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate static get_relationships() method
|
||||||
|
$relationships_json = json_encode(array_values($relationships));
|
||||||
|
$content .= " /**\n";
|
||||||
|
$content .= " * Get list of relationship names available on this model\n";
|
||||||
|
$content .= " * @returns {Array} Array of relationship method names\n";
|
||||||
|
$content .= " */\n";
|
||||||
|
$content .= " static get_relationships() {\n";
|
||||||
|
$content .= " return {$relationships_json};\n";
|
||||||
|
$content .= " }\n\n";
|
||||||
|
|
||||||
// Generate relationship methods
|
// Generate relationship methods
|
||||||
foreach ($relationships as $relationship) {
|
foreach ($relationships as $relationship) {
|
||||||
$content .= " /**\n";
|
$content .= " /**\n";
|
||||||
$content .= " * Fetch {$relationship} relationship\n";
|
$content .= " * Fetch {$relationship} relationship\n";
|
||||||
$content .= " * @returns {Promise} Related model instance(s) or false\n";
|
$content .= " * @returns {Promise} Related model instance(s), null, or empty array\n";
|
||||||
$content .= " */\n";
|
$content .= " */\n";
|
||||||
$content .= " async {$relationship}() {\n";
|
$content .= " async {$relationship}() {\n";
|
||||||
$content .= " if (!this.id) {\n";
|
$content .= " if (!this.id) {\n";
|
||||||
$content .= " shouldnt_happen('Cannot fetch relationship without id property');\n";
|
$content .= " shouldnt_happen('Cannot fetch relationship without id property');\n";
|
||||||
$content .= " }\n\n";
|
$content .= " }\n";
|
||||||
$content .= " const response = await $.ajax({\n";
|
$content .= " return await Orm_Controller.fetch_relationship({\n";
|
||||||
$content .= " url: `/_fetch_rel/{$class_name}/\${this.id}/{$relationship}`,\n";
|
$content .= " model: '{$class_name}',\n";
|
||||||
$content .= " method: 'POST',\n";
|
$content .= " id: this.id,\n";
|
||||||
$content .= " dataType: 'json'\n";
|
$content .= " relationship: '{$relationship}'\n";
|
||||||
$content .= " });\n\n";
|
$content .= " });\n";
|
||||||
$content .= " if (!response) return false;\n\n";
|
|
||||||
$content .= " // Convert response to model instance(s)\n";
|
|
||||||
$content .= " // Framework handles instantiation based on relationship type\n";
|
|
||||||
$content .= " return response;\n";
|
|
||||||
$content .= " }\n\n";
|
$content .= " }\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,43 @@ abstract class Rsx_Model_Abstract extends Model
|
|||||||
return parent::__get($key);
|
return parent::__get($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magic isset for enum properties
|
||||||
|
*
|
||||||
|
* Required because PHP's ?? operator calls __isset() before __get().
|
||||||
|
* Without this, Eloquent's __isset() returns false for enum properties,
|
||||||
|
* causing ?? to use the default value without ever calling __get().
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function __isset($key)
|
||||||
|
{
|
||||||
|
// Check for enum magic properties
|
||||||
|
if (!empty(static::$enums)) {
|
||||||
|
foreach (static::$enums as $column => $enum_config) {
|
||||||
|
// field_enum_val
|
||||||
|
if ($key == $column . '_enum_val') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// field_label, field_constant, field_* (any custom enum property)
|
||||||
|
foreach ($enum_config as $enum_val => $enum_properties) {
|
||||||
|
foreach ($enum_properties as $prop_name => $prop_value) {
|
||||||
|
if ($key == $column . '_' . $prop_name) {
|
||||||
|
// Property exists if current column value matches this enum value
|
||||||
|
if ($this->$column == $enum_val) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::__isset($key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Magic static method handler for enum methods
|
* Magic static method handler for enum methods
|
||||||
*
|
*
|
||||||
|
|||||||
341
app/RSpade/Core/Database/Orm_Controller.php
Executable file
341
app/RSpade/Core/Database/Orm_Controller.php
Executable file
@@ -0,0 +1,341 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\Core\Database;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
||||||
|
use App\RSpade\Core\Manifest\Manifest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ORM Controller for JavaScript model fetch operations
|
||||||
|
*
|
||||||
|
* Provides Ajax endpoints for the JavaScript ORM (Rsx_Js_Model) to fetch
|
||||||
|
* model records. Uses the standard #[Ajax_Endpoint] pattern to ensure
|
||||||
|
* proper request/response wrapping.
|
||||||
|
*
|
||||||
|
* SECURITY NOTICE: This controller is a major data egress point in the system.
|
||||||
|
* It will be a target for vulnerability scanning and enumeration attacks.
|
||||||
|
* All methods must be written defensively to prevent information disclosure.
|
||||||
|
*
|
||||||
|
* @auth-exempt Auth is handled per-model via the fetch() method which must have #[Ajax_Endpoint_Model_Fetch]
|
||||||
|
*/
|
||||||
|
class Orm_Controller extends Rsx_Controller_Abstract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fetch model record by ID
|
||||||
|
*
|
||||||
|
* Called by Rsx_Js_Model.fetch() in JavaScript.
|
||||||
|
* Model must have #[Ajax_Endpoint_Model_Fetch] attribute on its fetch() method.
|
||||||
|
*
|
||||||
|
* SECURITY: This endpoint is a primary target for enumeration attacks.
|
||||||
|
* Error responses MUST be identical regardless of failure reason to prevent
|
||||||
|
* attackers from discovering valid class names or system architecture:
|
||||||
|
* - Class doesn't exist → "Model not found"
|
||||||
|
* - Class exists but isn't a model → "Model not found"
|
||||||
|
* - Model exists but missing attribute → "Model not found"
|
||||||
|
* All failures return the same generic error to prevent information leakage.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @param array $params Expected: model (string), id (int)
|
||||||
|
* @return mixed Model data or false
|
||||||
|
*/
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function fetch(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
$model_name = $params['model'] ?? null;
|
||||||
|
$id = $params['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$model_name) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'Model name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id === null) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'ID parameter is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the PHP model class from manifest
|
||||||
|
try {
|
||||||
|
$model_metadata = Manifest::php_get_metadata_by_class($model_name);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model {$model_name} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
$model_class = $model_metadata['fqcn'] ?? null;
|
||||||
|
if (!$model_class) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model {$model_name} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a subclass of Rsx_Model_Abstract (security check)
|
||||||
|
// SECURITY: Use same error message as "not found" to prevent enumeration
|
||||||
|
if (!Manifest::php_is_subclass_of($model_name, 'Rsx_Model_Abstract')) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model {$model_name} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute
|
||||||
|
// SECURITY EXCEPTION: This error reveals the model exists but lacks the attribute.
|
||||||
|
// This is acceptable because: (1) attacker already knows model name from client code,
|
||||||
|
// (2) detailed message is essential for developers diagnosing configuration issues,
|
||||||
|
// (3) knowing a model lacks fetch() doesn't expose exploitable information.
|
||||||
|
$has_fetch_attribute = false;
|
||||||
|
if (isset($model_metadata['public_static_methods']['fetch'])) {
|
||||||
|
$fetch_method = $model_metadata['public_static_methods']['fetch'];
|
||||||
|
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
|
||||||
|
$has_fetch_attribute = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$has_fetch_attribute) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED, "Model {$model_name} fetch() method missing Ajax_Endpoint_Model_Fetch attribute");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $model_class::fetch($id);
|
||||||
|
|
||||||
|
// Handle not found / access denied (false or null from fetch())
|
||||||
|
if ($result === false || $result === null) {
|
||||||
|
// Check if caller wants null instead of exception
|
||||||
|
$or_null = $params['or_null'] ?? false;
|
||||||
|
if ($or_null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate array returns include __MODEL for JavaScript hydration
|
||||||
|
if (is_array($result) && !isset($result['__MODEL'])) {
|
||||||
|
throw new \App\RSpade\Core\Exception\Rsx_Exception(
|
||||||
|
"Model {$model_name}::fetch() returned an array without __MODEL property. " .
|
||||||
|
"JavaScript ORM requires __MODEL for class hydration. " .
|
||||||
|
"Fix: Call \$model->toArray() on the Eloquent model first, then augment the array before returning. " .
|
||||||
|
"Example: \$data = \$model->toArray(); \$data['computed_field'] = 'value'; return \$data;"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch related records via a model's relationship method
|
||||||
|
*
|
||||||
|
* Called by JavaScript relationship methods in Base_*_Model stubs.
|
||||||
|
* Validates source model is fetchable, then calls the relationship,
|
||||||
|
* and passes each related record through its own fetch() for security.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Validate source model has fetch attribute
|
||||||
|
* 2. Call source model's fetch() to verify access to parent record
|
||||||
|
* 3. Call relationship method to get related IDs
|
||||||
|
* 4. For each related record, call its model's fetch() for security
|
||||||
|
* 5. Return hydrated results (single object or array)
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @param array $params Expected: model (string), id (int), relationship (string)
|
||||||
|
* @return mixed Related model data or false
|
||||||
|
*/
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function fetch_relationship(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
$model_name = $params['model'] ?? null;
|
||||||
|
$id = $params['id'] ?? null;
|
||||||
|
$relationship_name = $params['relationship'] ?? null;
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!$model_name) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'Model name is required');
|
||||||
|
}
|
||||||
|
if ($id === null) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'ID parameter is required');
|
||||||
|
}
|
||||||
|
if (!$relationship_name) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'Relationship name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the PHP model class from manifest
|
||||||
|
try {
|
||||||
|
$model_metadata = Manifest::php_get_metadata_by_class($model_name);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
$model_class = $model_metadata['fqcn'] ?? null;
|
||||||
|
if (!$model_class) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a subclass of Rsx_Model_Abstract
|
||||||
|
if (!Manifest::php_is_subclass_of($model_name, 'Rsx_Model_Abstract')) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute
|
||||||
|
$has_fetch_attribute = false;
|
||||||
|
if (isset($model_metadata['public_static_methods']['fetch'])) {
|
||||||
|
$fetch_method = $model_metadata['public_static_methods']['fetch'];
|
||||||
|
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
|
||||||
|
$has_fetch_attribute = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$has_fetch_attribute) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED, "Model fetch() not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the relationship exists and is fetchable
|
||||||
|
// Check if method has #[Relationship] attribute
|
||||||
|
$has_relationship_attr = false;
|
||||||
|
$has_fetch_attr_on_rel = false;
|
||||||
|
|
||||||
|
if (isset($model_metadata['public_instance_methods'][$relationship_name])) {
|
||||||
|
$rel_method = $model_metadata['public_instance_methods'][$relationship_name];
|
||||||
|
$has_relationship_attr = isset($rel_method['attributes']['Relationship']);
|
||||||
|
$has_fetch_attr_on_rel = isset($rel_method['attributes']['Ajax_Endpoint_Model_Fetch']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$has_relationship_attr) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "No such relationship: {$relationship_name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$has_fetch_attr_on_rel) {
|
||||||
|
return response_error(
|
||||||
|
\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED,
|
||||||
|
"Relationship '{$relationship_name}' does not have the #[Ajax_Endpoint_Model_Fetch] attribute. " .
|
||||||
|
"Add this attribute to the relationship method before it can be fetched from JavaScript."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the source record using its fetch() method (security check)
|
||||||
|
// This validates that the current user has access to this record.
|
||||||
|
$fetch_result = $model_class::fetch($id);
|
||||||
|
if (!$fetch_result) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found or access denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch() may return an Eloquent model OR an augmented array.
|
||||||
|
// If it's an array, we need to verify access was granted and re-fetch
|
||||||
|
// the actual Eloquent model to call relationship methods on it.
|
||||||
|
//
|
||||||
|
// INEFFICIENCY NOTE: When fetch() returns an array, we're hitting the
|
||||||
|
// database twice - once for fetch() access check, once for the model.
|
||||||
|
// TODO: Implement Laravel query caching so the second fetch() call
|
||||||
|
// returns the cached model from the first query, eliminating the
|
||||||
|
// duplicate database hit.
|
||||||
|
if (is_array($fetch_result)) {
|
||||||
|
// Verify the array contains a valid id matching our request
|
||||||
|
// This confirms fetch() actually returned data for this record
|
||||||
|
// (not, for example, false cast to array or invalid data)
|
||||||
|
if (!isset($fetch_result['id']) || $fetch_result['id'] != $id) {
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found or access denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access granted - now fetch the actual Eloquent model for relationships
|
||||||
|
$source_record = $model_class::find($id);
|
||||||
|
if (!$source_record) {
|
||||||
|
// Shouldn't happen if fetch() succeeded, but defensive check
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// fetch() returned an Eloquent model directly - use it
|
||||||
|
$source_record = $fetch_result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the relationship method
|
||||||
|
$relation = $source_record->$relationship_name();
|
||||||
|
|
||||||
|
// Determine if this is a singular or plural relationship
|
||||||
|
$is_singular = $relation instanceof \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
|| $relation instanceof \Illuminate\Database\Eloquent\Relations\HasOne
|
||||||
|
|| $relation instanceof \Illuminate\Database\Eloquent\Relations\MorphOne
|
||||||
|
|| $relation instanceof \Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
// Get just the IDs first (efficient single query)
|
||||||
|
if ($is_singular) {
|
||||||
|
// For singular relationships, get the foreign key value directly
|
||||||
|
if ($relation instanceof \Illuminate\Database\Eloquent\Relations\MorphTo) {
|
||||||
|
$related_id = $source_record->{$relation->getForeignKeyName()};
|
||||||
|
$related_type = $source_record->{$relation->getMorphType()};
|
||||||
|
if (!$related_id || !$related_type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// For morphTo, we need to resolve the related model class
|
||||||
|
$related_class = $related_type;
|
||||||
|
} else {
|
||||||
|
$related_id = $relation->getQuery()->getQuery()->first()?->id ?? null;
|
||||||
|
if (!$related_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$related_class = get_class($relation->getRelated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get simple class name for fetch
|
||||||
|
$related_class_parts = explode('\\', $related_class);
|
||||||
|
$related_model_name = end($related_class_parts);
|
||||||
|
|
||||||
|
// Check if related model has fetch() with attribute
|
||||||
|
try {
|
||||||
|
$related_metadata = Manifest::php_get_metadata_by_class($related_model_name);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$related_has_fetch = false;
|
||||||
|
if (isset($related_metadata['public_static_methods']['fetch'])) {
|
||||||
|
$fetch_method = $related_metadata['public_static_methods']['fetch'];
|
||||||
|
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
|
||||||
|
$related_has_fetch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$related_has_fetch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch through the security filter
|
||||||
|
$related_fqcn = $related_metadata['fqcn'];
|
||||||
|
return $related_fqcn::fetch($related_id);
|
||||||
|
} else {
|
||||||
|
// For plural relationships, get all IDs efficiently
|
||||||
|
$related_ids = $relation->pluck('id')->toArray();
|
||||||
|
|
||||||
|
if (empty($related_ids)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the related model class
|
||||||
|
$related_class = get_class($relation->getRelated());
|
||||||
|
$related_class_parts = explode('\\', $related_class);
|
||||||
|
$related_model_name = end($related_class_parts);
|
||||||
|
|
||||||
|
// Check if related model has fetch() with attribute
|
||||||
|
try {
|
||||||
|
$related_metadata = Manifest::php_get_metadata_by_class($related_model_name);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$related_has_fetch = false;
|
||||||
|
if (isset($related_metadata['public_static_methods']['fetch'])) {
|
||||||
|
$fetch_method = $related_metadata['public_static_methods']['fetch'];
|
||||||
|
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
|
||||||
|
$related_has_fetch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$related_has_fetch) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch each record through its security filter
|
||||||
|
// TODO: Optimize with batch prefetch cache (see model_fetch.txt TODO)
|
||||||
|
$related_fqcn = $related_metadata['fqcn'];
|
||||||
|
$results = [];
|
||||||
|
foreach ($related_ids as $related_id) {
|
||||||
|
$record = $related_fqcn::fetch($related_id);
|
||||||
|
if ($record) {
|
||||||
|
$results[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ use App\RSpade\Core\Debug\Debugger;
|
|||||||
*
|
*
|
||||||
* Handles AJAX requests from JavaScript for console_debug and error logging.
|
* Handles AJAX requests from JavaScript for console_debug and error logging.
|
||||||
* Delegates to the Debugger utility class for actual implementation.
|
* Delegates to the Debugger utility class for actual implementation.
|
||||||
|
*
|
||||||
|
* @auth-exempt Dev tool for logging client-side debug info - must work for unauthenticated states (e.g., login page debugging). Controlled by config flags.
|
||||||
*/
|
*/
|
||||||
class Debugger_Controller extends Rsx_Controller_Abstract
|
class Debugger_Controller extends Rsx_Controller_Abstract
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
|||||||
*
|
*
|
||||||
* Thin wrapper that routes /_ajax/:controller/:action requests to the Ajax class.
|
* Thin wrapper that routes /_ajax/:controller/:action requests to the Ajax class.
|
||||||
* The actual logic is consolidated in Ajax::handle_browser_request() for better organization.
|
* The actual logic is consolidated in Ajax::handle_browser_request() for better organization.
|
||||||
|
*
|
||||||
|
* @auth-exempt Routes requests to #[Ajax_Endpoint] methods - each endpoint has its own auth checks
|
||||||
*/
|
*/
|
||||||
class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
|
class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
|
||||||
{
|
{
|
||||||
@@ -25,7 +27,6 @@ class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
|
|||||||
*
|
*
|
||||||
* Routes /_ajax/:controller/:action to Ajax::handle_browser_request()
|
* Routes /_ajax/:controller/:action to Ajax::handle_browser_request()
|
||||||
*/
|
*/
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
#[Route('/_ajax/:controller/:action', methods: ['POST'])]
|
#[Route('/_ajax/:controller/:action', methods: ['POST'])]
|
||||||
public static function dispatch(Request $request, array $params = [])
|
public static function dispatch(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
@@ -51,223 +52,4 @@ class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
|
|||||||
return Ajax::is_ajax_response_mode();
|
return Ajax::is_ajax_response_mode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle model fetch requests from JavaScript ORM
|
|
||||||
*
|
|
||||||
* Routes /_fetch/:model to the model's fetch() method
|
|
||||||
* Model must have #[Ajax_Endpoint_Model_Fetch] annotation on its fetch() method
|
|
||||||
*/
|
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
#[Route('/_fetch/:model', methods: ['POST'])]
|
|
||||||
public static function fetch_model(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
$model_name = $params['model'] ?? null;
|
|
||||||
|
|
||||||
if (!$model_name) {
|
|
||||||
return response()->json(['error' => 'Model name is required'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the model class from manifest
|
|
||||||
try {
|
|
||||||
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
|
|
||||||
$model_class = null;
|
|
||||||
|
|
||||||
// Find the model class in manifest
|
|
||||||
foreach ($manifest as $file_path => $metadata) {
|
|
||||||
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
|
|
||||||
$model_class = $metadata['fqcn'] ?? null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$model_class) {
|
|
||||||
return response()->json(['error' => "Model {$model_name} not found"], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if model extends Rsx_Model_Abstract using manifest
|
|
||||||
$extends = $model_metadata['extends'] ?? null;
|
|
||||||
$is_rsx_model = false;
|
|
||||||
|
|
||||||
// Check direct extension
|
|
||||||
if ($extends === 'Rsx_Model_Abstract') {
|
|
||||||
$is_rsx_model = true;
|
|
||||||
} else {
|
|
||||||
// Check indirect extension via manifest
|
|
||||||
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
|
|
||||||
foreach ($extending_models as $extending_model) {
|
|
||||||
if ($extending_model['class'] === $model_name) {
|
|
||||||
$is_rsx_model = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$is_rsx_model) {
|
|
||||||
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute using manifest
|
|
||||||
$model_metadata = null;
|
|
||||||
foreach ($manifest as $file_path => $metadata) {
|
|
||||||
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
|
|
||||||
$model_metadata = $metadata;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$model_metadata) {
|
|
||||||
return response()->json(['error' => "Model {$model_name} metadata not found in manifest"], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if fetch method exists and has the attribute
|
|
||||||
$has_fetch_attribute = false;
|
|
||||||
if (isset($model_metadata['public_static_methods']['fetch'])) {
|
|
||||||
$fetch_method = $model_metadata['public_static_methods']['fetch'];
|
|
||||||
if (isset($fetch_method['attributes'])) {
|
|
||||||
// Check if Ajax_Endpoint_Model_Fetch attribute exists
|
|
||||||
// Attributes are stored as associative array with attribute name as key
|
|
||||||
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
|
|
||||||
$has_fetch_attribute = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$has_fetch_attribute) {
|
|
||||||
return response()->json(['error' => "Model {$model_name} fetch() method missing Ajax_Endpoint_Model_Fetch attribute"], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the ID parameter
|
|
||||||
$id = $request->input('id');
|
|
||||||
|
|
||||||
if ($id === null) {
|
|
||||||
return response()->json(['error' => 'ID parameter is required'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle arrays by calling fetch() for each ID
|
|
||||||
if (is_array($id)) {
|
|
||||||
$results = [];
|
|
||||||
foreach ($id as $single_id) {
|
|
||||||
$model = $model_class::fetch($single_id);
|
|
||||||
if ($model !== false) {
|
|
||||||
// Convert to array if it's an object
|
|
||||||
if (is_object($model) && method_exists($model, 'toArray')) {
|
|
||||||
$results[] = $model->toArray();
|
|
||||||
} else {
|
|
||||||
$results[] = $model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($results);
|
|
||||||
}
|
|
||||||
// Single ID - call fetch() directly
|
|
||||||
$result = $model_class::fetch($id);
|
|
||||||
|
|
||||||
if ($result === false) {
|
|
||||||
return response()->json(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to array for JSON response
|
|
||||||
if (is_object($result) && method_exists($result, 'toArray')) {
|
|
||||||
return response()->json($result->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return as-is if not an object with toArray
|
|
||||||
return response()->json($result);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return response()->json(['error' => $e->getMessage()], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle model relationship fetch requests
|
|
||||||
*
|
|
||||||
* Routes /_fetch_rel/:model/:id/:relationship to fetch relationship data
|
|
||||||
* Model must have the relationship defined
|
|
||||||
*/
|
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
#[Route('/_fetch_rel/:model/:id/:relationship', methods: ['POST'])]
|
|
||||||
public static function fetch_relationship(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
$model_name = $params['model'] ?? null;
|
|
||||||
$id = $params['id'] ?? null;
|
|
||||||
$relationship = $params['relationship'] ?? null;
|
|
||||||
|
|
||||||
if (!$model_name || !$id || !$relationship) {
|
|
||||||
return response()->json(['error' => 'Model name, ID, and relationship are required'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Look up the model class from manifest
|
|
||||||
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
|
|
||||||
$model_class = null;
|
|
||||||
|
|
||||||
foreach ($manifest as $file_path => $metadata) {
|
|
||||||
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
|
|
||||||
$model_class = $metadata['fqcn'] ?? null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$model_class) {
|
|
||||||
return response()->json(['error' => "Model {$model_name} not found"], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if model extends Rsx_Model_Abstract using manifest
|
|
||||||
$extends = $model_metadata['extends'] ?? null;
|
|
||||||
$is_rsx_model = false;
|
|
||||||
|
|
||||||
// Check direct extension
|
|
||||||
if ($extends === 'Rsx_Model_Abstract') {
|
|
||||||
$is_rsx_model = true;
|
|
||||||
} else {
|
|
||||||
// Check indirect extension via manifest
|
|
||||||
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
|
|
||||||
foreach ($extending_models as $extending_model) {
|
|
||||||
if ($extending_model['class'] === $model_name) {
|
|
||||||
$is_rsx_model = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$is_rsx_model) {
|
|
||||||
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the base model
|
|
||||||
$model = $model_class::find($id);
|
|
||||||
|
|
||||||
if (!$model) {
|
|
||||||
return response()->json(['error' => "Record with ID {$id} not found"], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if relationship exists using get_relationships()
|
|
||||||
$relationships = $model_class::get_relationships();
|
|
||||||
if (!in_array($relationship, $relationships)) {
|
|
||||||
return response()->json(['error' => "Relationship {$relationship} not found on model {$model_name}"], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the relationship method directly and get the results
|
|
||||||
if (!method_exists($model, $relationship)) {
|
|
||||||
return response()->json(['error' => "Relationship method {$relationship} does not exist on model {$model_name}"], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the relationship and return the data
|
|
||||||
$result = $model->$relationship;
|
|
||||||
|
|
||||||
// Handle different result types
|
|
||||||
if ($result instanceof \Illuminate\Database\Eloquent\Collection) {
|
|
||||||
return response()->json($result->map->toArray());
|
|
||||||
} elseif ($result instanceof \Illuminate\Database\Eloquent\Model) {
|
|
||||||
return response()->json($result->toArray());
|
|
||||||
} elseif ($result === null) {
|
|
||||||
return response()->json(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['error' => "Unable to load relationship {$relationship}"], 500);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return response()->json(['error' => $e->getMessage()], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,12 +273,6 @@ class Dispatcher
|
|||||||
$handler_method = $route_match['method'];
|
$handler_method = $route_match['method'];
|
||||||
$params = $route_match['params'] ?? [];
|
$params = $route_match['params'] ?? [];
|
||||||
|
|
||||||
// Check for SSR Full Page Cache (FPC) before any processing
|
|
||||||
$fpc_response = static::__check_ssr_fpc($url, $handler_class, $handler_method, $request);
|
|
||||||
if ($fpc_response !== null) {
|
|
||||||
return static::__transform_response($fpc_response, $original_method);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge parameters with correct priority order:
|
// Merge parameters with correct priority order:
|
||||||
// 1. Extra parameters (usually empty, lowest priority)
|
// 1. Extra parameters (usually empty, lowest priority)
|
||||||
// 2. GET parameters (from query string)
|
// 2. GET parameters (from query string)
|
||||||
@@ -301,43 +295,9 @@ class Dispatcher
|
|||||||
// Load and validate handler class
|
// Load and validate handler class
|
||||||
static::__load_handler_class($handler_class);
|
static::__load_handler_class($handler_class);
|
||||||
|
|
||||||
// Check controller pre_dispatch Auth attributes
|
// Permission checks are now handled manually in controller pre_dispatch() methods
|
||||||
$pre_dispatch_requires = Manifest::get_pre_dispatch_requires($handler_class);
|
// and within individual route methods. See: php artisan rsx:man auth
|
||||||
foreach ($pre_dispatch_requires as $require_attr) {
|
// A code quality rule (rsx:check) verifies auth checks exist.
|
||||||
$result = static::__check_require($require_attr, $request, $params, $handler_class, 'pre_dispatch');
|
|
||||||
if ($result !== null) {
|
|
||||||
$response = static::__build_response($result);
|
|
||||||
return static::__transform_response($response, $original_method);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check route method Auth attributes
|
|
||||||
$route_requires = $route_match['require'] ?? [];
|
|
||||||
|
|
||||||
// Validate that at least one Auth exists (either on route or pre_dispatch)
|
|
||||||
if (empty($route_requires) && empty($pre_dispatch_requires)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Route method '{$handler_class}::{$handler_method}' is missing required #[\\Auth] attribute.\n\n" .
|
|
||||||
"All routes must specify access control using #[\\Auth('Permission::method()')].\n\n" .
|
|
||||||
"Examples:\n" .
|
|
||||||
" #[\\Auth('Permission::anybody()')] // Public access\n" .
|
|
||||||
" #[\\Auth('Permission::authenticated()')] // Must be logged in\n" .
|
|
||||||
" #[\\Auth('Permission::has_role(\"admin\")')] // Custom check with args\n\n" .
|
|
||||||
"Alternatively, add #[\\Auth] to pre_dispatch() to apply to all routes in this controller.\n\n" .
|
|
||||||
"To create a permission method, add to rsx/permission.php:\n" .
|
|
||||||
" public static function custom_check(Request \$request, array \$params): mixed {\n" .
|
|
||||||
" return RsxAuth::check(); // true = allow, false = deny\n" .
|
|
||||||
" }"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($route_requires as $require_attr) {
|
|
||||||
$result = static::__check_require($require_attr, $request, $params, $handler_class, $handler_method);
|
|
||||||
if ($result !== null) {
|
|
||||||
$response = static::__build_response($result);
|
|
||||||
return static::__transform_response($response, $original_method);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call pre_dispatch hooks
|
// Call pre_dispatch hooks
|
||||||
$pre_dispatch_result = static::__call_pre_dispatch($handler_class, $handler_method, $params, $request);
|
$pre_dispatch_result = static::__call_pre_dispatch($handler_class, $handler_method, $params, $request);
|
||||||
@@ -1034,422 +994,4 @@ class Dispatcher
|
|||||||
throw new RuntimeException($error_msg);
|
throw new RuntimeException($error_msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check a Auth attribute and execute the permission check
|
|
||||||
*
|
|
||||||
* @param array $require_attr The Auth attribute arguments
|
|
||||||
* @param Request $request
|
|
||||||
* @param array $params
|
|
||||||
* @param string $handler_class For error messages
|
|
||||||
* @param string $handler_method For error messages
|
|
||||||
* @return mixed|null Returns response to halt dispatch, or null to continue
|
|
||||||
* @throws RuntimeException on parsing or execution errors
|
|
||||||
*/
|
|
||||||
protected static function __check_require(array $require_attr, Request $request, array $params, string $handler_class, string $handler_method)
|
|
||||||
{
|
|
||||||
// Extract parameters - first arg is callable string, rest are named params
|
|
||||||
$callable_str = $require_attr[0] ?? null;
|
|
||||||
$message = $require_attr['message'] ?? null;
|
|
||||||
$redirect = $require_attr['redirect'] ?? null;
|
|
||||||
$redirect_to = $require_attr['redirect_to'] ?? null;
|
|
||||||
|
|
||||||
if (!$callable_str) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Auth attribute on {$handler_class}::{$handler_method} is missing callable string.\n" .
|
|
||||||
"Expected: #[Auth('Permission::method()')]\n" .
|
|
||||||
"Got: " . json_encode($require_attr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($redirect && $redirect_to) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Auth attribute on {$handler_class}::{$handler_method} cannot specify both 'redirect' and 'redirect_to'.\n" .
|
|
||||||
"Use either redirect: '/path' OR redirect_to: ['Controller', 'action']"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse callable string - FATAL if parsing fails
|
|
||||||
[$class, $method, $args] = static::__parse_require_callable($callable_str, $handler_class, $handler_method);
|
|
||||||
|
|
||||||
// Verify permission class and method exist
|
|
||||||
if (!class_exists($class)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Permission class '{$class}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
|
|
||||||
"Make sure the class exists and is loaded by the manifest."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!method_exists($class, $method)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Permission method '{$class}::{$method}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
|
|
||||||
"Add this method to {$class}:\n" .
|
|
||||||
" public static function {$method}(Request \$request, array \$params): mixed {\n" .
|
|
||||||
" return true; // or false to deny\n" .
|
|
||||||
" }"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call permission method
|
|
||||||
try {
|
|
||||||
$result = $class::$method($request, $params, ...$args);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Permission check '{$class}::{$method}' threw exception for {$handler_class}::{$handler_method}:\n" .
|
|
||||||
$e->getMessage(),
|
|
||||||
0,
|
|
||||||
$e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle result
|
|
||||||
if ($result === true || $result === null) {
|
|
||||||
return null; // Pass - continue to next check or route
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result instanceof \Symfony\Component\HttpFoundation\Response) {
|
|
||||||
return $result; // Custom response from permission method
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission failed - detect if Ajax context
|
|
||||||
$is_ajax = ($handler_class === 'App\\RSpade\\Core\\Dispatch\\Ajax_Endpoint_Controller') ||
|
|
||||||
str_starts_with($request->path(), '_ajax/') ||
|
|
||||||
str_starts_with($request->path(), '_fetch/');
|
|
||||||
|
|
||||||
if ($is_ajax) {
|
|
||||||
// Ajax context - return JSON error, ignore redirect parameters
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'error' => $message ?? 'Permission denied',
|
|
||||||
'error_type' => 'permission_denied'
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular HTTP context - handle redirect/message
|
|
||||||
if ($redirect_to) {
|
|
||||||
if (!is_array($redirect_to) || count($redirect_to) < 1) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$action = $redirect_to[0];
|
|
||||||
if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') {
|
|
||||||
$action .= '::' . $redirect_to[1];
|
|
||||||
}
|
|
||||||
$url = Rsx::Route($action);
|
|
||||||
if ($message) {
|
|
||||||
Rsx::flash_error($message);
|
|
||||||
}
|
|
||||||
return redirect($url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($redirect) {
|
|
||||||
if ($message) {
|
|
||||||
Rsx::flash_error($message);
|
|
||||||
}
|
|
||||||
return redirect($redirect);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: throw 403 Forbidden
|
|
||||||
throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, $message ?? 'Forbidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Auth callable string into class, method, and args
|
|
||||||
*
|
|
||||||
* Supports formats:
|
|
||||||
* - Permission::method()
|
|
||||||
* - Permission::method(3)
|
|
||||||
* - Permission::method("string")
|
|
||||||
* - Permission::method(1, "arg2", 3)
|
|
||||||
*
|
|
||||||
* FATAL on parse failure
|
|
||||||
*
|
|
||||||
* @param string $str Callable string
|
|
||||||
* @param string $handler_class For error messages
|
|
||||||
* @param string $handler_method For error messages
|
|
||||||
* @return array [class, method, args]
|
|
||||||
* @throws RuntimeException on parse failure
|
|
||||||
*/
|
|
||||||
protected static function __parse_require_callable(string $str, string $handler_class, string $handler_method): array
|
|
||||||
{
|
|
||||||
// Pattern: ClassName::method_name(args)
|
|
||||||
if (!preg_match('/^([A-Za-z_][A-Za-z0-9_\\\\]*)::([a-z_][a-z0-9_]*)\((.*)\)$/i', $str, $matches)) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Failed to parse Auth callable on {$handler_class}::{$handler_method}.\n\n" .
|
|
||||||
"Invalid format: '{$str}'\n\n" .
|
|
||||||
"Expected format: 'ClassName::method_name()' or 'ClassName::method_name(arg1, arg2)'\n\n" .
|
|
||||||
"Examples:\n" .
|
|
||||||
" Permission::anybody()\n" .
|
|
||||||
" Permission::authenticated()\n" .
|
|
||||||
" Permission::has_role(\"admin\")\n" .
|
|
||||||
" Permission::has_permission(3, \"write\")"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$class = $matches[1];
|
|
||||||
$method = $matches[2];
|
|
||||||
$args_str = trim($matches[3]);
|
|
||||||
|
|
||||||
// Resolve class name if not fully qualified
|
|
||||||
if (strpos($class, '\\') === false) {
|
|
||||||
// Try to find the class using manifest discovery
|
|
||||||
try {
|
|
||||||
$metadata = Manifest::php_get_metadata_by_class($class);
|
|
||||||
if (isset($metadata['fqcn'])) {
|
|
||||||
$class = $metadata['fqcn'];
|
|
||||||
}
|
|
||||||
} catch (\RuntimeException $e) {
|
|
||||||
// Class not found in manifest - leave as-is and let class_exists check fail later with better error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$args = [];
|
|
||||||
if ($args_str !== '') {
|
|
||||||
$args = static::__parse_args($args_str, $handler_class, $handler_method, $str);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$class, $method, $args];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse argument list from Auth callable
|
|
||||||
*
|
|
||||||
* Supports: integers, quoted strings, simple values
|
|
||||||
* FATAL on parse failure
|
|
||||||
*
|
|
||||||
* @param string $args_str Argument list string
|
|
||||||
* @param string $handler_class For error messages
|
|
||||||
* @param string $handler_method For error messages
|
|
||||||
* @param string $full_callable For error messages
|
|
||||||
* @return array Parsed arguments
|
|
||||||
* @throws RuntimeException on parse failure
|
|
||||||
*/
|
|
||||||
protected static function __parse_args(string $args_str, string $handler_class, string $handler_method, string $full_callable): array
|
|
||||||
{
|
|
||||||
$args = [];
|
|
||||||
$current_arg = '';
|
|
||||||
$in_quotes = false;
|
|
||||||
$quote_char = null;
|
|
||||||
$escaped = false;
|
|
||||||
|
|
||||||
for ($i = 0; $i < strlen($args_str); $i++) {
|
|
||||||
$char = $args_str[$i];
|
|
||||||
|
|
||||||
if ($escaped) {
|
|
||||||
$current_arg .= $char;
|
|
||||||
$escaped = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($char === '\\') {
|
|
||||||
$escaped = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$in_quotes && ($char === '"' || $char === "'")) {
|
|
||||||
$in_quotes = true;
|
|
||||||
$quote_char = $char;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($in_quotes && $char === $quote_char) {
|
|
||||||
$in_quotes = false;
|
|
||||||
$quote_char = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$in_quotes && $char === ',') {
|
|
||||||
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
|
|
||||||
$current_arg = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$current_arg .= $char;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($in_quotes) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Failed to parse Auth arguments on {$handler_class}::{$handler_method}.\n\n" .
|
|
||||||
"Unclosed quote in: '{$full_callable}'\n\n" .
|
|
||||||
"Make sure all quoted strings are properly closed."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($current_arg !== '') {
|
|
||||||
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $args;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert argument string to appropriate type
|
|
||||||
*
|
|
||||||
* @param string $value
|
|
||||||
* @param string $handler_class For error messages
|
|
||||||
* @param string $handler_method For error messages
|
|
||||||
* @param string $full_callable For error messages
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
protected static function __convert_arg_value(string $value, string $handler_class, string $handler_method, string $full_callable)
|
|
||||||
{
|
|
||||||
if ($value === '') {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Empty argument in Auth on {$handler_class}::{$handler_method}.\n" .
|
|
||||||
"Callable: '{$full_callable}'"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Integer
|
|
||||||
if (preg_match('/^-?\d+$/', $value)) {
|
|
||||||
return (int) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Float
|
|
||||||
if (preg_match('/^-?\d+\.\d+$/', $value)) {
|
|
||||||
return (float) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boolean
|
|
||||||
if ($value === 'true') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ($value === 'false') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Null
|
|
||||||
if ($value === 'null') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else is a string
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for SSR Full Page Cache and serve if available
|
|
||||||
*
|
|
||||||
* @param string $url
|
|
||||||
* @param string $handler_class
|
|
||||||
* @param string $handler_method
|
|
||||||
* @param Request $request
|
|
||||||
* @return Response|null Returns response if FPC should be served, null otherwise
|
|
||||||
*/
|
|
||||||
protected static function __check_ssr_fpc(string $url, string $handler_class, string $handler_method, Request $request)
|
|
||||||
{
|
|
||||||
console_debug('SSR_FPC', "FPC check started for {$url}");
|
|
||||||
|
|
||||||
// Check if SSR FPC is enabled
|
|
||||||
if (!config('rsx.ssr_fpc.enabled', false)) {
|
|
||||||
console_debug('SSR_FPC', 'FPC disabled in config');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is the FPC client (prevent infinite loop)
|
|
||||||
// FPC clients are identified by X-RSpade-FPC-Client: 1 header
|
|
||||||
if (isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1') {
|
|
||||||
console_debug('SSR_FPC', 'Skipping FPC - is FPC client');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has a session (only serve FPC to users without sessions)
|
|
||||||
if (\App\RSpade\Core\Session\Session::has_session()) {
|
|
||||||
console_debug('SSR_FPC', 'Skipping FPC - user has session');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
console_debug('SSR_FPC', 'User has no session, continuing...');
|
|
||||||
|
|
||||||
// FEATURE DISABLED: SSR Full Page Cache is not yet complete
|
|
||||||
// This feature will be finished at a later point
|
|
||||||
// See docs.dev/SSR_FPC.md for implementation details and current state
|
|
||||||
// Disable by always returning false for Static_Page check
|
|
||||||
$has_static_page = false;
|
|
||||||
|
|
||||||
if (!$has_static_page) {
|
|
||||||
console_debug('SSR_FPC', 'SSR FPC feature is disabled - will be completed later');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is authenticated (only serve FPC to unauthenticated users)
|
|
||||||
// TODO: Should use has_session() instead of is_logged_in(), but using is_logged_in() for now to simplify debugging
|
|
||||||
if (\App\RSpade\Core\Session\Session::is_logged_in()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip GET parameters from URL for cache key
|
|
||||||
$clean_url = parse_url($url, PHP_URL_PATH) ?: $url;
|
|
||||||
|
|
||||||
// Get build key from manifest
|
|
||||||
$build_key = \App\RSpade\Core\Manifest\Manifest::get_build_key();
|
|
||||||
|
|
||||||
// Generate Redis cache key
|
|
||||||
$url_hash = sha1($clean_url);
|
|
||||||
$redis_key = "ssr_fpc:{$build_key}:{$url_hash}";
|
|
||||||
|
|
||||||
// Check if cache exists
|
|
||||||
try {
|
|
||||||
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
|
|
||||||
|
|
||||||
if (!$cache_data) {
|
|
||||||
// Cache doesn't exist - generate it
|
|
||||||
console_debug('SSR_FPC', "Cache miss for {$clean_url}, generating...");
|
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Artisan::call('rsx:ssr_fpc:create', [
|
|
||||||
'url' => $clean_url
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Read the newly generated cache
|
|
||||||
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
|
|
||||||
|
|
||||||
if (!$cache_data) {
|
|
||||||
throw new \RuntimeException("Failed to generate SSR FPC cache for route: {$clean_url}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse cache data
|
|
||||||
$cache = json_decode($cache_data, true);
|
|
||||||
if (!$cache) {
|
|
||||||
throw new \RuntimeException("Invalid SSR FPC cache data for route: {$clean_url}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ETag for 304 response
|
|
||||||
$client_etag = $request->header('If-None-Match');
|
|
||||||
if ($client_etag && $client_etag === $cache['etag']) {
|
|
||||||
return response('', 304)
|
|
||||||
->header('ETag', $cache['etag'])
|
|
||||||
->header('X-Cache', 'HIT')
|
|
||||||
->header('Cache-Control', app()->environment('production') ? 'public, max-age=300' : 'no-cache, must-revalidate');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine cache control header
|
|
||||||
$cache_control = app()->environment('production')
|
|
||||||
? 'public, max-age=300' // 5 min in production
|
|
||||||
: 'no-cache, must-revalidate'; // 0s in dev
|
|
||||||
|
|
||||||
// Handle redirect response
|
|
||||||
if ($cache['code'] >= 300 && $cache['code'] < 400 && $cache['redirect']) {
|
|
||||||
return response('', $cache['code'])
|
|
||||||
->header('Location', $cache['redirect'])
|
|
||||||
->header('ETag', $cache['etag'])
|
|
||||||
->header('X-Cache', 'HIT')
|
|
||||||
->header('Cache-Control', $cache_control);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve static HTML
|
|
||||||
console_debug('SSR_FPC', "Serving cached page for {$clean_url}");
|
|
||||||
return response($cache['page_dom'], $cache['code'])
|
|
||||||
->header('Content-Type', 'text/html; charset=UTF-8')
|
|
||||||
->header('ETag', $cache['etag'])
|
|
||||||
->header('X-Cache', 'HIT')
|
|
||||||
->header('X-FPC-Debug', 'served-from-cache')
|
|
||||||
->header('Cache-Control', $cache_control);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Log error and let request proceed normally
|
|
||||||
\Illuminate\Support\Facades\Log::error('SSR FPC error: ' . $e->getMessage());
|
|
||||||
throw new \RuntimeException('SSR FPC generation failed: ' . $e->getMessage(), 0, $e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ use App\RSpade\Core\Session\Session;
|
|||||||
*
|
*
|
||||||
* Unified controller for file attachment operations: upload, download, viewing, and thumbnails.
|
* Unified controller for file attachment operations: upload, download, viewing, and thumbnails.
|
||||||
*
|
*
|
||||||
|
* @auth-exempt Auth is handled via event hooks (file.upload.authorize, file.download.authorize, etc.)
|
||||||
|
*
|
||||||
* ================================================================================================
|
* ================================================================================================
|
||||||
* UPLOAD ENDPOINT
|
* UPLOAD ENDPOINT
|
||||||
* ================================================================================================
|
* ================================================================================================
|
||||||
*
|
*
|
||||||
* POST /_upload
|
* POST /_upload
|
||||||
* AUTH: Permission::anybody() - Authorization is handled via event hooks
|
* AUTH: Handled via file.upload.authorize event hook
|
||||||
*
|
*
|
||||||
* REQUEST PARAMETERS:
|
* REQUEST PARAMETERS:
|
||||||
* - file (required) - The file to upload
|
* - file (required) - The file to upload
|
||||||
@@ -947,7 +949,6 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
|||||||
* @return Response PNG image data
|
* @return Response PNG image data
|
||||||
*/
|
*/
|
||||||
#[Route('/_icon_by_extension/:extension', methods: ['GET'])]
|
#[Route('/_icon_by_extension/:extension', methods: ['GET'])]
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
public static function icon_by_extension(Request $request, array $params = [])
|
public static function icon_by_extension(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
$extension = $params['extension'] ?? '';
|
$extension = $params['extension'] ?? '';
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ class Ajax {
|
|||||||
static ERROR_AUTH_REQUIRED = 'auth_required';
|
static ERROR_AUTH_REQUIRED = 'auth_required';
|
||||||
static ERROR_FATAL = 'fatal';
|
static ERROR_FATAL = 'fatal';
|
||||||
static ERROR_GENERIC = 'generic';
|
static ERROR_GENERIC = 'generic';
|
||||||
static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
|
static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
|
||||||
static ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
|
static ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
|
||||||
|
static ERROR_PHP_EXCEPTION = 'php_exception'; // Client-generated (PHP exception with file/line/backtrace)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Ajax system
|
* Initialize Ajax system
|
||||||
@@ -208,7 +209,8 @@ class Ajax {
|
|||||||
resolve(processed_value);
|
resolve(processed_value);
|
||||||
} else {
|
} else {
|
||||||
// Handle error responses
|
// Handle error responses
|
||||||
const error_code = response.error_code || Ajax.ERROR_GENERIC;
|
// Server may use error_code or error_type
|
||||||
|
const error_code = response.error_code || response.error_type || Ajax.ERROR_GENERIC;
|
||||||
const reason = response.reason || 'An error occurred';
|
const reason = response.reason || 'An error occurred';
|
||||||
const metadata = response.metadata || {};
|
const metadata = response.metadata || {};
|
||||||
|
|
||||||
@@ -217,13 +219,26 @@ class Ajax {
|
|||||||
error.code = error_code;
|
error.code = error_code;
|
||||||
error.metadata = metadata;
|
error.metadata = metadata;
|
||||||
|
|
||||||
// Handle fatal errors specially
|
// Handle fatal errors specially - detect PHP exceptions
|
||||||
if (error_code === Ajax.ERROR_FATAL) {
|
if (error_code === Ajax.ERROR_FATAL) {
|
||||||
const fatal_error_data = response.error || {};
|
const fatal_error_data = response.error || {};
|
||||||
error.message = fatal_error_data.error || 'Fatal error occurred';
|
|
||||||
error.metadata = response.error;
|
|
||||||
|
|
||||||
console.error('Ajax error response from server:', response.error);
|
// Check if this is a PHP exception (has file, line, error, backtrace)
|
||||||
|
if (fatal_error_data.file && fatal_error_data.line && fatal_error_data.error) {
|
||||||
|
error.code = Ajax.ERROR_PHP_EXCEPTION;
|
||||||
|
error.message = fatal_error_data.error;
|
||||||
|
error.metadata = {
|
||||||
|
file: fatal_error_data.file,
|
||||||
|
line: fatal_error_data.line,
|
||||||
|
error: fatal_error_data.error,
|
||||||
|
backtrace: fatal_error_data.backtrace || []
|
||||||
|
};
|
||||||
|
console.error('PHP Exception:', fatal_error_data.error, 'at', fatal_error_data.file + ':' + fatal_error_data.line);
|
||||||
|
} else {
|
||||||
|
error.message = fatal_error_data.error || 'Fatal error occurred';
|
||||||
|
error.metadata = response.error;
|
||||||
|
console.error('Ajax error response from server:', response.error);
|
||||||
|
}
|
||||||
|
|
||||||
// Log to server
|
// Log to server
|
||||||
Debugger.log_error({
|
Debugger.log_error({
|
||||||
@@ -375,7 +390,8 @@ class Ajax {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Handle error
|
// Handle error
|
||||||
const error_code = call_response.error_code || Ajax.ERROR_GENERIC;
|
// Server may use error_code or error_type
|
||||||
|
const error_code = call_response.error_code || call_response.error_type || Ajax.ERROR_GENERIC;
|
||||||
const error_message = call_response.reason || 'Unknown error occurred';
|
const error_message = call_response.reason || 'Unknown error occurred';
|
||||||
const metadata = call_response.metadata || {};
|
const metadata = call_response.metadata || {};
|
||||||
|
|
||||||
@@ -383,6 +399,28 @@ class Ajax {
|
|||||||
error.code = error_code;
|
error.code = error_code;
|
||||||
error.metadata = metadata;
|
error.metadata = metadata;
|
||||||
|
|
||||||
|
// Handle fatal errors specially - detect PHP exceptions
|
||||||
|
if (error_code === Ajax.ERROR_FATAL) {
|
||||||
|
const fatal_error_data = call_response.error || {};
|
||||||
|
|
||||||
|
// Check if this is a PHP exception (has file, line, error, backtrace)
|
||||||
|
if (fatal_error_data.file && fatal_error_data.line && fatal_error_data.error) {
|
||||||
|
error.code = Ajax.ERROR_PHP_EXCEPTION;
|
||||||
|
error.message = fatal_error_data.error;
|
||||||
|
error.metadata = {
|
||||||
|
file: fatal_error_data.file,
|
||||||
|
line: fatal_error_data.line,
|
||||||
|
error: fatal_error_data.error,
|
||||||
|
backtrace: fatal_error_data.backtrace || []
|
||||||
|
};
|
||||||
|
console.error('PHP Exception:', fatal_error_data.error, 'at', fatal_error_data.file + ':' + fatal_error_data.line);
|
||||||
|
} else {
|
||||||
|
error.message = fatal_error_data.error || 'Fatal error occurred';
|
||||||
|
error.metadata = call_response.error;
|
||||||
|
console.error('Ajax error response from server:', call_response.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pending_call.is_error = true;
|
pending_call.is_error = true;
|
||||||
pending_call.error = error;
|
pending_call.error = error;
|
||||||
|
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
// Simple key value cache. Can only store 5000 entries, will reset after 5000 entries.
|
|
||||||
|
|
||||||
// Todo: keep local cache concept the same, replace global cache concept with the nov 2019 version of
|
|
||||||
// session cache. Use a session key & build key to track cache keys so cached values only last until user logs out.
|
|
||||||
// review session code to ensure that session key *always* rotates on logout. Make session id a protected value.
|
|
||||||
class Rsx_Cache {
|
|
||||||
static on_core_define() {
|
|
||||||
Core_Cache._caches = {
|
|
||||||
global: {},
|
|
||||||
instance: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
Core_Cache._caches_set = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alias for get_instance
|
|
||||||
static get(key) {
|
|
||||||
return Rsx_Cache.get_instance(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns from the pool of cached data for this 'instance'. An instance
|
|
||||||
// in this case is a virtual page load / navigation in the SPA. Call Main.lib.reset() to reset.
|
|
||||||
// Returns null on failure
|
|
||||||
static get_instance(key) {
|
|
||||||
if (Main.debug('no_api_cache')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_encoded = Rsx_Cache._encodekey(key);
|
|
||||||
|
|
||||||
if (typeof Core_Cache._caches.instance[key_encoded] != undef) {
|
|
||||||
return JSON.parse(Core_Cache._caches.instance[key_encoded]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns null on failure
|
|
||||||
// Returns a cached value from global cache (unique to page load, survives reset())
|
|
||||||
static get_global(key) {
|
|
||||||
if (Main.debug('no_api_cache')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_encoded = Rsx_Cache._encodekey(key);
|
|
||||||
|
|
||||||
if (typeof Core_Cache._caches.global[key_encoded] != undef) {
|
|
||||||
return JSON.parse(Core_Cache._caches.global[key_encoded]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets a value in instance and global cache (not shared between browser tabs)
|
|
||||||
static set(key, value) {
|
|
||||||
if (Main.debug('no_api_cache')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.length > 64 * 1024) {
|
|
||||||
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_encoded = Rsx_Cache._encodekey(key);
|
|
||||||
|
|
||||||
Core_Cache._caches.global[key_encoded] = JSON.stringify(value);
|
|
||||||
Core_Cache._caches.instance[key_encoded] = JSON.stringify(value);
|
|
||||||
|
|
||||||
// Debugger.console_debug("CACHE", "Set", key, value);
|
|
||||||
|
|
||||||
Core_Cache._caches_set++;
|
|
||||||
|
|
||||||
// Reset cache after 5000 items set
|
|
||||||
if (Core_Cache._caches_set > 5000) {
|
|
||||||
// Get an accurate count
|
|
||||||
Core_Cache._caches_set = count(Core_Cache._caches.global);
|
|
||||||
|
|
||||||
if (Core_Cache._caches_set > 5000) {
|
|
||||||
Core_Cache._caches = {
|
|
||||||
global: {},
|
|
||||||
instance: {},
|
|
||||||
};
|
|
||||||
Core_Cache._caches_set = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns null on failure
|
|
||||||
// Returns a cached value from session cache (shared between browser tabs)
|
|
||||||
static get_session(key) {
|
|
||||||
if (Main.debug('no_api_cache')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Rsx_Cache._supportsStorage()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_encoded = Rsx_Cache._encodekey(key);
|
|
||||||
|
|
||||||
let rs = sessionStorage.getItem(key_encoded);
|
|
||||||
|
|
||||||
if (!empty(rs)) {
|
|
||||||
return JSON.parse(rs);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sets a value in session cache (shared between browser tabs)
|
|
||||||
static set_session(key, value, _tryagain = true) {
|
|
||||||
if (Main.debug('no_api_cache')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.length > 64 * 1024) {
|
|
||||||
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Rsx_Cache._supportsStorage()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_encoded = Rsx_Cache._encodekey(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(key_encoded);
|
|
||||||
sessionStorage.setItem(key_encoded, JSON.stringify(value));
|
|
||||||
} catch (e) {
|
|
||||||
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
|
|
||||||
sessionStorage.clear();
|
|
||||||
if (_tryagain) {
|
|
||||||
Core_Cache.set_session(key, value, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static _reset() {
|
|
||||||
Core_Cache._caches.instance = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For given key of any type including an object, return a string representing
|
|
||||||
* the key that the cached value should be stored as in sessionstorage
|
|
||||||
*/
|
|
||||||
static _encodekey(key) {
|
|
||||||
const prefix = 'cache_';
|
|
||||||
|
|
||||||
// Session reimplement
|
|
||||||
// var prefix = "cache_" + Spa.session().user_id() + "_";
|
|
||||||
|
|
||||||
if (is_string(key) && key.length < 150 && key.indexOf(' ') == -1) {
|
|
||||||
return prefix + Manifest.build_key() + '_' + key;
|
|
||||||
} else {
|
|
||||||
return prefix + hash([Manifest.build_key(), key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determines if sessionStorage is supported in the browser;
|
|
||||||
// result is cached for better performance instead of being run each time.
|
|
||||||
// Feature detection is based on how Modernizr does it;
|
|
||||||
// it's not straightforward due to FF4 issues.
|
|
||||||
// It's not run at parse-time as it takes 200ms in Android.
|
|
||||||
// Code from https://github.com/pamelafox/lscache/blob/master/lscache.js, Apache License Pamelafox
|
|
||||||
static _supportsStorage() {
|
|
||||||
let key = '__cachetest__';
|
|
||||||
let value = key;
|
|
||||||
|
|
||||||
if (Rsx_Cache.__supportsStorage !== undefined) {
|
|
||||||
return Rsx_Cache.__supportsStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// some browsers will throw an error if you try to access local storage (e.g. brave browser)
|
|
||||||
// hence check is inside a try/catch
|
|
||||||
try {
|
|
||||||
if (!sessionStorage) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (ex) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(key, value);
|
|
||||||
sessionStorage.removeItem(key);
|
|
||||||
Rsx_Cache.__supportsStorage = true;
|
|
||||||
} catch (e) {
|
|
||||||
// If we hit the limit, and we don't have an empty sessionStorage then it means we have support
|
|
||||||
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
|
|
||||||
Rsx_Cache.__supportsStorage = true; // just maxed it out and even the set test failed.
|
|
||||||
} else {
|
|
||||||
Rsx_Cache.__supportsStorage = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Rsx_Cache.__supportsStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check to set if the error is us dealing with being out of space
|
|
||||||
static _isOutOfSpace(e) {
|
|
||||||
return e && (e.name === 'QUOTA_EXCEEDED_ERR' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || e.name === 'QuotaExceededError');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,10 @@
|
|||||||
* // Fetch single record
|
* // Fetch single record
|
||||||
* const user = await User_Model.fetch(123);
|
* const user = await User_Model.fetch(123);
|
||||||
*
|
*
|
||||||
* // Fetch multiple records
|
|
||||||
* const users = await User_Model.fetch([1, 2, 3]);
|
|
||||||
*
|
|
||||||
* // Create instance with data
|
* // Create instance with data
|
||||||
* const user = new User_Model({id: 1, name: 'John'});
|
* const user = new User_Model({id: 1, name: 'John'});
|
||||||
*
|
*
|
||||||
* @Instantiatable
|
* @Instantiatable
|
||||||
*/
|
*/
|
||||||
class Rsx_Js_Model {
|
class Rsx_Js_Model {
|
||||||
/**
|
/**
|
||||||
@@ -42,40 +39,58 @@ class Rsx_Js_Model {
|
|||||||
* The backend model must have a fetch() method with the
|
* The backend model must have a fetch() method with the
|
||||||
* #[Ajax_Endpoint_Model_Fetch] annotation to be callable.
|
* #[Ajax_Endpoint_Model_Fetch] annotation to be callable.
|
||||||
*
|
*
|
||||||
|
* Throws an error if the record is not found or access is denied.
|
||||||
|
* Use fetch_or_null() if you want null instead of an exception.
|
||||||
|
*
|
||||||
* @param {number|Array} id - Single ID or array of IDs to fetch
|
* @param {number|Array} id - Single ID or array of IDs to fetch
|
||||||
* @returns {Promise} - Single model instance, array of instances, or false
|
* @returns {Promise} - Single model instance or array of instances
|
||||||
|
* @throws {Error} - If record not found or access denied
|
||||||
*/
|
*/
|
||||||
static async fetch(id) {
|
static async fetch(id) {
|
||||||
const CurrentClass = this;
|
const CurrentClass = this;
|
||||||
// Get the model class name from the current class
|
// Get the PHP model name from the static __MODEL property
|
||||||
const modelName = CurrentClass.name;
|
// This allows Base_Project_Model to correctly identify as "Project_Model" to PHP
|
||||||
|
const modelName = CurrentClass.__MODEL || CurrentClass.name;
|
||||||
|
|
||||||
const response = await $.ajax({
|
// Use the standard Ajax endpoint pattern via Orm_Controller
|
||||||
url: `/_fetch/${modelName}`,
|
const response = await Orm_Controller.fetch({ model: modelName, id: id });
|
||||||
method: 'POST',
|
|
||||||
data: { id: id },
|
|
||||||
dataType: 'json',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle response based on type
|
// Response is already hydrated by Ajax layer (Ajax.js calls _instantiate_models_recursive)
|
||||||
if (response === false) {
|
// Just return the response directly
|
||||||
return false;
|
return response;
|
||||||
}
|
|
||||||
|
|
||||||
// Use _instantiate_models_recursive to handle ORM instantiation
|
|
||||||
// This will automatically detect __MODEL properties and create appropriate instances
|
|
||||||
return Rsx_Js_Model._instantiate_models_recursive(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the model class name
|
* Fetch record from the backend model, returning null if not found
|
||||||
|
*
|
||||||
|
* Same as fetch() but returns null instead of throwing an error
|
||||||
|
* when the record doesn't exist or access is denied.
|
||||||
|
*
|
||||||
|
* Use this when you're not sure if the record exists and want
|
||||||
|
* to handle the "not found" case gracefully without try/catch.
|
||||||
|
*
|
||||||
|
* @param {number} id - ID to fetch
|
||||||
|
* @returns {Promise} - Model instance or null if not found
|
||||||
|
*/
|
||||||
|
static async fetch_or_null(id) {
|
||||||
|
const CurrentClass = this;
|
||||||
|
const modelName = CurrentClass.__MODEL || CurrentClass.name;
|
||||||
|
|
||||||
|
// Pass or_null flag to get null instead of exception
|
||||||
|
const response = await Orm_Controller.fetch({ model: modelName, id: id, or_null: true });
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PHP model class name
|
||||||
* Used internally for API calls
|
* Used internally for API calls
|
||||||
*
|
*
|
||||||
* @returns {string} The class name
|
* @returns {string} The PHP model class name
|
||||||
*/
|
*/
|
||||||
static getModelName() {
|
static getModelName() {
|
||||||
const CurrentClass = this;
|
const CurrentClass = this;
|
||||||
return CurrentClass.name;
|
return CurrentClass.__MODEL || CurrentClass.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,20 +146,12 @@ class Rsx_Js_Model {
|
|||||||
* Recursively instantiate ORM models in response data
|
* Recursively instantiate ORM models in response data
|
||||||
*
|
*
|
||||||
* Looks for objects with __MODEL property and instantiates the appropriate
|
* Looks for objects with __MODEL property and instantiates the appropriate
|
||||||
* JavaScript model class if it exists in the global scope.
|
* JavaScript model class from the Manifest registry.
|
||||||
*
|
*
|
||||||
* @param {*} data - The data to process (can be any type)
|
* @param {*} data - The data to process (can be any type)
|
||||||
* @returns {*} The data with ORM objects instantiated
|
* @returns {*} The data with ORM objects instantiated
|
||||||
*/
|
*/
|
||||||
static _instantiate_models_recursive(data) {
|
static _instantiate_models_recursive(data) {
|
||||||
// __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.
|
|
||||||
// PHP models add "__MODEL": "ClassName" to JSON, JavaScript uses it to create proper instances.
|
|
||||||
// This provides typed model objects instead of plain JSON, with methods and type checking.
|
|
||||||
|
|
||||||
// This recursive processor scans all API response data looking for __MODEL markers.
|
|
||||||
// When found, it attempts to instantiate the appropriate JavaScript model class,
|
|
||||||
// converting {__MODEL: "User_Model", id: 1, name: "John"} into new User_Model({...}).
|
|
||||||
// Works recursively through arrays and nested objects to handle complex data structures.
|
|
||||||
// Handle null/undefined
|
// Handle null/undefined
|
||||||
if (data === null || data === undefined) {
|
if (data === null || data === undefined) {
|
||||||
return data;
|
return data;
|
||||||
@@ -159,12 +166,12 @@ class Rsx_Js_Model {
|
|||||||
if (typeof data === 'object') {
|
if (typeof data === 'object') {
|
||||||
// Check if this object has a __MODEL property
|
// Check if this object has a __MODEL property
|
||||||
if (data.__MODEL && typeof data.__MODEL === 'string') {
|
if (data.__MODEL && typeof data.__MODEL === 'string') {
|
||||||
// Try to find the model class in the global scope
|
// Look up the model class in the Manifest registry
|
||||||
const ModelClass = window[data.__MODEL];
|
const ModelClass = Manifest.get_class_by_name(data.__MODEL);
|
||||||
|
|
||||||
// If the model class exists and extends Rsx_Js_Model, instantiate it
|
// If the model class exists and extends Rsx_Js_Model, instantiate it
|
||||||
// Dynamic model resolution requires checking class existence - @JS-DEFENSIVE-01-EXCEPTION
|
// Dynamic model resolution requires checking class existence - @JS-DEFENSIVE-01-EXCEPTION
|
||||||
if (ModelClass && ModelClass.prototype instanceof Rsx_Js_Model) {
|
if (ModelClass && Manifest.js_is_subclass_of(ModelClass, Rsx_Js_Model)) {
|
||||||
return new ModelClass(data);
|
return new ModelClass(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -591,6 +591,21 @@ class Manifest
|
|||||||
return $lineage;
|
return $lineage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a class name corresponds to a PHP model class (exists in models index)
|
||||||
|
*
|
||||||
|
* This is used by the JS model system to recognize PHP model class names that may
|
||||||
|
* appear in JS inheritance chains but don't exist as JS classes in the manifest.
|
||||||
|
* PHP models like "Project_Model" generate JS stubs during bundle compilation.
|
||||||
|
*
|
||||||
|
* @param string $class_name The class name to check
|
||||||
|
* @return bool True if this is a PHP model class name
|
||||||
|
*/
|
||||||
|
public static function is_php_model_class(string $class_name): bool
|
||||||
|
{
|
||||||
|
return isset(static::$data['data']['models'][$class_name]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a class is a subclass of another by traversing the inheritance chain
|
* Check if a class is a subclass of another by traversing the inheritance chain
|
||||||
*
|
*
|
||||||
@@ -625,6 +640,13 @@ class Manifest
|
|||||||
|
|
||||||
$visited[] = $current_class;
|
$visited[] = $current_class;
|
||||||
|
|
||||||
|
// HACK #1 - JS Model shortcut: When checking against Rsx_Js_Model, if we encounter
|
||||||
|
// a PHP model class name (like "Project_Model"), we know it's a model that will have
|
||||||
|
// a generated Base_ stub extending Rsx_Js_Model. Return true immediately.
|
||||||
|
if ($superclass === 'Rsx_Js_Model' && static::is_php_model_class($current_class)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Find the current class in the manifest
|
// Find the current class in the manifest
|
||||||
if (!isset(static::$data['data']['js_classes'][$current_class])) {
|
if (!isset(static::$data['data']['js_classes'][$current_class])) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
use App\RSpade\Core\Database\Models\Rsx_Site_Model_Abstract;
|
use App\RSpade\Core\Database\Models\Rsx_Site_Model_Abstract;
|
||||||
use App\RSpade\Core\Models\Login_User_Model;
|
use App\RSpade\Core\Models\Login_User_Model;
|
||||||
use App\RSpade\Core\Models\Site_Model;
|
use App\RSpade\Core\Models\Site_Model;
|
||||||
|
use App\RSpade\Core\Models\User_Permission_Model;
|
||||||
use App\RSpade\Core\Models\User_Profile_Model;
|
use App\RSpade\Core\Models\User_Profile_Model;
|
||||||
/**
|
/**
|
||||||
* User_Model - Site-specific user profile
|
* User_Model - Site-specific user profile with role-based access control
|
||||||
*
|
*
|
||||||
* Represents a user's profile within a specific site/organization.
|
* Represents a user's profile within a specific site/organization.
|
||||||
* A single login identity (Login_User_Model) can have multiple User_Model records
|
* A single login identity (Login_User_Model) can have multiple User_Model records
|
||||||
* for different sites, allowing different names, roles, and profiles per organization.
|
* for different sites, allowing different names, roles, and profiles per organization.
|
||||||
*
|
*
|
||||||
* Contains: first_name, last_name, phone, site_id, role_id, user_role_id
|
* ACL System:
|
||||||
* References: login_user_id → Login_User_Model (authentication identity)
|
* - Primary role (role_id) grants base permissions
|
||||||
|
* - Supplementary permissions (user_permissions table) can GRANT or DENY specific permissions
|
||||||
|
* - Resolution: DISABLED check → DENY override → GRANT override → role default
|
||||||
|
*
|
||||||
|
* See: php artisan rsx:man acls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
@@ -45,83 +50,130 @@ use App\RSpade\Core\Models\User_Profile_Model;
|
|||||||
* @method static mixed role_id_enum_ids()
|
* @method static mixed role_id_enum_ids()
|
||||||
* @property-read mixed $role_id_constant
|
* @property-read mixed $role_id_constant
|
||||||
* @property-read mixed $role_id_label
|
* @property-read mixed $role_id_label
|
||||||
* @method static mixed user_role_id_enum()
|
* @property-read mixed $role_id_permissions
|
||||||
* @method static mixed user_role_id_enum_select()
|
* @property-read mixed $role_id_can_admin_roles
|
||||||
* @method static mixed user_role_id_enum_ids()
|
* @property-read mixed $role_id_selectable
|
||||||
* @property-read mixed $user_role_id_constant
|
|
||||||
* @property-read mixed $user_role_id_label
|
|
||||||
* @property-read mixed $user_role_id_order
|
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class User_Model extends Rsx_Site_Model_Abstract
|
class User_Model extends Rsx_Site_Model_Abstract
|
||||||
{
|
{
|
||||||
/** __AUTO_GENERATED: */
|
/** __AUTO_GENERATED: */
|
||||||
const ROLE_OWNER = 1;
|
const ROLE_DEVELOPER = 100;
|
||||||
const ROLE_ADMIN = 2;
|
const ROLE_ROOT_ADMIN = 200;
|
||||||
const ROLE_MEMBER = 3;
|
const ROLE_SITE_OWNER = 300;
|
||||||
const ROLE_VIEWER = 4;
|
const ROLE_SITE_ADMIN = 400;
|
||||||
const USER_ROLE_READ_ONLY = 1;
|
const ROLE_MANAGER = 500;
|
||||||
const USER_ROLE_STANDARD = 2;
|
const ROLE_USER = 600;
|
||||||
const USER_ROLE_ADMIN = 3;
|
const ROLE_VIEWER = 700;
|
||||||
const USER_ROLE_BILLING_ADMIN = 4;
|
const ROLE_DISABLED = 800;
|
||||||
const USER_ROLE_ROOT_ADMIN = 5;
|
|
||||||
/** __/AUTO_GENERATED */
|
/** __/AUTO_GENERATED */
|
||||||
|
|
||||||
// Invitation status constants
|
// =========================================================================
|
||||||
|
// ROLE CONSTANTS (lower ID = higher privilege, 100-based for future expansion)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PERMISSION CONSTANTS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// INVITATION STATUS CONSTANTS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
const INVITATION_PENDING = 'pending';
|
const INVITATION_PENDING = 'pending';
|
||||||
const INVITATION_ACCEPTED = 'accepted';
|
const INVITATION_ACCEPTED = 'accepted';
|
||||||
const INVITATION_EXPIRED = 'expired';
|
const INVITATION_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
// Permission constants - must match integer values used in $enums['role_id']['permissions']
|
||||||
|
const PERM_MANAGE_SITES_ROOT = 1;
|
||||||
|
const PERM_MANAGE_SITE_BILLING = 2;
|
||||||
|
const PERM_MANAGE_SITE_SETTINGS = 3;
|
||||||
|
const PERM_MANAGE_SITE_USERS = 4;
|
||||||
|
const PERM_VIEW_USER_ACTIVITY = 5;
|
||||||
|
const PERM_EDIT_DATA = 6;
|
||||||
|
const PERM_VIEW_DATA = 7;
|
||||||
|
const PERM_API_ACCESS = 8;
|
||||||
|
const PERM_DATA_EXPORT = 9;
|
||||||
|
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum field definitions
|
* Cached supplementary permissions for this user
|
||||||
|
* @var array|null
|
||||||
|
*/
|
||||||
|
protected $_supplementary_permissions = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum field definitions with ACL permissions and can_admin_roles
|
||||||
|
*
|
||||||
|
* NOTE: Cannot use self:: constants in static property initialization (PHP limitation).
|
||||||
|
* Values must match the ROLE_* and PERM_* constants defined above.
|
||||||
|
*
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
public static $enums = [
|
public static $enums = [
|
||||||
'role_id' => [
|
'role_id' => [
|
||||||
1 => [
|
// ROLE_DEVELOPER = 100
|
||||||
'constant' => 'ROLE_OWNER',
|
100 => [
|
||||||
'label' => 'Owner',
|
'constant' => 'ROLE_DEVELOPER',
|
||||||
|
'label' => 'Developer',
|
||||||
|
'permissions' => [1, 2, 3, 4, 5, 6, 7], // All core PERM_* (1-7)
|
||||||
|
'can_admin_roles' => [200, 300, 400, 500, 600, 700, 800], // All roles below
|
||||||
|
'selectable' => false, // Developer assigned by system only
|
||||||
],
|
],
|
||||||
2 => [
|
// ROLE_ROOT_ADMIN = 200
|
||||||
'constant' => 'ROLE_ADMIN',
|
200 => [
|
||||||
'label' => 'Admin',
|
'constant' => 'ROLE_ROOT_ADMIN',
|
||||||
|
'label' => 'Root Admin',
|
||||||
|
'permissions' => [1, 2, 3, 4, 5, 6, 7], // All core PERM_* (1-7)
|
||||||
|
'can_admin_roles' => [300, 400, 500, 600, 700, 800], // All roles below
|
||||||
|
'selectable' => false, // Root admin assigned by system only
|
||||||
],
|
],
|
||||||
3 => [
|
// ROLE_SITE_OWNER = 300
|
||||||
'constant' => 'ROLE_MEMBER',
|
300 => [
|
||||||
'label' => 'Member',
|
'constant' => 'ROLE_SITE_OWNER',
|
||||||
|
'label' => 'Site Owner',
|
||||||
|
'permissions' => [2, 3, 4, 5, 6, 7], // BILLING(2) through VIEW(7)
|
||||||
|
'can_admin_roles' => [400, 500, 600, 700, 800], // Site Admin and below
|
||||||
],
|
],
|
||||||
4 => [
|
// ROLE_SITE_ADMIN = 400
|
||||||
|
400 => [
|
||||||
|
'constant' => 'ROLE_SITE_ADMIN',
|
||||||
|
'label' => 'Site Admin',
|
||||||
|
'permissions' => [3, 4, 5, 6, 7], // SETTINGS(3) through VIEW(7)
|
||||||
|
'can_admin_roles' => [500, 600, 700, 800], // Manager and below
|
||||||
|
],
|
||||||
|
// ROLE_MANAGER = 500
|
||||||
|
500 => [
|
||||||
|
'constant' => 'ROLE_MANAGER',
|
||||||
|
'label' => 'Manager',
|
||||||
|
'permissions' => [5, 6, 7], // ACTIVITY(5), EDIT(6), VIEW(7)
|
||||||
|
'can_admin_roles' => [600, 700, 800], // User and below
|
||||||
|
],
|
||||||
|
// ROLE_USER = 600
|
||||||
|
600 => [
|
||||||
|
'constant' => 'ROLE_USER',
|
||||||
|
'label' => 'User',
|
||||||
|
'permissions' => [6, 7], // EDIT(6), VIEW(7)
|
||||||
|
'can_admin_roles' => [],
|
||||||
|
],
|
||||||
|
// ROLE_VIEWER = 700
|
||||||
|
700 => [
|
||||||
'constant' => 'ROLE_VIEWER',
|
'constant' => 'ROLE_VIEWER',
|
||||||
'label' => 'Viewer',
|
'label' => 'Viewer',
|
||||||
|
'permissions' => [7], // VIEW(7) only
|
||||||
|
'can_admin_roles' => [],
|
||||||
],
|
],
|
||||||
],
|
// ROLE_DISABLED = 800
|
||||||
'user_role_id' => [
|
800 => [
|
||||||
1 => [
|
'constant' => 'ROLE_DISABLED',
|
||||||
'constant' => 'USER_ROLE_READ_ONLY',
|
'label' => 'Disabled',
|
||||||
'label' => 'Read Only',
|
'permissions' => [],
|
||||||
'order' => 1,
|
'can_admin_roles' => [],
|
||||||
],
|
|
||||||
2 => [
|
|
||||||
'constant' => 'USER_ROLE_STANDARD',
|
|
||||||
'label' => 'Standard',
|
|
||||||
'order' => 2,
|
|
||||||
],
|
|
||||||
3 => [
|
|
||||||
'constant' => 'USER_ROLE_ADMIN',
|
|
||||||
'label' => 'Admin',
|
|
||||||
'order' => 3,
|
|
||||||
],
|
|
||||||
4 => [
|
|
||||||
'constant' => 'USER_ROLE_BILLING_ADMIN',
|
|
||||||
'label' => 'Billing Admin',
|
|
||||||
'order' => 4,
|
|
||||||
],
|
|
||||||
5 => [
|
|
||||||
'constant' => 'USER_ROLE_ROOT_ADMIN',
|
|
||||||
'label' => 'Root Admin',
|
|
||||||
'order' => 5,
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -133,22 +185,9 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
*/
|
*/
|
||||||
protected $table = 'users';
|
protected $table = 'users';
|
||||||
|
|
||||||
/**
|
// =========================================================================
|
||||||
* Column metadata for special handling
|
// RELATIONSHIPS
|
||||||
*
|
// =========================================================================
|
||||||
* @var array
|
|
||||||
*/
|
|
||||||
protected $columnMeta = [
|
|
||||||
'role_id' => [
|
|
||||||
'type' => 'enum',
|
|
||||||
'values' => [
|
|
||||||
1 => 'owner',
|
|
||||||
2 => 'admin',
|
|
||||||
3 => 'member',
|
|
||||||
4 => 'viewer',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the login user (authentication identity)
|
* Get the login user (authentication identity)
|
||||||
@@ -183,6 +222,155 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
return $this->hasOne(User_Profile_Model::class, 'user_id');
|
return $this->hasOne(User_Profile_Model::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get supplementary permissions
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
*/
|
||||||
|
#[Relationship]
|
||||||
|
public function supplementary_permissions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(User_Permission_Model::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ACL METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has a specific permission
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. DISABLED role = deny all
|
||||||
|
* 2. Supplementary DENY = deny
|
||||||
|
* 3. Supplementary GRANT = grant
|
||||||
|
* 4. Role default permissions = grant if included
|
||||||
|
* 5. Deny
|
||||||
|
*
|
||||||
|
* @param int $permission Permission constant (PERM_*)
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function has_permission(int $permission): bool
|
||||||
|
{
|
||||||
|
// Disabled users have no permissions
|
||||||
|
if ($this->role_id === self::ROLE_DISABLED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check supplementary DENY (overrides everything)
|
||||||
|
if ($this->has_supplementary_deny($permission)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check supplementary GRANT
|
||||||
|
if ($this->has_supplementary_grant($permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role default permissions
|
||||||
|
$role_permissions = $this->role_id_permissions ?? [];
|
||||||
|
return in_array($permission, $role_permissions, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can administer users with the given role
|
||||||
|
*
|
||||||
|
* Prevents privilege escalation - users can only assign roles
|
||||||
|
* at or below their own permission level.
|
||||||
|
*
|
||||||
|
* @param int $role_id Role constant (ROLE_*)
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function can_admin_role(int $role_id): bool
|
||||||
|
{
|
||||||
|
$can_admin = $this->role_id_can_admin_roles ?? [];
|
||||||
|
return in_array($role_id, $can_admin, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has at least the specified role level
|
||||||
|
*
|
||||||
|
* "At least" means same or higher privilege (lower role_id number).
|
||||||
|
*
|
||||||
|
* @param int $role_id Role constant (ROLE_*)
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function has_role(int $role_id): bool
|
||||||
|
{
|
||||||
|
// Lower role_id = higher privilege
|
||||||
|
return $this->role_id <= $role_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SUPPLEMENTARY PERMISSION METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load supplementary permissions for this user (cached per request)
|
||||||
|
*
|
||||||
|
* @return array ['grants' => [permission_ids], 'denies' => [permission_ids]]
|
||||||
|
*/
|
||||||
|
protected function _load_supplementary_permissions(): array
|
||||||
|
{
|
||||||
|
if ($this->_supplementary_permissions !== null) {
|
||||||
|
return $this->_supplementary_permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->_supplementary_permissions = [
|
||||||
|
'grants' => [],
|
||||||
|
'denies' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load from user_permissions table
|
||||||
|
$permissions = User_Permission_Model::where('user_id', $this->id)->get();
|
||||||
|
|
||||||
|
foreach ($permissions as $perm) {
|
||||||
|
if ($perm->is_grant) {
|
||||||
|
$this->_supplementary_permissions['grants'][] = $perm->permission_id;
|
||||||
|
} else {
|
||||||
|
$this->_supplementary_permissions['denies'][] = $perm->permission_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->_supplementary_permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has explicit GRANT for permission
|
||||||
|
*
|
||||||
|
* @param int $permission Permission constant
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function has_supplementary_grant(int $permission): bool
|
||||||
|
{
|
||||||
|
$supplementary = $this->_load_supplementary_permissions();
|
||||||
|
return in_array($permission, $supplementary['grants'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has explicit DENY for permission
|
||||||
|
*
|
||||||
|
* @param int $permission Permission constant
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function has_supplementary_deny(int $permission): bool
|
||||||
|
{
|
||||||
|
$supplementary = $this->_load_supplementary_permissions();
|
||||||
|
return in_array($permission, $supplementary['denies'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached supplementary permissions (call after modifying)
|
||||||
|
*/
|
||||||
|
public function clear_supplementary_cache(): void
|
||||||
|
{
|
||||||
|
$this->_supplementary_permissions = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ACCESSORS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full name of the user
|
* Get the full name of the user
|
||||||
*
|
*
|
||||||
@@ -213,17 +401,9 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
return 'Unknown User';
|
return 'Unknown User';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// =========================================================================
|
||||||
* Get the role name
|
// STATUS METHODS
|
||||||
*
|
// =========================================================================
|
||||||
* @return string|null
|
|
||||||
*/
|
|
||||||
public function get_role_name()
|
|
||||||
{
|
|
||||||
$roles = $this->get_enum_values('role_id');
|
|
||||||
|
|
||||||
return $roles[$this->role_id] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is active in this site
|
* Check if user is active in this site
|
||||||
@@ -235,37 +415,6 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
return $this->is_enabled && !$this->trashed();
|
return $this->is_enabled && !$this->trashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user has a specific role
|
|
||||||
*
|
|
||||||
* @param string $role_name
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function has_role($role_name)
|
|
||||||
{
|
|
||||||
return $this->get_role_name() === $role_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is an owner
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function is_owner()
|
|
||||||
{
|
|
||||||
return $this->has_role('owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is an admin or owner
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function is_admin()
|
|
||||||
{
|
|
||||||
return $this->has_role('admin') || $this->has_role('owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope to only get enabled site users
|
* Scope to only get enabled site users
|
||||||
*
|
*
|
||||||
@@ -281,20 +430,12 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
* Scope to get users with a specific role
|
* Scope to get users with a specific role
|
||||||
*
|
*
|
||||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||||
* @param int|string $role
|
* @param int $role_id Role constant
|
||||||
* @return \Illuminate\Database\Eloquent\Builder
|
* @return \Illuminate\Database\Eloquent\Builder
|
||||||
*/
|
*/
|
||||||
public function scopeWithRole($query, $role)
|
public function scopeWithRole($query, int $role_id)
|
||||||
{
|
{
|
||||||
if (is_string($role)) {
|
return $query->where('role_id', $role_id);
|
||||||
$roles = $this->get_enum_values('role_id');
|
|
||||||
$role_id = array_search($role, $roles);
|
|
||||||
if ($role_id !== false) {
|
|
||||||
$role = $role_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where('role_id', $role);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
151
app/RSpade/Core/Models/User_Permission_Model.php
Executable file
151
app/RSpade/Core/Models/User_Permission_Model.php
Executable file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\Core\Models;
|
||||||
|
|
||||||
|
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
|
||||||
|
use App\RSpade\Core\Models\User_Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* _AUTO_GENERATED_
|
||||||
|
* @property integer $id
|
||||||
|
* @property integer $user_id
|
||||||
|
* @property integer $permission_id
|
||||||
|
* @property boolean $is_grant
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
class User_Permission_Model extends Rsx_Model_Abstract
|
||||||
|
{
|
||||||
|
protected $table = 'user_permissions';
|
||||||
|
protected $fillable = []; // No mass assignment - always explicit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum field definitions (none for this simple model)
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public static $enums = [];
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// STATIC MANAGEMENT METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant a permission to a user
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID
|
||||||
|
* @param int $permission_id Permission constant
|
||||||
|
* @return User_Permission_Model
|
||||||
|
*/
|
||||||
|
public static function grant(int $user_id, int $permission_id): User_Permission_Model
|
||||||
|
{
|
||||||
|
// Remove any existing entry first (could be DENY)
|
||||||
|
static::where('user_id', $user_id)
|
||||||
|
->where('permission_id', $permission_id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$perm = new static();
|
||||||
|
$perm->user_id = $user_id;
|
||||||
|
$perm->permission_id = $permission_id;
|
||||||
|
$perm->is_grant = true;
|
||||||
|
$perm->save();
|
||||||
|
|
||||||
|
// Clear cache on user if loaded
|
||||||
|
static::_clear_user_cache($user_id);
|
||||||
|
|
||||||
|
return $perm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deny a permission to a user
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID
|
||||||
|
* @param int $permission_id Permission constant
|
||||||
|
* @return User_Permission_Model
|
||||||
|
*/
|
||||||
|
public static function deny(int $user_id, int $permission_id): User_Permission_Model
|
||||||
|
{
|
||||||
|
// Remove any existing entry first (could be GRANT)
|
||||||
|
static::where('user_id', $user_id)
|
||||||
|
->where('permission_id', $permission_id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$perm = new static();
|
||||||
|
$perm->user_id = $user_id;
|
||||||
|
$perm->permission_id = $permission_id;
|
||||||
|
$perm->is_grant = false;
|
||||||
|
$perm->save();
|
||||||
|
|
||||||
|
// Clear cache on user if loaded
|
||||||
|
static::_clear_user_cache($user_id);
|
||||||
|
|
||||||
|
return $perm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a supplementary permission (revert to role default)
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID
|
||||||
|
* @param int $permission_id Permission constant
|
||||||
|
* @return bool True if removed, false if not found
|
||||||
|
*/
|
||||||
|
public static function remove(int $user_id, int $permission_id): bool
|
||||||
|
{
|
||||||
|
$deleted = static::where('user_id', $user_id)
|
||||||
|
->where('permission_id', $permission_id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
// Clear cache on user if loaded
|
||||||
|
static::_clear_user_cache($user_id);
|
||||||
|
|
||||||
|
return $deleted > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all supplementary permissions for a user
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID
|
||||||
|
* @return array ['grants' => [permission_ids], 'denies' => [permission_ids]]
|
||||||
|
*/
|
||||||
|
public static function for_user(int $user_id): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'grants' => [],
|
||||||
|
'denies' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$permissions = static::where('user_id', $user_id)->get();
|
||||||
|
|
||||||
|
foreach ($permissions as $perm) {
|
||||||
|
if ($perm->is_grant) {
|
||||||
|
$result['grants'][] = $perm->permission_id;
|
||||||
|
} else {
|
||||||
|
$result['denies'][] = $perm->permission_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear user's supplementary permission cache
|
||||||
|
*
|
||||||
|
* @param int $user_id User ID
|
||||||
|
*/
|
||||||
|
protected static function _clear_user_cache(int $user_id): void
|
||||||
|
{
|
||||||
|
// Find user instance and clear cache if it exists
|
||||||
|
// This is best-effort - the user might not be loaded
|
||||||
|
// Permission checks will reload from DB on next access
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// RELATIONSHIPS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Relationship]
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User_Model::class, 'user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
3
app/RSpade/Core/SPA/Default_Layout.jqhtml
Executable file
3
app/RSpade/Core/SPA/Default_Layout.jqhtml
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
<Define:Default_Layout>
|
||||||
|
<div $sid="content"></div>
|
||||||
|
</Define:Default_Layout>
|
||||||
9
app/RSpade/Core/SPA/Default_Layout.js
Executable file
9
app/RSpade/Core/SPA/Default_Layout.js
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Default_Layout - Minimal passthrough layout for actions without explicit @layout
|
||||||
|
*
|
||||||
|
* This layout provides the required $sid="content" element but adds no visual structure.
|
||||||
|
* Used when an action doesn't specify any @layout decorator.
|
||||||
|
*/
|
||||||
|
class Default_Layout extends Spa_Layout {
|
||||||
|
// No custom behavior - just provides the content container
|
||||||
|
}
|
||||||
@@ -192,7 +192,10 @@ class Spa {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Match URL to a route and extract parameters
|
* Match URL to a route and extract parameters
|
||||||
* Returns: { action_class, args, layout } or null
|
* Returns: { action_class, args, layouts } or null
|
||||||
|
*
|
||||||
|
* layouts is an array of layout class names for sublayout chain support.
|
||||||
|
* First element = outermost layout, last = innermost (closest to action).
|
||||||
*/
|
*/
|
||||||
static match_url_to_route(url) {
|
static match_url_to_route(url) {
|
||||||
// Parse URL to get path and query params
|
// Parse URL to get path and query params
|
||||||
@@ -210,10 +213,11 @@ class Spa {
|
|||||||
// Try exact match first
|
// Try exact match first
|
||||||
const exact_pattern = '/' + path;
|
const exact_pattern = '/' + path;
|
||||||
if (Spa.routes[exact_pattern]) {
|
if (Spa.routes[exact_pattern]) {
|
||||||
|
const action_class = Spa.routes[exact_pattern];
|
||||||
return {
|
return {
|
||||||
action_class: Spa.routes[exact_pattern],
|
action_class: action_class,
|
||||||
args: parsed.query_params,
|
args: parsed.query_params,
|
||||||
layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout',
|
layouts: action_class._spa_layouts || ['Default_Layout'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,11 +230,12 @@ class Spa {
|
|||||||
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
|
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
|
||||||
// This matches the PHP Dispatcher behavior where route params override GET params
|
// This matches the PHP Dispatcher behavior where route params override GET params
|
||||||
const args = { ...parsed.query_params, ...match };
|
const args = { ...parsed.query_params, ...match };
|
||||||
|
const action_class = Spa.routes[pattern];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action_class: Spa.routes[pattern],
|
action_class: action_class,
|
||||||
args: args,
|
args: args,
|
||||||
layout: Spa.routes[pattern]._spa_layout || 'Default_Layout',
|
layouts: action_class._spa_layouts || ['Default_Layout'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,7 +598,7 @@ class Spa {
|
|||||||
console_debug('Spa', 'Route match:', {
|
console_debug('Spa', 'Route match:', {
|
||||||
action_class: route_match?.action_class?.name,
|
action_class: route_match?.action_class?.name,
|
||||||
args: route_match?.args,
|
args: route_match?.args,
|
||||||
layout: route_match?.layout,
|
layouts: route_match?.layouts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if action's @spa() attribute matches current SPA bootstrap
|
// Check if action's @spa() attribute matches current SPA bootstrap
|
||||||
@@ -647,8 +652,8 @@ class Spa {
|
|||||||
Spa.path = parsed.path;
|
Spa.path = parsed.path;
|
||||||
Spa.params = route_match.args;
|
Spa.params = route_match.args;
|
||||||
|
|
||||||
// Get layout name and action info
|
// Get layout chain and action info
|
||||||
const layout_name = route_match.layout;
|
const target_layouts = route_match.layouts;
|
||||||
const action_class = route_match.action_class;
|
const action_class = route_match.action_class;
|
||||||
const action_name = action_class.name;
|
const action_name = action_class.name;
|
||||||
|
|
||||||
@@ -658,43 +663,12 @@ class Spa {
|
|||||||
path: parsed.path,
|
path: parsed.path,
|
||||||
params: route_match.args,
|
params: route_match.args,
|
||||||
action: action_name,
|
action: action_name,
|
||||||
layout: layout_name,
|
layouts: target_layouts,
|
||||||
history_mode: opts.history
|
history_mode: opts.history
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if we need a new layout
|
// Resolve layout chain - find divergence point and reuse matching layouts
|
||||||
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
|
await Spa._resolve_layout_chain(target_layouts, action_name, route_match.args, url);
|
||||||
// Stop old layout if exists (auto-stops children)
|
|
||||||
if (Spa.layout) {
|
|
||||||
await Spa.layout.trigger('unload');
|
|
||||||
Spa.layout.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear spa-root and create new layout
|
|
||||||
// Note: We target #spa-root instead of body to preserve global UI containers
|
|
||||||
// (Flash_Alert, modals, tooltips, etc. that append to body)
|
|
||||||
const $spa_root = $('#spa-root');
|
|
||||||
if (!$spa_root.length) {
|
|
||||||
throw new Error('[Spa] #spa-root element not found - check Spa_App.blade.php');
|
|
||||||
}
|
|
||||||
$spa_root.empty();
|
|
||||||
$spa_root.attr('class', '');
|
|
||||||
|
|
||||||
// Create layout using component system
|
|
||||||
Spa.layout = $spa_root.component(layout_name, {}).component();
|
|
||||||
|
|
||||||
// Wait for layout to be ready
|
|
||||||
await Spa.layout.ready();
|
|
||||||
|
|
||||||
console_debug('Spa', `Created layout: ${layout_name}`);
|
|
||||||
} else {
|
|
||||||
// Wait for layout to finish previous action if still loading
|
|
||||||
await Spa.layout.ready();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell layout to run the action
|
|
||||||
Spa.layout._set_action(action_name, route_match.args, url);
|
|
||||||
await Spa.layout._run_action();
|
|
||||||
|
|
||||||
// Scroll Restoration #1: Immediate (after action starts)
|
// Scroll Restoration #1: Immediate (after action starts)
|
||||||
// This occurs synchronously after the action component is created
|
// This occurs synchronously after the action component is created
|
||||||
@@ -722,7 +696,7 @@ class Spa {
|
|||||||
// making the target scroll position accessible. The first restoration happens before
|
// making the target scroll position accessible. The first restoration happens before
|
||||||
// this content renders, so we need a second attempt after the page is fully ready.
|
// this content renders, so we need a second attempt after the page is fully ready.
|
||||||
|
|
||||||
console_debug('Spa', `Rendered ${action_name} in ${layout_name}`);
|
console_debug('Spa', `Rendered ${action_name} with ${target_layouts.length} layout(s)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Spa] Dispatch error:', error);
|
console.error('[Spa] Dispatch error:', error);
|
||||||
// TODO: Better error handling - show error UI to user
|
// TODO: Better error handling - show error UI to user
|
||||||
@@ -744,6 +718,189 @@ class Spa {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve layout chain - find divergence point and reuse matching layouts
|
||||||
|
*
|
||||||
|
* Walks down from the top-level layout comparing current DOM chain to target chain.
|
||||||
|
* Reuses layouts that match, destroys from the first mismatch point down,
|
||||||
|
* then creates new layouts/action from that point.
|
||||||
|
*
|
||||||
|
* @param {string[]} target_layouts - Array of layout class names (outermost first)
|
||||||
|
* @param {string} action_name - The action class name to render at the bottom
|
||||||
|
* @param {object} args - URL parameters for the action
|
||||||
|
* @param {string} url - The full URL being navigated to
|
||||||
|
*/
|
||||||
|
static async _resolve_layout_chain(target_layouts, action_name, args, url) {
|
||||||
|
// Build target chain: layouts + action at the end
|
||||||
|
const target_chain = [...target_layouts, action_name];
|
||||||
|
|
||||||
|
console_debug('Spa', 'Resolving layout chain:', target_chain);
|
||||||
|
|
||||||
|
// Find the divergence point by walking the current DOM
|
||||||
|
let $current_container = $('#spa-root');
|
||||||
|
if (!$current_container.length) {
|
||||||
|
throw new Error('[Spa] #spa-root element not found - check Spa_App.blade.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
let divergence_index = 0;
|
||||||
|
let reusable_layouts = [];
|
||||||
|
|
||||||
|
// Walk down current chain, checking each level against target
|
||||||
|
for (let i = 0; i < target_chain.length; i++) {
|
||||||
|
const target_name = target_chain[i];
|
||||||
|
const is_last = (i === target_chain.length - 1);
|
||||||
|
|
||||||
|
// Check if current container has a component with target class name
|
||||||
|
// jqhtml adds class names to component root elements automatically
|
||||||
|
const $existing = $current_container.children().first();
|
||||||
|
|
||||||
|
if ($existing.length && $existing.hasClass(target_name)) {
|
||||||
|
// Match found - can potentially reuse this level
|
||||||
|
const existing_component = $existing.component();
|
||||||
|
|
||||||
|
if (is_last) {
|
||||||
|
// This is the action level - actions are never reused, always replaced
|
||||||
|
divergence_index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a layout level - reuse it
|
||||||
|
reusable_layouts.push(existing_component);
|
||||||
|
divergence_index = i + 1;
|
||||||
|
|
||||||
|
// Move to next level - look in this layout's $content
|
||||||
|
const $content = existing_component.$sid ? existing_component.$sid('content') : null;
|
||||||
|
if (!$content || !$content.length) {
|
||||||
|
// Layout doesn't have content area - can't go deeper
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$current_container = $content;
|
||||||
|
} else {
|
||||||
|
// No match - divergence point found
|
||||||
|
divergence_index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console_debug('Spa', `Divergence at index ${divergence_index}, reusing ${reusable_layouts.length} layouts`);
|
||||||
|
|
||||||
|
// Destroy everything from divergence point down
|
||||||
|
if (divergence_index === 0) {
|
||||||
|
// Complete replacement - destroy top-level layout
|
||||||
|
if (Spa.layout) {
|
||||||
|
await Spa.layout.trigger('unload');
|
||||||
|
Spa.layout.stop();
|
||||||
|
Spa.layout = null;
|
||||||
|
}
|
||||||
|
$current_container = $('#spa-root');
|
||||||
|
$current_container.empty();
|
||||||
|
Spa._clear_container_attributes($current_container);
|
||||||
|
} else {
|
||||||
|
// Partial replacement - clear from the reusable layout's $content
|
||||||
|
const last_reusable = reusable_layouts[reusable_layouts.length - 1];
|
||||||
|
$current_container = last_reusable.$sid('content');
|
||||||
|
$current_container.empty();
|
||||||
|
Spa._clear_container_attributes($current_container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new layouts/action from divergence point
|
||||||
|
for (let i = divergence_index; i < target_chain.length; i++) {
|
||||||
|
const component_name = target_chain[i];
|
||||||
|
const is_last = (i === target_chain.length - 1);
|
||||||
|
|
||||||
|
console_debug('Spa', `Creating ${is_last ? 'action' : 'layout'}: ${component_name}`);
|
||||||
|
|
||||||
|
// Create component
|
||||||
|
const component = $current_container.component(component_name, is_last ? args : {}).component();
|
||||||
|
|
||||||
|
// Wait for it to be ready
|
||||||
|
await component.ready();
|
||||||
|
|
||||||
|
if (is_last) {
|
||||||
|
// This is the action
|
||||||
|
Spa.action = component;
|
||||||
|
} else {
|
||||||
|
// This is a layout
|
||||||
|
if (i === 0) {
|
||||||
|
// Top-level layout
|
||||||
|
Spa.layout = component;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move container to this layout's $content for next iteration
|
||||||
|
$current_container = component.$sid('content');
|
||||||
|
if (!$current_container || !$current_container.length) {
|
||||||
|
throw new Error(`[Spa] Layout ${component_name} must have an element with $sid="content"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate on_action to all layouts in the chain
|
||||||
|
// All layouts receive the same action info (final action's url, name, args)
|
||||||
|
const layouts_for_on_action = Spa._collect_all_layouts();
|
||||||
|
|
||||||
|
for (const layout of layouts_for_on_action) {
|
||||||
|
// Set action reference before calling on_action so layouts can access it
|
||||||
|
layout.action = Spa.action;
|
||||||
|
if (layout.on_action) {
|
||||||
|
layout.on_action(url, action_name, args);
|
||||||
|
}
|
||||||
|
layout.trigger('action');
|
||||||
|
}
|
||||||
|
|
||||||
|
console_debug('Spa', `Rendered ${action_name} with ${target_layouts.length} layout(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all layouts from Spa.layout down through nested $content elements
|
||||||
|
* @returns {Array} Array of layout instances from top to bottom
|
||||||
|
*/
|
||||||
|
static _collect_all_layouts() {
|
||||||
|
const layouts = [];
|
||||||
|
let current = Spa.layout;
|
||||||
|
|
||||||
|
while (current && current instanceof Spa_Layout) {
|
||||||
|
layouts.push(current);
|
||||||
|
|
||||||
|
// Look for nested layout in $content
|
||||||
|
const $content = current.$sid ? current.$sid('content') : null;
|
||||||
|
if (!$content || !$content.length) break;
|
||||||
|
|
||||||
|
const $child = $content.children().first();
|
||||||
|
if (!$child.length) break;
|
||||||
|
|
||||||
|
const child_component = $child.component();
|
||||||
|
if (child_component && child_component instanceof Spa_Layout) {
|
||||||
|
current = child_component;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layouts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all attributes except id from a container element
|
||||||
|
* Called before loading new content to ensure clean state
|
||||||
|
* @param {jQuery} $container - The container element to clear
|
||||||
|
*/
|
||||||
|
static _clear_container_attributes($container) {
|
||||||
|
if (!$container || !$container.length) return;
|
||||||
|
|
||||||
|
const el = $container[0];
|
||||||
|
const attrs_to_remove = [];
|
||||||
|
|
||||||
|
for (const attr of el.attributes) {
|
||||||
|
if (attr.name !== 'id' && attr.name !== 'data-id') {
|
||||||
|
attrs_to_remove.push(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attr_name of attrs_to_remove) {
|
||||||
|
el.removeAttribute(attr_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fatal error when trying to navigate to unknown route on current URL
|
* Fatal error when trying to navigate to unknown route on current URL
|
||||||
* This shouldn't happen - prevents infinite redirect loops
|
* This shouldn't happen - prevents infinite redirect loops
|
||||||
|
|||||||
@@ -30,16 +30,26 @@ function route(pattern) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @decorator
|
* @decorator
|
||||||
* Define which layout this action renders within
|
* Define which layout(s) this action renders within
|
||||||
|
*
|
||||||
|
* Multiple @layout decorators create a chain of nested layouts (sublayouts).
|
||||||
|
* First decorator = outermost layout, subsequent = progressively nested.
|
||||||
|
* Each layout persists independently during navigation if unchanged.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* @layout('Frontend_Layout')
|
* @layout('Frontend_Layout') // Outermost (header/footer)
|
||||||
* class Contacts_Index_Action extends Spa_Action { }
|
* @layout('Settings_Layout') // Nested inside Frontend_Layout
|
||||||
|
* class Settings_Profile_Action extends Spa_Action { }
|
||||||
*/
|
*/
|
||||||
function layout(layout_name) {
|
function layout(layout_name) {
|
||||||
return function (target) {
|
return function (target) {
|
||||||
// Store layout name on the class
|
// Store layout names as array for sublayout chain support
|
||||||
target._spa_layout = layout_name;
|
// Use unshift because decorators execute bottom-up, so we prepend
|
||||||
|
// each layout to get correct order (outermost first in array)
|
||||||
|
if (!target._spa_layouts) {
|
||||||
|
target._spa_layouts = [];
|
||||||
|
}
|
||||||
|
target._spa_layouts.unshift(layout_name);
|
||||||
return target;
|
return target;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Spa_Layout - Base class for Spa layouts
|
* Spa_Layout - Base class for Spa layouts
|
||||||
*
|
*
|
||||||
* Layouts provide the persistent wrapper (header, nav, footer) around actions.
|
* Layouts provide the persistent wrapper (header, nav, footer) around actions or sublayouts.
|
||||||
* They render directly to body and contain a content area where actions render.
|
* They render to their parent's $content area and contain their own $content for children.
|
||||||
|
*
|
||||||
|
* Sublayouts: Multiple @layout decorators create a chain of nested layouts.
|
||||||
|
* Each layout persists independently - if navigating between pages with the same
|
||||||
|
* outer layout but different inner layouts, only the differing parts are recreated.
|
||||||
*
|
*
|
||||||
* Requirements:
|
* Requirements:
|
||||||
* - Must have an element with $id="content" where actions will render
|
* - Must have an element with $sid="content" where children (sublayouts or actions) render
|
||||||
* - Persists across action navigations (only re-created when layout changes)
|
* - Persists across navigations as long as it remains in the layout chain
|
||||||
*
|
*
|
||||||
* Lifecycle events triggered for actions:
|
* Properties set by Spa:
|
||||||
* - before_action_init, action_init
|
* - this.action - Reference to the current action (bottom of chain)
|
||||||
* - before_action_render, action_render
|
|
||||||
* - before_action_ready, action_ready
|
|
||||||
*
|
*
|
||||||
* Hook methods that can be overridden:
|
* Hook methods that can be overridden:
|
||||||
* - on_action(url, action_name, args) - Called when new action is set
|
* - on_action(url, action_name, args) - Called when any action is dispatched
|
||||||
|
* All layouts in the chain receive this with the final action's info.
|
||||||
*/
|
*/
|
||||||
class Spa_Layout extends Component {
|
class Spa_Layout extends Component {
|
||||||
on_create() {
|
on_create() {
|
||||||
console.log('Layout create!');
|
console_debug('Spa_Layout', `${this.constructor.name} created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,133 +78,6 @@ class Spa_Layout extends Component {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set which action should be rendered
|
|
||||||
* Called by Spa.dispatch() - stores action info for _run_action()
|
|
||||||
*
|
|
||||||
* @param {string} action_name - Name of the action class
|
|
||||||
* @param {object} args - URL parameters and query params
|
|
||||||
* @param {string} url - The full URL being dispatched to
|
|
||||||
*/
|
|
||||||
_set_action(action_name, args, url) {
|
|
||||||
this._pending_action_name = action_name;
|
|
||||||
this._pending_action_args = args;
|
|
||||||
this._pending_action_url = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the pending action - stop old action, create new one
|
|
||||||
* Called by Spa.dispatch() after _set_action()
|
|
||||||
*/
|
|
||||||
async _run_action() {
|
|
||||||
const action_name = this._pending_action_name;
|
|
||||||
const args = this._pending_action_args;
|
|
||||||
const url = this._pending_action_url;
|
|
||||||
|
|
||||||
// Get content container
|
|
||||||
console.log('[Spa_Layout] Looking for content element...');
|
|
||||||
console.log('[Spa_Layout] this.$id available?', typeof this.$id);
|
|
||||||
console.log('[Spa_Layout] this.$ exists?', !!this.$);
|
|
||||||
|
|
||||||
const $content = this.$content();
|
|
||||||
console.log('[Spa_Layout] $content result:', $content);
|
|
||||||
console.log('[Spa_Layout] $content.length:', $content?.length);
|
|
||||||
|
|
||||||
if (!$content || !$content.length) {
|
|
||||||
// TODO: Better error handling - show error UI instead of just console
|
|
||||||
console.error(`[Spa_Layout] Layout ${this.constructor.name} must have an element with $id="content"`);
|
|
||||||
console.error(
|
|
||||||
'[Spa_Layout] Available elements in this.$:',
|
|
||||||
this.$.find('[data-id]')
|
|
||||||
.toArray()
|
|
||||||
.map((el) => el.getAttribute('data-id'))
|
|
||||||
);
|
|
||||||
throw new Error(`Layout ${this.constructor.name} must have an element with $id="content"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop old action (jqhtml auto-stops when .component() replaces)
|
|
||||||
// Clear content area
|
|
||||||
$content.empty();
|
|
||||||
|
|
||||||
// Mark action as loading for exception display logic
|
|
||||||
this._action_is_loading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the action class to check for @title decorator
|
|
||||||
const action_class = Manifest.get_class_by_name(action_name);
|
|
||||||
|
|
||||||
// Update page title if @title decorator is present (optional), clear if not
|
|
||||||
if (action_class._spa_title) {
|
|
||||||
document.title = action_class._spa_title;
|
|
||||||
} else {
|
|
||||||
document.title = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new action component
|
|
||||||
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
|
|
||||||
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
|
|
||||||
|
|
||||||
console.warn(args);
|
|
||||||
|
|
||||||
const action = $content.component(action_name, args).component();
|
|
||||||
|
|
||||||
// Store reference
|
|
||||||
Spa.action = action;
|
|
||||||
this.action = action;
|
|
||||||
|
|
||||||
// Call on_action hook (can be overridden by subclasses)
|
|
||||||
this.on_action(url, action_name, args);
|
|
||||||
this.trigger('action');
|
|
||||||
|
|
||||||
// Setup event forwarding from action to layout
|
|
||||||
// Action triggers 'init' -> Layout triggers 'action_init'
|
|
||||||
this._setup_action_events(action);
|
|
||||||
|
|
||||||
// Wait for action to be ready
|
|
||||||
await action.ready();
|
|
||||||
|
|
||||||
// Mark action as done loading
|
|
||||||
this._action_is_loading = false;
|
|
||||||
} catch (error) {
|
|
||||||
// Mark action as done loading (even though it failed)
|
|
||||||
this._action_is_loading = false;
|
|
||||||
|
|
||||||
// Action lifecycle failed - log and trigger event
|
|
||||||
console.error('[Spa_Layout] Action lifecycle failed:', error);
|
|
||||||
|
|
||||||
// Disable SPA so forward navigation becomes full page loads
|
|
||||||
// (Back/forward still work as SPA to allow user to navigate away)
|
|
||||||
if (typeof Spa !== 'undefined') {
|
|
||||||
Spa.disable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger global exception event
|
|
||||||
// Exception_Handler will decide how to display (layout or flash alert)
|
|
||||||
// Pass payload with exception (no meta.source = already logged above)
|
|
||||||
if (typeof Rsx !== 'undefined') {
|
|
||||||
Rsx.trigger('unhandled_exception', { exception: error, meta: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't re-throw - allow navigation to continue working
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup event listeners on action to forward to layout
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_setup_action_events(action) {
|
|
||||||
const events = ['before_init', 'init', 'before_render', 'render', 'before_ready', 'ready'];
|
|
||||||
|
|
||||||
events.forEach((event) => {
|
|
||||||
action.on(event, () => {
|
|
||||||
// Trigger corresponding layout event with 'action_' prefix
|
|
||||||
const layout_event = event.replace('before_', 'before_action_').replace(/^(?!before)/, 'action_');
|
|
||||||
this.trigger(layout_event, action);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook called when a new action is set
|
* Hook called when a new action is set
|
||||||
* Override this in subclasses to react to action changes.
|
* Override this in subclasses to react to action changes.
|
||||||
@@ -223,6 +99,6 @@ class Spa_Layout extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
on_ready() {
|
on_ready() {
|
||||||
console.log('layout ready!');
|
console_debug('Spa_Layout', `${this.constructor.name} ready`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,15 +135,6 @@ class Session extends Rsx_System_Model_Abstract
|
|||||||
return php_sapi_name() === 'cli';
|
return php_sapi_name() === 'cli';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if requester is a playwright fpc client
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private static function __is_fpc_client(): bool
|
|
||||||
{
|
|
||||||
return isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize session from cookie or request
|
* Initialize session from cookie or request
|
||||||
* Loads existing session but does not create new one
|
* Loads existing session but does not create new one
|
||||||
@@ -159,7 +150,7 @@ class Session extends Rsx_System_Model_Abstract
|
|||||||
self::$_has_init = true;
|
self::$_has_init = true;
|
||||||
|
|
||||||
// CLI mode: do nothing
|
// CLI mode: do nothing
|
||||||
if (self::__is_cli() || self::__is_fpc_client()) {
|
if (self::__is_cli()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,8 +206,8 @@ class Session extends Rsx_System_Model_Abstract
|
|||||||
}
|
}
|
||||||
self::$_has_activate = true;
|
self::$_has_activate = true;
|
||||||
|
|
||||||
// CLI & FPC mode: do nothing
|
// CLI mode: do nothing
|
||||||
if (self::__is_cli() || self::__is_fpc_client()) {
|
if (self::__is_cli()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,33 @@ use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
|||||||
*/
|
*/
|
||||||
class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Authentication check - requires developer role or CLI access
|
||||||
|
*
|
||||||
|
* This is a dev tool that generates test data. Access is restricted to:
|
||||||
|
* 1. CLI users (artisan commands, tests)
|
||||||
|
* 2. Authenticated users with ROLE_DEVELOPER
|
||||||
|
*/
|
||||||
|
public static function pre_dispatch(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// Allow CLI access (artisan commands, tests)
|
||||||
|
if (app()->runningInConsole()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
if (!\App\RSpade\Core\Session\Session::is_logged_in()) {
|
||||||
|
return response_unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require developer role
|
||||||
|
if (!Permission::has_role(\App\RSpade\Core\Models\User_Model::ROLE_DEVELOPER)) {
|
||||||
|
return response_unauthorized('Developer access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static $first_names = null;
|
private static $first_names = null;
|
||||||
private static $last_names = null;
|
private static $last_names = null;
|
||||||
private static $cities = null;
|
private static $cities = null;
|
||||||
|
|||||||
@@ -1300,6 +1300,70 @@ function handle_resolve_class_service($data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final fallback: Check for Base_* model stubs (auto-generated at bundle time)
|
||||||
|
// These don't exist as JS classes but their corresponding PHP models do
|
||||||
|
if (str_starts_with($identifier, 'Base_')) {
|
||||||
|
$model_name = substr($identifier, 5); // Strip 'Base_' prefix
|
||||||
|
$model_data = $find_php_class($model_name);
|
||||||
|
|
||||||
|
if ($model_data) {
|
||||||
|
// Check if this is a subclass of Rsx_Model_Abstract
|
||||||
|
$is_model = false;
|
||||||
|
$current_class = $model_name;
|
||||||
|
$visited = [];
|
||||||
|
|
||||||
|
while ($current_class) {
|
||||||
|
if (in_array($current_class, $visited)) break;
|
||||||
|
$visited[] = $current_class;
|
||||||
|
|
||||||
|
$class_data = $find_php_class($current_class);
|
||||||
|
if (!$class_data) break;
|
||||||
|
|
||||||
|
$extends = $class_data['extends'] ?? null;
|
||||||
|
if (!$extends) break;
|
||||||
|
|
||||||
|
// Normalize extends (strip namespace)
|
||||||
|
$extends_parts = explode('\\', $extends);
|
||||||
|
$extends_simple = end($extends_parts);
|
||||||
|
|
||||||
|
if ($extends_simple === 'Rsx_Model_Abstract') {
|
||||||
|
$is_model = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current_class = $extends_simple;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($is_model) {
|
||||||
|
$file_path = $model_data['file'];
|
||||||
|
$line_number = 1;
|
||||||
|
$absolute_path = IDE_BASE_PATH . '/' . $file_path;
|
||||||
|
|
||||||
|
// Try to find the fetch method line number if it exists
|
||||||
|
if (file_exists($absolute_path)) {
|
||||||
|
$content = file_get_contents($absolute_path);
|
||||||
|
$lines = explode("\n", $content);
|
||||||
|
|
||||||
|
foreach ($lines as $index => $line) {
|
||||||
|
if (preg_match('/^\s*(public\s+)?(static\s+)?function\s+fetch\s*\(/', $line)) {
|
||||||
|
$line_number = $index + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response([
|
||||||
|
'found' => true,
|
||||||
|
'type' => 'base_model_stub',
|
||||||
|
'file' => $file_path,
|
||||||
|
'line' => $line_number,
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'resolved_model' => $model_name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Nothing found after trying all types
|
// Nothing found after trying all types
|
||||||
json_response([
|
json_response([
|
||||||
'found' => false,
|
'found' => false,
|
||||||
|
|||||||
@@ -172,7 +172,6 @@ class Jqhtml_Integration {
|
|||||||
if (is_top_level) {
|
if (is_top_level) {
|
||||||
(async () => {
|
(async () => {
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
await Rsx._rsx_call_all_classes('on_jqhtml_ready');
|
|
||||||
Rsx.trigger('jqhtml_ready');
|
Rsx.trigger('jqhtml_ready');
|
||||||
})();
|
})();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
top: 60px;
|
top: 60px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 9999;
|
z-index: 1200; // Flash alerts layer - see rsx:man zindex
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center; // Center alerts horizontally
|
align-items: center; // Center alerts horizontally
|
||||||
|
|||||||
@@ -1071,6 +1071,42 @@ function response_error(string $error_code, $metadata = null)
|
|||||||
return new \App\RSpade\Core\Response\Error_Response($error_code, $metadata);
|
return new \App\RSpade\Core\Response\Error_Response($error_code, $metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an unauthorized error response
|
||||||
|
*
|
||||||
|
* Context-aware response:
|
||||||
|
* - Ajax requests: Returns JSON {success: false, error_code: 'unauthorized'}
|
||||||
|
* - Web requests (not logged in): Eventually redirects to login (see wishlist 1.7.1)
|
||||||
|
* - Web requests (logged in): Renders 403 error page or throws HttpException
|
||||||
|
*
|
||||||
|
* Use this when user is authenticated but lacks permission for an action,
|
||||||
|
* OR when user is not authenticated and needs to be.
|
||||||
|
*
|
||||||
|
* @param string|null $message Custom error message (optional)
|
||||||
|
* @return \App\RSpade\Core\Response\Error_Response
|
||||||
|
*/
|
||||||
|
function response_unauthorized(?string $message = null)
|
||||||
|
{
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a not found error response
|
||||||
|
*
|
||||||
|
* Context-aware response:
|
||||||
|
* - Ajax requests: Returns JSON {success: false, error_code: 'not_found'}
|
||||||
|
* - Web requests: Renders 404 error page or throws HttpException
|
||||||
|
*
|
||||||
|
* Use this when a requested resource does not exist.
|
||||||
|
*
|
||||||
|
* @param string|null $message Custom error message (optional)
|
||||||
|
* @return \App\RSpade\Core\Response\Error_Response
|
||||||
|
*/
|
||||||
|
function response_not_found(?string $message = null)
|
||||||
|
{
|
||||||
|
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, $message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current request is from a loopback IP address
|
* Check if the current request is from a loopback IP address
|
||||||
*
|
*
|
||||||
|
|||||||
598
app/RSpade/man/acls.txt
Executable file
598
app/RSpade/man/acls.txt
Executable file
@@ -0,0 +1,598 @@
|
|||||||
|
ACLS(7) RSpade Developer Manual ACLS(7)
|
||||||
|
|
||||||
|
NAME
|
||||||
|
acls - Role-based access control with supplementary permissions
|
||||||
|
|
||||||
|
SYNOPSIS
|
||||||
|
// Check if current user has a permission
|
||||||
|
Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)
|
||||||
|
|
||||||
|
// Check if current user has at least a certain role
|
||||||
|
Permission::has_role(User_Model::ROLE_SITE_ADMIN)
|
||||||
|
|
||||||
|
// Check on specific user instance
|
||||||
|
$user->has_permission(User_Model::PERM_EDIT_DATA)
|
||||||
|
|
||||||
|
// Check if user can administer another user's role
|
||||||
|
$user->can_admin_role($target_user->role_id)
|
||||||
|
|
||||||
|
DESCRIPTION
|
||||||
|
RSpade provides a role-based access control (RBAC) system where:
|
||||||
|
|
||||||
|
1. Users have a primary role on their site membership (users.role_id)
|
||||||
|
2. Roles grant a predefined set of permissions
|
||||||
|
3. Supplementary permissions can GRANT or DENY specific permissions per-user
|
||||||
|
4. Permission checks resolve: role grants → DENY override → GRANT override
|
||||||
|
|
||||||
|
Key design principles:
|
||||||
|
|
||||||
|
Identity vs Membership
|
||||||
|
login_users = identity (email, password, one per person)
|
||||||
|
users = site membership (role, permissions, many per login_user)
|
||||||
|
|
||||||
|
Roles and permissions attach to site memberships (users table),
|
||||||
|
not login identities. One person can have different roles on
|
||||||
|
different sites.
|
||||||
|
|
||||||
|
Integer Constants
|
||||||
|
All roles and permissions are integer constants, not strings.
|
||||||
|
This provides type safety, IDE autocompletion, and works with
|
||||||
|
the RSX enum system for magic properties.
|
||||||
|
|
||||||
|
Hierarchical Roles
|
||||||
|
Roles are hierarchical. Higher roles inherit all permissions
|
||||||
|
from lower roles. Role IDs are ordered by privilege level
|
||||||
|
(lower ID = more privilege).
|
||||||
|
|
||||||
|
Supplementary Permissions
|
||||||
|
Individual users can have permissions granted or denied beyond
|
||||||
|
their role defaults. DENY always wins over role grants. GRANT
|
||||||
|
adds permissions the role doesn't provide.
|
||||||
|
|
||||||
|
ARCHITECTURE
|
||||||
|
|
||||||
|
Database Tables
|
||||||
|
|
||||||
|
users (site membership)
|
||||||
|
role_id Primary role for this site membership
|
||||||
|
... Other membership fields
|
||||||
|
|
||||||
|
user_permissions (supplementary)
|
||||||
|
user_id FK to users
|
||||||
|
permission_id Which permission constant
|
||||||
|
is_grant 1 = GRANT, 0 = DENY
|
||||||
|
|
||||||
|
Permission Resolution Order
|
||||||
|
|
||||||
|
1. Check if user role is DISABLED → deny all
|
||||||
|
2. Check user_permissions for explicit DENY → deny if found
|
||||||
|
3. Check user_permissions for explicit GRANT → allow if found
|
||||||
|
4. Check role's default permissions → allow if included
|
||||||
|
5. Deny (permission not granted)
|
||||||
|
|
||||||
|
Role Hierarchy
|
||||||
|
|
||||||
|
ID Constant Label Can Admin Roles
|
||||||
|
-- -------- ----- ---------------
|
||||||
|
1 ROLE_ROOT_ADMIN Root Admin 2,3,4,5,6,7
|
||||||
|
2 ROLE_SITE_OWNER Site Owner 3,4,5,6,7
|
||||||
|
3 ROLE_SITE_ADMIN Site Admin 4,5,6,7
|
||||||
|
4 ROLE_MANAGER Manager 5,6,7
|
||||||
|
5 ROLE_USER User (none)
|
||||||
|
6 ROLE_VIEWER Viewer (none)
|
||||||
|
7 ROLE_DISABLED Disabled (none)
|
||||||
|
|
||||||
|
"Can Admin Roles" means a user with that role can create, edit,
|
||||||
|
or change the role of users with the listed role IDs. This
|
||||||
|
prevents privilege escalation (admin can't create root admin).
|
||||||
|
|
||||||
|
PERMISSIONS
|
||||||
|
|
||||||
|
Core Permissions (granted by role)
|
||||||
|
|
||||||
|
ID Constant Granted By Default To
|
||||||
|
-- -------- ---------------------
|
||||||
|
1 PERM_MANAGE_SITES_ROOT Root Admin only
|
||||||
|
2 PERM_MANAGE_SITE_BILLING Site Owner+
|
||||||
|
3 PERM_MANAGE_SITE_SETTINGS Site Admin+
|
||||||
|
4 PERM_MANAGE_SITE_USERS Site Admin+
|
||||||
|
5 PERM_VIEW_USER_ACTIVITY Manager+
|
||||||
|
6 PERM_EDIT_DATA User+
|
||||||
|
7 PERM_VIEW_DATA Viewer+
|
||||||
|
|
||||||
|
Supplementary Permissions (not granted by any role by default)
|
||||||
|
|
||||||
|
ID Constant Purpose
|
||||||
|
-- -------- -------
|
||||||
|
8 PERM_API_ACCESS Allow API key creation/usage
|
||||||
|
9 PERM_DATA_EXPORT Allow bulk data export
|
||||||
|
|
||||||
|
Role-Permission Matrix
|
||||||
|
|
||||||
|
Permission Root Owner Admin Mgr User View Dis
|
||||||
|
---------- ---- ----- ----- --- ---- ---- ---
|
||||||
|
MANAGE_SITES_ROOT X
|
||||||
|
MANAGE_SITE_BILLING X X
|
||||||
|
MANAGE_SITE_SETTINGS X X X
|
||||||
|
MANAGE_SITE_USERS X X X
|
||||||
|
VIEW_USER_ACTIVITY X X X X
|
||||||
|
EDIT_DATA X X X X X
|
||||||
|
VIEW_DATA X X X X X X
|
||||||
|
API_ACCESS - - - - - -
|
||||||
|
DATA_EXPORT - - - - - -
|
||||||
|
|
||||||
|
Legend: X = granted by role, - = must be granted individually
|
||||||
|
|
||||||
|
MODEL IMPLEMENTATION
|
||||||
|
|
||||||
|
User_Model Definition
|
||||||
|
|
||||||
|
class User_Model extends Rsx_Model_Abstract
|
||||||
|
{
|
||||||
|
// Role constants
|
||||||
|
const ROLE_ROOT_ADMIN = 1;
|
||||||
|
const ROLE_SITE_OWNER = 2;
|
||||||
|
const ROLE_SITE_ADMIN = 3;
|
||||||
|
const ROLE_MANAGER = 4;
|
||||||
|
const ROLE_USER = 5;
|
||||||
|
const ROLE_VIEWER = 6;
|
||||||
|
const ROLE_DISABLED = 7;
|
||||||
|
|
||||||
|
// Permission constants
|
||||||
|
const PERM_MANAGE_SITES_ROOT = 1;
|
||||||
|
const PERM_MANAGE_SITE_BILLING = 2;
|
||||||
|
const PERM_MANAGE_SITE_SETTINGS = 3;
|
||||||
|
const PERM_MANAGE_SITE_USERS = 4;
|
||||||
|
const PERM_VIEW_USER_ACTIVITY = 5;
|
||||||
|
const PERM_EDIT_DATA = 6;
|
||||||
|
const PERM_VIEW_DATA = 7;
|
||||||
|
const PERM_API_ACCESS = 8;
|
||||||
|
const PERM_DATA_EXPORT = 9;
|
||||||
|
|
||||||
|
public static $enums = [
|
||||||
|
'role_id' => [
|
||||||
|
self::ROLE_ROOT_ADMIN => [
|
||||||
|
'constant' => 'ROLE_ROOT_ADMIN',
|
||||||
|
'label' => 'Root Admin',
|
||||||
|
'permissions' => [
|
||||||
|
self::PERM_MANAGE_SITES_ROOT,
|
||||||
|
self::PERM_MANAGE_SITE_BILLING,
|
||||||
|
self::PERM_MANAGE_SITE_SETTINGS,
|
||||||
|
self::PERM_MANAGE_SITE_USERS,
|
||||||
|
self::PERM_VIEW_USER_ACTIVITY,
|
||||||
|
self::PERM_EDIT_DATA,
|
||||||
|
self::PERM_VIEW_DATA,
|
||||||
|
],
|
||||||
|
'can_admin_roles' => [2,3,4,5,6,7],
|
||||||
|
],
|
||||||
|
// ... additional roles
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function has_permission(int $permission): bool
|
||||||
|
{
|
||||||
|
if ($this->role_id === self::ROLE_DISABLED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check supplementary DENY (overrides everything)
|
||||||
|
if ($this->has_supplementary_deny($permission)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check supplementary GRANT
|
||||||
|
if ($this->has_supplementary_grant($permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role default permissions
|
||||||
|
return in_array($permission, $this->role_permissions ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function can_admin_role(int $role_id): bool
|
||||||
|
{
|
||||||
|
return in_array($role_id, $this->role_can_admin_roles ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Magic Properties (via enum system)
|
||||||
|
|
||||||
|
$user->role_label // "Site Admin"
|
||||||
|
$user->role_permissions // [3,4,5,6,7]
|
||||||
|
$user->role_can_admin_roles // [4,5,6,7]
|
||||||
|
|
||||||
|
PERMISSION CLASS API
|
||||||
|
|
||||||
|
Static Methods (use current session user)
|
||||||
|
|
||||||
|
Permission::has_permission(int $permission): bool
|
||||||
|
Check if current logged-in user has permission.
|
||||||
|
Returns false if not logged in or no site selected.
|
||||||
|
|
||||||
|
if (Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) {
|
||||||
|
// Show user management UI
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission::has_role(int $role_id): bool
|
||||||
|
Check if current user has at least the specified role.
|
||||||
|
"At least" means same or higher privilege (lower role_id).
|
||||||
|
|
||||||
|
if (Permission::has_role(User_Model::ROLE_SITE_ADMIN)) {
|
||||||
|
// User is Site Admin or higher (Owner, Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission::get_user(): ?User_Model
|
||||||
|
Get current user's site membership record.
|
||||||
|
Returns null if not logged in or no site selected.
|
||||||
|
|
||||||
|
$user = Permission::get_user();
|
||||||
|
if ($user && $user->has_permission(User_Model::PERM_EDIT_DATA)) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
Instance Methods (on User_Model)
|
||||||
|
|
||||||
|
$user->has_permission(int $permission): bool
|
||||||
|
Check if this specific user has permission.
|
||||||
|
|
||||||
|
$user->can_admin_role(int $role_id): bool
|
||||||
|
Check if user can create/edit users with given role.
|
||||||
|
|
||||||
|
$user->has_supplementary_grant(int $permission): bool
|
||||||
|
Check if user has explicit GRANT for permission.
|
||||||
|
|
||||||
|
$user->has_supplementary_deny(int $permission): bool
|
||||||
|
Check if user has explicit DENY for permission.
|
||||||
|
|
||||||
|
ROUTE PROTECTION
|
||||||
|
|
||||||
|
Using #[Auth] Attribute
|
||||||
|
|
||||||
|
Standard permission checks work with #[Auth]:
|
||||||
|
|
||||||
|
#[Route('/settings/users')]
|
||||||
|
#[Auth('Permission::authenticated()')]
|
||||||
|
public static function users(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// Manual permission check inside route
|
||||||
|
if (!Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) {
|
||||||
|
return response_error(Ajax::ERROR_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
Custom Permission Methods
|
||||||
|
|
||||||
|
Define reusable permission checks in rsx/permission.php:
|
||||||
|
|
||||||
|
class Permission
|
||||||
|
{
|
||||||
|
public static function can_manage_users(): bool
|
||||||
|
{
|
||||||
|
return self::has_permission(User_Model::PERM_MANAGE_SITE_USERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function can_edit(): bool
|
||||||
|
{
|
||||||
|
return self::has_permission(User_Model::PERM_EDIT_DATA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Usage in routes:
|
||||||
|
|
||||||
|
#[Route('/users/create')]
|
||||||
|
#[Auth('Permission::can_manage_users()')]
|
||||||
|
public static function create(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// Guaranteed to have PERM_MANAGE_SITE_USERS
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPLEMENTARY PERMISSIONS
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
|
||||||
|
Supplementary permissions allow per-user exceptions to role defaults:
|
||||||
|
|
||||||
|
- GRANT: Give a permission the role doesn't include
|
||||||
|
- DENY: Remove a permission the role normally includes
|
||||||
|
|
||||||
|
Common use cases:
|
||||||
|
- Grant API access to specific users regardless of role
|
||||||
|
- Deny export access to a user who normally has it
|
||||||
|
- Temporary elevated permissions during onboarding
|
||||||
|
|
||||||
|
Database Table
|
||||||
|
|
||||||
|
user_permissions
|
||||||
|
id BIGINT PRIMARY KEY
|
||||||
|
user_id BIGINT NOT NULL (FK to users)
|
||||||
|
permission_id INT NOT NULL
|
||||||
|
is_grant TINYINT(1) NOT NULL (1=GRANT, 0=DENY)
|
||||||
|
created_at TIMESTAMP
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
|
||||||
|
UNIQUE KEY (user_id, permission_id)
|
||||||
|
|
||||||
|
Management API
|
||||||
|
|
||||||
|
// Grant a permission
|
||||||
|
User_Permission_Model::grant($user_id, User_Model::PERM_API_ACCESS);
|
||||||
|
|
||||||
|
// Deny a permission
|
||||||
|
User_Permission_Model::deny($user_id, User_Model::PERM_DATA_EXPORT);
|
||||||
|
|
||||||
|
// Remove supplementary (revert to role default)
|
||||||
|
User_Permission_Model::remove($user_id, User_Model::PERM_API_ACCESS);
|
||||||
|
|
||||||
|
// Get all supplementary permissions for user
|
||||||
|
$supplementary = User_Permission_Model::for_user($user_id);
|
||||||
|
|
||||||
|
Resolution Priority
|
||||||
|
|
||||||
|
DENY always wins. Order of precedence:
|
||||||
|
|
||||||
|
1. Explicit DENY → permission denied
|
||||||
|
2. Explicit GRANT → permission granted
|
||||||
|
3. Role default → permission granted if in role
|
||||||
|
4. Not granted → permission denied
|
||||||
|
|
||||||
|
Example: User is Site Admin (has PERM_MANAGE_SITE_USERS by role)
|
||||||
|
|
||||||
|
- No supplementary → has permission (from role)
|
||||||
|
- GRANT added → has permission (redundant but harmless)
|
||||||
|
- DENY added → NO permission (DENY overrides role)
|
||||||
|
- Both GRANT and DENY → NO permission (DENY wins)
|
||||||
|
|
||||||
|
EXAMPLES
|
||||||
|
|
||||||
|
Example 1: Check Permission in Controller
|
||||||
|
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
#[Auth('Permission::authenticated()')]
|
||||||
|
public static function delete_user(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
if (!Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) {
|
||||||
|
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot manage users');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target_id = $params['user_id'];
|
||||||
|
$target = User_Model::find($target_id);
|
||||||
|
|
||||||
|
// Check can admin this user's role
|
||||||
|
$current = Permission::get_user();
|
||||||
|
if (!$current->can_admin_role($target->role_id)) {
|
||||||
|
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot modify this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target->delete();
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
Example 2: Conditional UI Based on Permissions
|
||||||
|
|
||||||
|
// In controller, pass permissions to view
|
||||||
|
return rsx_view('Settings_Users', [
|
||||||
|
'can_create' => Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS),
|
||||||
|
'can_export' => Permission::has_permission(User_Model::PERM_DATA_EXPORT),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// In jqhtml template
|
||||||
|
<% if (this.args.can_create) { %>
|
||||||
|
<button @click=this.create_user>Add User</button>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
Example 3: Role Assignment Validation
|
||||||
|
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function update_user_role(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
$target = User_Model::find($params['user_id']);
|
||||||
|
$new_role_id = $params['role_id'];
|
||||||
|
|
||||||
|
$current = Permission::get_user();
|
||||||
|
|
||||||
|
// Can't change own role
|
||||||
|
if ($target->id === $current->id) {
|
||||||
|
return response_error(Ajax::ERROR_VALIDATION, 'Cannot change own role');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be able to admin both current and new role
|
||||||
|
if (!$current->can_admin_role($target->role_id)) {
|
||||||
|
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot modify this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$current->can_admin_role($new_role_id)) {
|
||||||
|
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot assign this role');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target->role_id = $new_role_id;
|
||||||
|
$target->save();
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
Example 4: Grant Supplementary Permission
|
||||||
|
|
||||||
|
// Admin grants API access to a regular user
|
||||||
|
$user = User_Model::find($user_id);
|
||||||
|
|
||||||
|
// Verify current user can admin this user
|
||||||
|
if (!Permission::get_user()->can_admin_role($user->role_id)) {
|
||||||
|
throw new Exception('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
User_Permission_Model::grant($user->id, User_Model::PERM_API_ACCESS);
|
||||||
|
|
||||||
|
// User now has API access despite being a regular User role
|
||||||
|
|
||||||
|
Example 5: Check Multiple Permissions
|
||||||
|
|
||||||
|
// User needs EITHER permission
|
||||||
|
$can_view = Permission::has_permission(User_Model::PERM_VIEW_DATA)
|
||||||
|
|| Permission::has_permission(User_Model::PERM_EDIT_DATA);
|
||||||
|
|
||||||
|
// User needs BOTH permissions
|
||||||
|
$can_admin = Permission::has_permission(User_Model::PERM_MANAGE_SITE_SETTINGS)
|
||||||
|
&& Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS);
|
||||||
|
|
||||||
|
ADDING NEW PERMISSIONS
|
||||||
|
|
||||||
|
1. Add constant to User_Model:
|
||||||
|
|
||||||
|
const PERM_NEW_FEATURE = 10;
|
||||||
|
|
||||||
|
2. Add to role definitions in $enums if role should grant it:
|
||||||
|
|
||||||
|
self::ROLE_SITE_ADMIN => [
|
||||||
|
'permissions' => [
|
||||||
|
// ... existing
|
||||||
|
self::PERM_NEW_FEATURE,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
3. Run rsx:migrate:document_models to regenerate stubs
|
||||||
|
|
||||||
|
4. Use in code:
|
||||||
|
|
||||||
|
if (Permission::has_permission(User_Model::PERM_NEW_FEATURE)) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
ADDING NEW ROLES
|
||||||
|
|
||||||
|
1. Add constant (maintain hierarchy order):
|
||||||
|
|
||||||
|
const ROLE_SUPERVISOR = 4; // Between Admin and Manager
|
||||||
|
const ROLE_MANAGER = 5; // Renumber if needed
|
||||||
|
// ...
|
||||||
|
|
||||||
|
2. Add to $enums with permissions and can_admin_roles:
|
||||||
|
|
||||||
|
self::ROLE_SUPERVISOR => [
|
||||||
|
'constant' => 'ROLE_SUPERVISOR',
|
||||||
|
'label' => 'Supervisor',
|
||||||
|
'permissions' => [
|
||||||
|
self::PERM_VIEW_USER_ACTIVITY,
|
||||||
|
self::PERM_EDIT_DATA,
|
||||||
|
self::PERM_VIEW_DATA,
|
||||||
|
],
|
||||||
|
'can_admin_roles' => [5,6,7],
|
||||||
|
],
|
||||||
|
|
||||||
|
3. Update can_admin_roles for roles above:
|
||||||
|
|
||||||
|
self::ROLE_SITE_ADMIN => [
|
||||||
|
'can_admin_roles' => [4,5,6,7], // Add new role ID
|
||||||
|
],
|
||||||
|
|
||||||
|
4. Run migration if role_id column needs updating
|
||||||
|
|
||||||
|
5. Run rsx:migrate:document_models
|
||||||
|
|
||||||
|
FRAMEWORK IMPLEMENTATION DETAILS
|
||||||
|
|
||||||
|
This section is for framework developers modifying the ACL system.
|
||||||
|
|
||||||
|
Core Files
|
||||||
|
|
||||||
|
rsx/models/user_model.php
|
||||||
|
Role and permission constants
|
||||||
|
$enums definition with role metadata
|
||||||
|
has_permission(), can_admin_role() methods
|
||||||
|
Supplementary permission lookup methods
|
||||||
|
|
||||||
|
rsx/permission.php
|
||||||
|
Permission class with static helper methods
|
||||||
|
has_permission(), has_role(), get_user()
|
||||||
|
Custom permission methods for #[Auth]
|
||||||
|
|
||||||
|
rsx/models/user_permission_model.php
|
||||||
|
Supplementary permissions CRUD
|
||||||
|
grant(), deny(), remove() static methods
|
||||||
|
|
||||||
|
Session Integration
|
||||||
|
|
||||||
|
Permission::get_user() retrieves current site membership:
|
||||||
|
|
||||||
|
1. Get login_user_id from Session::get_user_id()
|
||||||
|
2. Get site_id from Session::get_site_id()
|
||||||
|
3. Query users WHERE login_user_id AND site_id
|
||||||
|
4. Cache result for request duration
|
||||||
|
|
||||||
|
Caching Strategy
|
||||||
|
|
||||||
|
Supplementary permissions are loaded once per request:
|
||||||
|
|
||||||
|
1. First has_permission() call loads all user_permissions
|
||||||
|
2. Stored in User_Model instance property
|
||||||
|
3. Subsequent checks use cached data
|
||||||
|
4. No cache invalidation needed (request-scoped)
|
||||||
|
|
||||||
|
Enum Integration
|
||||||
|
|
||||||
|
The $enums system provides magic properties:
|
||||||
|
|
||||||
|
$user->role_permissions // Array from enum definition
|
||||||
|
$user->role_can_admin_roles // Array from enum definition
|
||||||
|
$user->role_label // String label
|
||||||
|
|
||||||
|
These are resolved via Rsx_Model_Abstract::__get()
|
||||||
|
|
||||||
|
FUTURE ENHANCEMENTS
|
||||||
|
|
||||||
|
Attribute-Based Permission Checks
|
||||||
|
|
||||||
|
Future goal: Declare permissions directly in route attributes.
|
||||||
|
|
||||||
|
#[Route('/settings/users')]
|
||||||
|
#[Auth('Permission::authenticated()')]
|
||||||
|
#[RequiresPermission(User_Model::PERM_MANAGE_SITE_USERS)]
|
||||||
|
public static function users(...)
|
||||||
|
|
||||||
|
This section will be replaced with implementation details when
|
||||||
|
attribute-based permission checking is complete.
|
||||||
|
|
||||||
|
Custom Filters
|
||||||
|
|
||||||
|
Future goal: Allow permission modifications based on context.
|
||||||
|
|
||||||
|
Use cases:
|
||||||
|
- Site-level feature toggles (site disables user management)
|
||||||
|
- Subscription limits (free plan removes export permission)
|
||||||
|
- Time-based access (permission valid only during hours)
|
||||||
|
- Feature flags (A/B testing permission-gated features)
|
||||||
|
|
||||||
|
Architecture concept:
|
||||||
|
|
||||||
|
Role Permissions
|
||||||
|
↓
|
||||||
|
Custom Filters (modify permission set)
|
||||||
|
↓
|
||||||
|
Supplementary GRANT/DENY
|
||||||
|
↓
|
||||||
|
Final Permission Decision
|
||||||
|
|
||||||
|
Filters would be registered callbacks that receive the permission
|
||||||
|
set and context, returning modified permissions. This allows
|
||||||
|
dynamic permission modification without changing role definitions.
|
||||||
|
|
||||||
|
This section will be replaced with implementation details when
|
||||||
|
custom filters are implemented.
|
||||||
|
|
||||||
|
MIGRATION FROM LEGACY
|
||||||
|
|
||||||
|
If upgrading from a system without ACLs:
|
||||||
|
|
||||||
|
1. Add role_id column to users table (default ROLE_USER)
|
||||||
|
2. Create user_permissions table
|
||||||
|
3. Assign appropriate roles to existing users
|
||||||
|
4. Add Permission checks to sensitive routes
|
||||||
|
5. Test with rsx:debug --user_id= to verify
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
auth - Authentication system (login, sessions, invitations)
|
||||||
|
enums - Enum system for role/permission metadata
|
||||||
|
routing - Route protection with #[Auth] attribute
|
||||||
|
session - Session management and user context
|
||||||
|
|
||||||
|
RSpade 1.0 November 2024 ACLS(7)
|
||||||
@@ -285,7 +285,7 @@ CLIENT-SIDE IMPLEMENTATION
|
|||||||
Rsx.render_error(error, '#error_container');
|
Rsx.render_error(error, '#error_container');
|
||||||
|
|
||||||
// Display in form's error container (for Rsx_Form use form.render_error())
|
// Display in form's error container (for Rsx_Form use form.render_error())
|
||||||
Rsx.render_error(error, this.$id('error'));
|
Rsx.render_error(error, this.$sid('error'));
|
||||||
|
|
||||||
Handles all error types:
|
Handles all error types:
|
||||||
- fatal: Shows file:line and full message
|
- fatal: Shows file:line and full message
|
||||||
|
|||||||
@@ -793,85 +793,119 @@ API REFERENCE
|
|||||||
|
|
||||||
CONTROLLER PATTERNS
|
CONTROLLER PATTERNS
|
||||||
|
|
||||||
Route Protection:
|
Authentication Philosophy:
|
||||||
|
|
||||||
Use #[Auth] attribute on routes to require authentication:
|
RSX uses manual authentication checks rather than declarative attributes.
|
||||||
|
This approach ensures developers explicitly handle auth at the code level,
|
||||||
|
making permission logic visible and traceable in the actual method code.
|
||||||
|
|
||||||
#[Route('/dashboard')]
|
A code quality rule (PHP-AUTH-01) verifies that all endpoints have auth
|
||||||
#[Auth('Permission::authenticated()')]
|
checks, either in the method body or in the controller's pre_dispatch().
|
||||||
public static function dashboard(Request $request, array $params = [])
|
|
||||||
|
Controller-Level Authentication (Recommended):
|
||||||
|
|
||||||
|
Add auth check to pre_dispatch() to protect all endpoints in a controller:
|
||||||
|
|
||||||
|
class My_Controller extends Rsx_Controller_Abstract
|
||||||
{
|
{
|
||||||
// User guaranteed to be logged in
|
public static function pre_dispatch(Request $request, array $params = [])
|
||||||
$user = RsxAuth::user();
|
|
||||||
}
|
|
||||||
|
|
||||||
Built-in permission methods:
|
|
||||||
|
|
||||||
Permission::anybody() Allow all (public route)
|
|
||||||
Permission::authenticated() Require login
|
|
||||||
|
|
||||||
Custom permissions in rsx/permission.php:
|
|
||||||
|
|
||||||
class Permission
|
|
||||||
{
|
|
||||||
public static function is_admin(): bool|Response
|
|
||||||
{
|
{
|
||||||
if (!RsxAuth::check()) {
|
if (!Session::is_logged_in()) {
|
||||||
return false;
|
return response_unauthorized();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$user_id = RsxAuth::id();
|
#[Route('/dashboard')]
|
||||||
$site_id = Session::get_site_id();
|
public static function dashboard(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// User guaranteed to be logged in (checked in pre_dispatch)
|
||||||
|
$user = Session::get_user();
|
||||||
|
}
|
||||||
|
|
||||||
$user = User_Model::where('login_user_id', $user_id)
|
#[Ajax_Endpoint]
|
||||||
->where('site_id', $site_id)
|
public static function save_data(Request $request, array $params = [])
|
||||||
->first();
|
{
|
||||||
|
// Also protected by pre_dispatch
|
||||||
return $user && $user->role_id <= 2; // OWNER or ADMIN
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Usage in routes:
|
Method-Level Authentication:
|
||||||
|
|
||||||
#[Route('/admin/users')]
|
Add auth check at the start of individual methods:
|
||||||
#[Auth('Permission::is_admin()')]
|
|
||||||
public static function admin_users(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
// User is logged in AND is admin
|
|
||||||
}
|
|
||||||
|
|
||||||
Ajax Endpoint Authentication:
|
|
||||||
|
|
||||||
Ajax endpoints check authentication same as regular routes:
|
|
||||||
|
|
||||||
#[Ajax_Endpoint]
|
#[Ajax_Endpoint]
|
||||||
#[Auth('Permission::authenticated()')]
|
|
||||||
public static function save_settings(Request $request, array $params = [])
|
public static function save_settings(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
$user_id = RsxAuth::id();
|
if (!Session::is_logged_in()) {
|
||||||
|
return response_unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
// Process request
|
// Process request
|
||||||
}
|
}
|
||||||
|
|
||||||
On authentication failure:
|
Response Helpers:
|
||||||
Regular routes: Redirect to /login
|
|
||||||
Ajax endpoints: Return JSON: {success: false, error_type: "permission_denied"}
|
|
||||||
|
|
||||||
Manual Authentication in Main.php:
|
response_unauthorized(?string $message = null)
|
||||||
|
Returns context-aware unauthorized response:
|
||||||
|
- Ajax: JSON {success: false, error_code: 'unauthorized'}
|
||||||
|
- Web: Redirect to login or render 403 page
|
||||||
|
|
||||||
Use pre_dispatch() for application-wide auth logic:
|
response_not_found(?string $message = null)
|
||||||
|
Returns context-aware not found response:
|
||||||
|
- Ajax: JSON {success: false, error_code: 'not_found'}
|
||||||
|
- Web: Render 404 page
|
||||||
|
|
||||||
public static function pre_dispatch(Request $request, array $params = [])
|
Permission Checks:
|
||||||
{
|
|
||||||
// Require login for all /app/* routes
|
|
||||||
if (str_starts_with($request->path(), 'app/')) {
|
|
||||||
if (!RsxAuth::check()) {
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
Use Permission class helpers for role/permission-based access:
|
||||||
|
|
||||||
|
if (!Permission::has_permission(User_Model::PERM_EDIT_DATA)) {
|
||||||
|
return response_unauthorized('Insufficient permissions');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Permission::has_role(User_Model::ROLE_MANAGER)) {
|
||||||
|
return response_unauthorized('Manager access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
Public Endpoints (Auth Exempt):
|
||||||
|
|
||||||
|
For public endpoints, add @auth-exempt comment to class docblock:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login controller
|
||||||
|
*
|
||||||
|
* @auth-exempt Login routes are public by design
|
||||||
|
*/
|
||||||
|
class Login_Controller extends Rsx_Controller_Abstract
|
||||||
|
{
|
||||||
|
#[Route('/login')]
|
||||||
|
public static function index(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// No auth check needed - marked exempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Or exempt individual methods:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @auth-exempt Public webhook endpoint
|
||||||
|
*/
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function webhook(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// Process webhook
|
||||||
|
}
|
||||||
|
|
||||||
|
Code Quality Enforcement:
|
||||||
|
|
||||||
|
The PHP-AUTH-01 rule in rsx:check verifies:
|
||||||
|
- All #[Route], #[SPA], #[Ajax_Endpoint] methods have auth checks
|
||||||
|
- Check can be in pre_dispatch() OR method body
|
||||||
|
- @auth-exempt comment exempts from requirement
|
||||||
|
|
||||||
|
Run: php artisan rsx:check
|
||||||
|
|
||||||
SINGLE-SITE APPLICATIONS
|
SINGLE-SITE APPLICATIONS
|
||||||
|
|
||||||
Overview:
|
Overview:
|
||||||
|
|||||||
@@ -4,23 +4,35 @@ NAME
|
|||||||
Bundle - RSX asset compilation and management system
|
Bundle - RSX asset compilation and management system
|
||||||
|
|
||||||
SYNOPSIS
|
SYNOPSIS
|
||||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
// Module Bundle (page entry point)
|
||||||
|
use App\RSpade\Core\Bundle\Rsx_Module_Bundle_Abstract;
|
||||||
|
|
||||||
class My_Bundle extends Rsx_Bundle_Abstract
|
class My_Bundle extends Rsx_Module_Bundle_Abstract
|
||||||
{
|
{
|
||||||
public static function define(): array
|
public static function define(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'include' => [
|
'include' => [
|
||||||
'jquery', // Module alias
|
'bootstrap5', // Module alias
|
||||||
'Bootstrap5_Bundle', // Bundle class
|
'Quill_Bundle', // Asset bundle (explicit)
|
||||||
'rsx/app/myapp', // Directory
|
'rsx/theme', // Directory (auto-discovers asset bundles)
|
||||||
'rsx/lib/utils.js', // Specific file
|
__DIR__, // Module directory
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asset Bundle (dependency declaration, auto-discovered)
|
||||||
|
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||||
|
|
||||||
|
class Tom_Select_Bundle extends Rsx_Asset_Bundle_Abstract
|
||||||
|
{
|
||||||
|
public static function define(): array
|
||||||
|
{
|
||||||
|
return ['npm' => ['tom-select']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render in Blade
|
// Render in Blade
|
||||||
{!! My_Bundle::render() !!}
|
{!! My_Bundle::render() !!}
|
||||||
|
|
||||||
@@ -64,51 +76,123 @@ DESCRIPTION
|
|||||||
- Zero configuration SCSS compilation
|
- Zero configuration SCSS compilation
|
||||||
|
|
||||||
CREATING A BUNDLE
|
CREATING A BUNDLE
|
||||||
1. Extend Rsx_Bundle_Abstract
|
CREATING A MODULE BUNDLE (page entry point):
|
||||||
2. Implement define() method
|
1. Extend Rsx_Module_Bundle_Abstract
|
||||||
3. Return configuration array with 'include' key
|
2. Implement define() method
|
||||||
|
3. Return configuration array with 'include' key
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
class Dashboard_Bundle extends Rsx_Bundle_Abstract
|
class Dashboard_Bundle extends Rsx_Module_Bundle_Abstract
|
||||||
{
|
|
||||||
public static function define(): array
|
|
||||||
{
|
{
|
||||||
return [
|
public static function define(): array
|
||||||
'include' => [
|
{
|
||||||
'jquery',
|
return [
|
||||||
'lodash',
|
'include' => [
|
||||||
'bootstrap5',
|
'bootstrap5',
|
||||||
'rsx/app/dashboard',
|
'rsx/app/dashboard',
|
||||||
],
|
],
|
||||||
'config' => [
|
'config' => [
|
||||||
'api_version' => '2.0',
|
'api_version' => '2.0',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
CREATING AN ASSET BUNDLE (dependency declaration):
|
||||||
|
1. Extend Rsx_Asset_Bundle_Abstract
|
||||||
|
2. Place alongside components that need the dependency
|
||||||
|
3. Declare NPM modules, CDN assets, or direct file paths
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class Chart_JS_Bundle extends Rsx_Asset_Bundle_Abstract
|
||||||
|
{
|
||||||
|
public static function define(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cdn_assets' => [
|
||||||
|
'js' => [
|
||||||
|
['url' => 'https://cdn.jsdelivr.net/npm/chart.js'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BUNDLE TYPES
|
||||||
|
RSX has two types of bundles with distinct purposes:
|
||||||
|
|
||||||
|
MODULE BUNDLES (Rsx_Module_Bundle_Abstract)
|
||||||
|
Top-level bundles that get compiled and rendered on pages.
|
||||||
|
- Can scan directories via 'include' paths
|
||||||
|
- Can explicitly include Asset Bundles by class name
|
||||||
|
- Auto-discovers Asset Bundles in scanned directories
|
||||||
|
- Gets built via rsx:bundle:build
|
||||||
|
- Cannot include other Module Bundles
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class Frontend_Bundle extends Rsx_Module_Bundle_Abstract {
|
||||||
|
public static function define(): array {
|
||||||
|
return [
|
||||||
|
'include' => [
|
||||||
|
__DIR__, // Directory scan
|
||||||
|
'rsx/theme', // Directory scan (auto-discovers asset bundles)
|
||||||
|
'Quill_Bundle', // Explicit asset bundle
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSET BUNDLES (Rsx_Asset_Bundle_Abstract)
|
||||||
|
Dependency declaration bundles co-located with components.
|
||||||
|
- NO directory scanning - only direct file paths
|
||||||
|
- Declares CDN assets, NPM modules, watch directories
|
||||||
|
- Auto-discovered when Module Bundles scan directories
|
||||||
|
- Never built standalone - metadata consumed by Module Bundles
|
||||||
|
- Can include other Asset Bundles by class name
|
||||||
|
|
||||||
|
Example:
|
||||||
|
// /rsx/theme/components/inputs/select/Tom_Select_Bundle.php
|
||||||
|
class Tom_Select_Bundle extends Rsx_Asset_Bundle_Abstract {
|
||||||
|
public static function define(): array {
|
||||||
|
return [
|
||||||
|
'npm' => ['tom-select'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTO-DISCOVERY
|
||||||
|
When a Module Bundle scans a directory, Asset Bundles found within
|
||||||
|
are automatically processed. Their CDN assets, NPM modules, and
|
||||||
|
config are merged into the parent bundle.
|
||||||
|
|
||||||
|
This allows components to declare their own dependencies without
|
||||||
|
requiring explicit inclusion in every Module Bundle that uses them.
|
||||||
|
|
||||||
|
Discovered Asset Bundles cannot have directory scan paths in their
|
||||||
|
'include' array. If an Asset Bundle needs directory scanning, it
|
||||||
|
must be explicitly included by class name in the parent Module Bundle.
|
||||||
|
|
||||||
BUNDLE PLACEMENT
|
BUNDLE PLACEMENT
|
||||||
Bundles exist ONLY at the top-level module directory. Never create
|
MODULE BUNDLES exist at top-level module directories:
|
||||||
bundles in subdirectories or submodules.
|
|
||||||
|
|
||||||
CORRECT:
|
CORRECT:
|
||||||
/rsx/app/login/login_bundle.php ✓ Top-level module
|
/rsx/app/login/login_bundle.php - Module Bundle
|
||||||
/rsx/app/dashboard/dashboard_bundle.php ✓ Top-level module
|
/rsx/app/frontend/frontend_bundle.php - Module Bundle
|
||||||
/rsx/app/frontend/frontend_bundle.php ✓ Top-level module
|
|
||||||
|
|
||||||
INCORRECT:
|
ASSET BUNDLES live alongside their components:
|
||||||
/rsx/app/login/accept_invite/accept_invite_bundle.php ✗ Subdirectory
|
|
||||||
/rsx/app/frontend/settings/settings_bundle.php ✗ Submodule
|
|
||||||
/rsx/app/frontend/users/edit/edit_bundle.php ✗ Feature
|
|
||||||
|
|
||||||
WHY TOP-LEVEL ONLY:
|
CORRECT:
|
||||||
Including __DIR__ in a top-level bundle automatically includes all
|
/rsx/theme/components/inputs/select/Tom_Select_Bundle.php
|
||||||
files in that module directory and all subdirectories recursively.
|
/rsx/theme/bootstrap5_src_bundle.php
|
||||||
This provides complete coverage without needing multiple bundles.
|
|
||||||
|
MODULE BUNDLE COVERAGE:
|
||||||
|
Including __DIR__ in a Module Bundle automatically includes all
|
||||||
|
files in that directory and subdirectories recursively, while
|
||||||
|
auto-discovering any Asset Bundles found.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
// /rsx/app/login/login_bundle.php
|
// /rsx/app/login/login_bundle.php
|
||||||
class Login_Bundle extends Rsx_Bundle_Abstract
|
class Login_Bundle extends Rsx_Module_Bundle_Abstract
|
||||||
{
|
{
|
||||||
public static function define(): array
|
public static function define(): array
|
||||||
{
|
{
|
||||||
@@ -124,16 +208,9 @@ BUNDLE PLACEMENT
|
|||||||
/rsx/app/login/login_controller.php
|
/rsx/app/login/login_controller.php
|
||||||
/rsx/app/login/login_index.blade.php
|
/rsx/app/login/login_index.blade.php
|
||||||
/rsx/app/login/login_index.js
|
/rsx/app/login/login_index.js
|
||||||
/rsx/app/login/accept_invite/accept_invite_controller.php
|
/rsx/app/login/accept_invite/...
|
||||||
/rsx/app/login/accept_invite/accept_invite.blade.php
|
|
||||||
/rsx/app/login/accept_invite/accept_invite.js
|
|
||||||
/rsx/app/login/accept_invite/create_account.blade.php
|
|
||||||
/rsx/app/login/accept_invite/create_account.js
|
|
||||||
/rsx/app/login/signup/...
|
/rsx/app/login/signup/...
|
||||||
... and all other files in subdirectories
|
... and all files in subdirectories
|
||||||
|
|
||||||
The __DIR__ constant resolves to the bundle's directory, making it
|
|
||||||
self-referential and automatically including all module content.
|
|
||||||
|
|
||||||
INCLUDE TYPES
|
INCLUDE TYPES
|
||||||
Module Aliases
|
Module Aliases
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ TROUBLESHOOTING
|
|||||||
Console_debug not showing:
|
Console_debug not showing:
|
||||||
- Check CONSOLE_DEBUG_ENABLED=true
|
- Check CONSOLE_DEBUG_ENABLED=true
|
||||||
- Verify filter settings
|
- Verify filter settings
|
||||||
- Use php artisan rsx:debug --console-log
|
- Use php artisan rsx:debug --console
|
||||||
|
|
||||||
Bundles not updating:
|
Bundles not updating:
|
||||||
- Clear bundle cache in storage/rsx-build/bundles
|
- Clear bundle cache in storage/rsx-build/bundles
|
||||||
|
|||||||
@@ -5,17 +5,24 @@ NAME
|
|||||||
|
|
||||||
SYNOPSIS
|
SYNOPSIS
|
||||||
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
||||||
|
use App\RSpade\Core\Session\Session;
|
||||||
|
|
||||||
class User_Controller extends Rsx_Controller_Abstract
|
class User_Controller extends Rsx_Controller_Abstract
|
||||||
{
|
{
|
||||||
#[Auth('Permission::authenticated()')]
|
public static function pre_dispatch(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
if (!Session::is_logged_in()) {
|
||||||
|
return response_unauthorized();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/users', methods: ['GET'])]
|
#[Route('/users', methods: ['GET'])]
|
||||||
public static function index(Request $request, array $params = [])
|
public static function index(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
return rsx_view('User_List');
|
return rsx_view('User_List');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Auth('Permission::authenticated()')]
|
|
||||||
#[Ajax_Endpoint]
|
#[Ajax_Endpoint]
|
||||||
public static function get_profile(Request $request, array $params = [])
|
public static function get_profile(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
@@ -98,95 +105,73 @@ ROUTE PARAMETERS
|
|||||||
$name = $request->input('name');
|
$name = $request->input('name');
|
||||||
$email = $request->post('email');
|
$email = $request->post('email');
|
||||||
|
|
||||||
REQUIRE ATTRIBUTE
|
AUTHENTICATION
|
||||||
#[Auth(callable, message, redirect, redirect_to)]
|
|
||||||
REQUIRED on all routes. Defines access control check.
|
|
||||||
callable: 'Class::method()' string to execute
|
|
||||||
message: Optional error message
|
|
||||||
redirect: Optional URL to redirect on failure (HTTP only)
|
|
||||||
redirect_to: Optional ['Controller', 'action'] (HTTP only)
|
|
||||||
|
|
||||||
All routes MUST have at least one #[Auth] attribute, either on:
|
RSX uses manual authentication checks rather than declarative attributes.
|
||||||
- The route method itself
|
Auth checks are placed directly in controller code for visibility.
|
||||||
- The controller's pre_dispatch() method (applies to all routes)
|
|
||||||
- Both (pre_dispatch Require runs first, then route Require)
|
|
||||||
|
|
||||||
Multiple #[Auth] attributes are supported - all must pass.
|
A code quality rule (PHP-AUTH-01) verifies all endpoints have auth checks,
|
||||||
|
either in the method body or controller's pre_dispatch().
|
||||||
|
|
||||||
Permission Method Contract:
|
Controller-Wide Authentication (Recommended):
|
||||||
public static function method_name(Request $request, array $params, ...$args): mixed
|
|
||||||
|
|
||||||
Returns:
|
class Dashboard_Controller extends Rsx_Controller_Abstract
|
||||||
- true or null: Allow access
|
|
||||||
- false: Deny access
|
|
||||||
- Response: Custom response (overrides default handling)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
// Public access
|
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
#[Route('/')]
|
|
||||||
public static function index(Request $request, array $params = [])
|
|
||||||
{
|
{
|
||||||
return rsx_view('Landing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticated users only
|
|
||||||
#[Auth('Permission::authenticated()',
|
|
||||||
message: 'Please log in',
|
|
||||||
redirect: '/login')]
|
|
||||||
#[Route('/dashboard')]
|
|
||||||
public static function dashboard(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
return rsx_view('Dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect using controller/action
|
|
||||||
#[Auth('Permission::authenticated()',
|
|
||||||
message: 'Login required',
|
|
||||||
redirect_to: ['Login_Index_Controller', 'show_login'])]
|
|
||||||
#[Route('/profile')]
|
|
||||||
public static function profile(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
return rsx_view('Profile');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission with arguments
|
|
||||||
#[Auth('Permission::has_role("admin")')]
|
|
||||||
#[Route('/admin')]
|
|
||||||
public static function admin_panel(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
return rsx_view('Admin_Panel');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple requirements
|
|
||||||
#[Auth('Permission::authenticated()')]
|
|
||||||
#[Auth('Permission::has_permission("edit_users")')]
|
|
||||||
#[Route('/users/edit')]
|
|
||||||
public static function edit_users(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
return rsx_view('User_Edit');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controller-wide requirement
|
|
||||||
class Admin_Controller extends Rsx_Controller_Abstract
|
|
||||||
{
|
|
||||||
#[Auth('Permission::has_role("admin")',
|
|
||||||
message: 'Admin access required',
|
|
||||||
redirect: '/')]
|
|
||||||
public static function pre_dispatch(Request $request, array $params = [])
|
public static function pre_dispatch(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
|
if (!Session::is_logged_in()) {
|
||||||
|
return response_unauthorized();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All routes in this controller require admin role
|
#[Route('/dashboard')]
|
||||||
#[Route('/admin/users')]
|
public static function index(Request $request, array $params = [])
|
||||||
public static function users(Request $request, array $params = [])
|
|
||||||
{
|
{
|
||||||
return rsx_view('Admin_Users');
|
// Auth checked in pre_dispatch
|
||||||
|
return rsx_view('Dashboard');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Creating Permission Methods (rsx/permission.php):
|
Method-Level Authentication:
|
||||||
|
|
||||||
|
#[Route('/admin')]
|
||||||
|
public static function admin_panel(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
if (!Session::is_logged_in()) {
|
||||||
|
return response_unauthorized();
|
||||||
|
}
|
||||||
|
if (!Permission::has_role(User_Model::ROLE_ADMIN)) {
|
||||||
|
return response_unauthorized('Admin access required');
|
||||||
|
}
|
||||||
|
return rsx_view('Admin_Panel');
|
||||||
|
}
|
||||||
|
|
||||||
|
Response Helpers:
|
||||||
|
|
||||||
|
response_unauthorized(?string $message = null)
|
||||||
|
Context-aware: JSON for Ajax, redirect/403 for web
|
||||||
|
|
||||||
|
response_not_found(?string $message = null)
|
||||||
|
Context-aware: JSON for Ajax, 404 page for web
|
||||||
|
|
||||||
|
Public Endpoints:
|
||||||
|
|
||||||
|
Mark public endpoints with @auth-exempt in class docblock:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @auth-exempt Public landing page
|
||||||
|
*/
|
||||||
|
class Landing_Controller extends Rsx_Controller_Abstract
|
||||||
|
{
|
||||||
|
#[Route('/')]
|
||||||
|
public static function index(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
return rsx_view('Landing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission Helpers (rsx/permission.php):
|
||||||
class Permission extends Permission_Abstract
|
class Permission extends Permission_Abstract
|
||||||
{
|
{
|
||||||
public static function anybody(Request $request, array $params): mixed
|
public static function anybody(Request $request, array $params): mixed
|
||||||
@@ -208,23 +193,21 @@ REQUIRE ATTRIBUTE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AJAX ENDPOINTS AND REQUIRE
|
AJAX ENDPOINTS AND AUTHENTICATION
|
||||||
Ajax endpoints also require #[Auth] attributes.
|
Ajax endpoints use the same auth pattern as routes.
|
||||||
For Ajax endpoints, redirect parameters are ignored and JSON errors returned:
|
The pre_dispatch() check covers all methods in the controller:
|
||||||
|
|
||||||
#[Auth('Permission::authenticated()',
|
|
||||||
message: 'Login required')]
|
|
||||||
#[Ajax_Endpoint]
|
#[Ajax_Endpoint]
|
||||||
public static function get_data(Request $request, array $params = [])
|
public static function get_data(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
|
// Auth checked in pre_dispatch
|
||||||
return ['data' => 'value'];
|
return ['data' => 'value'];
|
||||||
}
|
}
|
||||||
|
|
||||||
On failure, returns:
|
On response_unauthorized(), Ajax returns:
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Login required",
|
"error_code": "unauthorized"
|
||||||
"error_type": "permission_denied"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HTTP Status: 403 Forbidden
|
HTTP Status: 403 Forbidden
|
||||||
@@ -296,7 +279,7 @@ PRE_DISPATCH HOOK
|
|||||||
public static function pre_dispatch(Request $request, array $params = [])
|
public static function pre_dispatch(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!RsxAuth::check()) {
|
if (!Session::is_logged_in()) {
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,11 +305,11 @@ RESPONSE TYPES
|
|||||||
return response()->json(['key' => 'value']);
|
return response()->json(['key' => 'value']);
|
||||||
|
|
||||||
AUTHENTICATION
|
AUTHENTICATION
|
||||||
Use RsxAuth in pre_dispatch:
|
Use Session:: in pre_dispatch:
|
||||||
|
|
||||||
public static function pre_dispatch(Request $request, array $params = [])
|
public static function pre_dispatch(Request $request, array $params = [])
|
||||||
{
|
{
|
||||||
if (!RsxAuth::check()) {
|
if (!Session::is_logged_in()) {
|
||||||
if ($request->ajax()) {
|
if ($request->ajax()) {
|
||||||
return response()->json(['error' => 'Unauthorized'], 401);
|
return response()->json(['error' => 'Unauthorized'], 401);
|
||||||
}
|
}
|
||||||
@@ -436,26 +419,25 @@ TROUBLESHOOTING
|
|||||||
- Run php artisan rsx:routes to list all
|
- Run php artisan rsx:routes to list all
|
||||||
- Clear cache: php artisan rsx:clean
|
- Clear cache: php artisan rsx:clean
|
||||||
|
|
||||||
Missing #[Auth] attribute error:
|
PHP-AUTH-01 code quality error:
|
||||||
- Add #[Auth('Permission::anybody()')] to route method
|
- Add auth check to pre_dispatch() (recommended)
|
||||||
- OR add #[Auth] to pre_dispatch() for controller-wide access
|
- OR add auth check at start of method
|
||||||
- Rebuild manifest: php artisan rsx:manifest:build
|
- OR add @auth-exempt comment for public endpoints
|
||||||
|
- Run: php artisan rsx:check
|
||||||
|
|
||||||
Permission denied (403):
|
Permission denied / Unauthorized:
|
||||||
- Check permission method logic returns true
|
- Check Session::is_logged_in() returns true
|
||||||
- Verify Session::is_logged_in() for authenticated routes
|
- Verify Permission helpers in rsx/permission.php
|
||||||
- Add message parameter for clearer errors
|
- Check user has required role/permission
|
||||||
- Check permission method exists in rsx/permission.php
|
|
||||||
|
|
||||||
JavaScript stub missing:
|
JavaScript stub missing:
|
||||||
- Ensure Ajax_Endpoint attribute present
|
- Ensure Ajax_Endpoint attribute present
|
||||||
- Ensure #[Auth] attribute present on Ajax method
|
|
||||||
- Rebuild manifest: php artisan rsx:manifest:build
|
- Rebuild manifest: php artisan rsx:manifest:build
|
||||||
- Check storage/rsx-build/js-stubs/
|
- Check storage/rsx-build/js-stubs/
|
||||||
|
|
||||||
Authentication issues:
|
Authentication issues:
|
||||||
- Implement pre_dispatch hook
|
- Implement pre_dispatch() with Session::is_logged_in() check
|
||||||
- Use Permission::authenticated() in Require
|
- Return response_unauthorized() on failure
|
||||||
- Verify session configuration
|
- Verify session configuration
|
||||||
|
|
||||||
SEE ALSO
|
SEE ALSO
|
||||||
|
|||||||
570
app/RSpade/man/crud.txt
Executable file
570
app/RSpade/man/crud.txt
Executable file
@@ -0,0 +1,570 @@
|
|||||||
|
CRUD(3) RSX Framework Manual CRUD(3)
|
||||||
|
|
||||||
|
NAME
|
||||||
|
crud - Standard CRUD implementation pattern for RSpade applications
|
||||||
|
|
||||||
|
SYNOPSIS
|
||||||
|
A complete CRUD (Create, Read, Update, Delete) implementation consists of:
|
||||||
|
|
||||||
|
Directory Structure:
|
||||||
|
rsx/app/frontend/{feature}/
|
||||||
|
{feature}_controller.php # Ajax endpoints
|
||||||
|
list/
|
||||||
|
{Feature}_Index_Action.js # List page action
|
||||||
|
{Feature}_Index_Action.jqhtml # List page template
|
||||||
|
{feature}_datagrid.php # DataGrid backend
|
||||||
|
{feature}_datagrid.jqhtml # DataGrid template
|
||||||
|
view/
|
||||||
|
{Feature}_View_Action.js # Detail page action
|
||||||
|
{Feature}_View_Action.jqhtml # Detail page template
|
||||||
|
edit/
|
||||||
|
{Feature}_Edit_Action.js # Add/Edit page action
|
||||||
|
{Feature}_Edit_Action.jqhtml # Add/Edit page template
|
||||||
|
|
||||||
|
Model:
|
||||||
|
rsx/models/{feature}_model.php # With fetch() method
|
||||||
|
|
||||||
|
DESCRIPTION
|
||||||
|
This document describes the standard pattern for implementing CRUD
|
||||||
|
functionality in RSpade SPA applications. The pattern provides:
|
||||||
|
|
||||||
|
- Consistent file organization across all features
|
||||||
|
- DataGrid for listing with sorting, filtering, pagination
|
||||||
|
- Model.fetch() for loading single records
|
||||||
|
- Rsx_Form for add/edit with automatic Ajax submission
|
||||||
|
- Server-side validation with field-level errors
|
||||||
|
- Three-state loading pattern (loading/error/content)
|
||||||
|
- Single action class handling both add and edit modes
|
||||||
|
|
||||||
|
DIRECTORY STRUCTURE
|
||||||
|
Each CRUD feature uses three subdirectories:
|
||||||
|
|
||||||
|
list/ - Index page with DataGrid listing all records
|
||||||
|
view/ - Detail page showing single record
|
||||||
|
edit/ - Combined add/edit form (dual-route action)
|
||||||
|
|
||||||
|
The controller sits at the feature root and provides Ajax endpoints
|
||||||
|
for all operations (datagrid_fetch, save, delete, restore).
|
||||||
|
|
||||||
|
MODEL SETUP
|
||||||
|
|
||||||
|
Fetchable Model
|
||||||
|
To load records from JavaScript, the model needs a fetch() method
|
||||||
|
with the #[Ajax_Endpoint_Model_Fetch] attribute:
|
||||||
|
|
||||||
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
|
public static function fetch($id)
|
||||||
|
{
|
||||||
|
$record = static::withTrashed()->find($id);
|
||||||
|
if (!$record) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $record->id,
|
||||||
|
'name' => $record->name,
|
||||||
|
// Include all fields needed by view/edit pages
|
||||||
|
// Add computed fields for display
|
||||||
|
'status_label' => ucfirst($record->status),
|
||||||
|
'created_at_formatted' => $record->created_at->format('M d, Y'),
|
||||||
|
'created_at_human' => $record->created_at->diffForHumans(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Use withTrashed() if soft deletes should be viewable
|
||||||
|
- Return false (not null) when record not found
|
||||||
|
- Include computed fields needed for display (labels, badges, formatted dates)
|
||||||
|
- The attribute enables Model.fetch(id) in JavaScript
|
||||||
|
|
||||||
|
See model_fetch(3) for complete documentation.
|
||||||
|
|
||||||
|
FEATURE CONTROLLER
|
||||||
|
The controller provides Ajax endpoints for all CRUD operations:
|
||||||
|
|
||||||
|
class Frontend_Clients_Controller extends Rsx_Controller_Abstract
|
||||||
|
{
|
||||||
|
#[Auth('Permission::anybody()')]
|
||||||
|
public static function pre_dispatch(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataGrid data endpoint
|
||||||
|
#[Auth('Permission::anybody()')]
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function datagrid_fetch(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
return Clients_DataGrid::fetch($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save (create or update)
|
||||||
|
#[Auth('Permission::anybody()')]
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function save(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// Validation
|
||||||
|
$errors = [];
|
||||||
|
if (empty($params['name'])) {
|
||||||
|
$errors['name'] = 'Name is required';
|
||||||
|
}
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return response_error(Ajax::ERROR_VALIDATION, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update
|
||||||
|
$id = $params['id'] ?? null;
|
||||||
|
if ($id) {
|
||||||
|
$record = Client_Model::find($id);
|
||||||
|
if (!$record) {
|
||||||
|
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$record = new Client_Model();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set fields explicitly (no mass assignment)
|
||||||
|
$record->name = $params['name'];
|
||||||
|
$record->email = $params['email'] ?? null;
|
||||||
|
// ... all fields ...
|
||||||
|
$record->save();
|
||||||
|
|
||||||
|
Flash_Alert::success($id ? 'Updated successfully' : 'Created successfully');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $record->id,
|
||||||
|
'redirect' => Rsx::Route('Clients_View_Action', $record->id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
#[Auth('Permission::anybody()')]
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function delete(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
$record = Client_Model::find($params['id']);
|
||||||
|
if (!$record) {
|
||||||
|
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
|
||||||
|
}
|
||||||
|
$record->delete();
|
||||||
|
return ['message' => 'Deleted successfully'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore soft-deleted record
|
||||||
|
#[Auth('Permission::anybody()')]
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function restore(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
$record = Client_Model::withTrashed()->find($params['id']);
|
||||||
|
if (!$record) {
|
||||||
|
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
|
||||||
|
}
|
||||||
|
if (!$record->trashed()) {
|
||||||
|
return response_error(Ajax::ERROR_VALIDATION, ['message' => 'Not deleted']);
|
||||||
|
}
|
||||||
|
$record->restore();
|
||||||
|
return ['message' => 'Restored successfully'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Validation Pattern
|
||||||
|
Return field-level errors that Rsx_Form can display:
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
if (empty($params['name'])) {
|
||||||
|
$errors['name'] = 'Name is required';
|
||||||
|
}
|
||||||
|
if (!empty($params['email']) && !filter_var($params['email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors['email'] = 'Invalid email address';
|
||||||
|
}
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return response_error(Ajax::ERROR_VALIDATION, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
The errors array keys must match form field names. Rsx_Form
|
||||||
|
automatically displays these errors next to the corresponding fields.
|
||||||
|
|
||||||
|
LIST PAGE (INDEX)
|
||||||
|
|
||||||
|
Action Class (list/{Feature}_Index_Action.js)
|
||||||
|
@route('/clients')
|
||||||
|
@layout('Frontend_Spa_Layout')
|
||||||
|
@spa('Frontend_Spa_Controller::index')
|
||||||
|
@title('Clients - RSX')
|
||||||
|
class Clients_Index_Action extends Spa_Action {
|
||||||
|
full_width = true; // DataGrid pages typically use full width
|
||||||
|
|
||||||
|
async on_load() {
|
||||||
|
// DataGrid loads its own data - nothing to do here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Template (list/{Feature}_Index_Action.jqhtml)
|
||||||
|
<Define:Clients_Index_Action>
|
||||||
|
<Page>
|
||||||
|
<Page_Header>
|
||||||
|
<Page_Header_Left>
|
||||||
|
<Page_Title>Clients</Page_Title>
|
||||||
|
</Page_Header_Left>
|
||||||
|
<Page_Header_Right>
|
||||||
|
<a href="<%= Rsx.Route('Clients_Edit_Action') %>" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-circle"></i> New
|
||||||
|
</a>
|
||||||
|
</Page_Header_Right>
|
||||||
|
</Page_Header>
|
||||||
|
|
||||||
|
<Clients_DataGrid />
|
||||||
|
</Page>
|
||||||
|
</Define:Clients_Index_Action>
|
||||||
|
|
||||||
|
DATAGRID
|
||||||
|
|
||||||
|
Backend Class (list/{feature}_datagrid.php)
|
||||||
|
Extend DataGrid_Abstract and implement build_query():
|
||||||
|
|
||||||
|
class Clients_DataGrid extends DataGrid_Abstract
|
||||||
|
{
|
||||||
|
protected static array $sortable_columns = [
|
||||||
|
'id', 'name', 'city', 'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function build_query(array $params): Builder
|
||||||
|
{
|
||||||
|
$query = Client_Model::query();
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (!empty($params['filter'])) {
|
||||||
|
$filter = $params['filter'];
|
||||||
|
$query->where(function ($q) use ($filter) {
|
||||||
|
$q->where('name', 'LIKE', "%{$filter}%")
|
||||||
|
->orWhere('city', 'LIKE', "%{$filter}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: transform records after fetch
|
||||||
|
protected static function transform_records(array $records, array $params): array
|
||||||
|
{
|
||||||
|
foreach ($records as &$record) {
|
||||||
|
$record['full_address'] = $record['city'] . ', ' . $record['state'];
|
||||||
|
}
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Template (list/{feature}_datagrid.jqhtml)
|
||||||
|
Extend DataGrid_Abstract and define columns and row template:
|
||||||
|
|
||||||
|
<Define:Clients_DataGrid
|
||||||
|
extends="DataGrid_Abstract"
|
||||||
|
$data_source=Frontend_Clients_Controller.datagrid_fetch
|
||||||
|
$sort="id"
|
||||||
|
$order="desc"
|
||||||
|
$per_page=15
|
||||||
|
class="card DataGrid">
|
||||||
|
|
||||||
|
<#DG_Card_Header>
|
||||||
|
<Card_Title>Client List</Card_Title>
|
||||||
|
<Card_Header_Right>
|
||||||
|
<Search_Input $sid="filter_input" $placeholder="Search..." />
|
||||||
|
</Card_Header_Right>
|
||||||
|
</#DG_Card_Header>
|
||||||
|
|
||||||
|
<#DG_Table_Header>
|
||||||
|
<tr>
|
||||||
|
<th data-sortby="id">ID</th>
|
||||||
|
<th data-sortby="name">Name</th>
|
||||||
|
<th data-sortby="created_at">Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</#DG_Table_Header>
|
||||||
|
|
||||||
|
<#row>
|
||||||
|
<tr data-href="<%= Rsx.Route('Clients_View_Action', row.id) %>">
|
||||||
|
<td><%= row.id %></td>
|
||||||
|
<td><%= row.name %></td>
|
||||||
|
<td><%= new Date(row.created_at).toLocaleDateString() %></td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a class="btn btn-outline-primary" href="<%= Rsx.Route('Clients_View_Action', row.id) %>">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-outline-secondary" href="<%= Rsx.Route('Clients_Edit_Action', row.id) %>">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</#row>
|
||||||
|
|
||||||
|
</Define:Clients_DataGrid>
|
||||||
|
|
||||||
|
Key attributes:
|
||||||
|
- $data_source: Controller method that returns data
|
||||||
|
- $sort/$order: Default sort column and direction
|
||||||
|
- $per_page: Records per page
|
||||||
|
- data-sortby: Makes column header clickable for sorting
|
||||||
|
- data-href: Makes entire row clickable
|
||||||
|
|
||||||
|
VIEW PAGE (DETAIL)
|
||||||
|
|
||||||
|
Action Class (view/{Feature}_View_Action.js)
|
||||||
|
Uses Model.fetch() and three-state pattern:
|
||||||
|
|
||||||
|
@route('/clients/view/:id')
|
||||||
|
@layout('Frontend_Spa_Layout')
|
||||||
|
@spa('Frontend_Spa_Controller::index')
|
||||||
|
@title('Client Details')
|
||||||
|
class Clients_View_Action extends Spa_Action {
|
||||||
|
on_create() {
|
||||||
|
this.data.client = { name: '', tags: [] }; // Stub
|
||||||
|
this.data.error_data = null;
|
||||||
|
this.data.loading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async on_load() {
|
||||||
|
try {
|
||||||
|
this.data.client = await Client_Model.fetch(this.args.id);
|
||||||
|
} catch (e) {
|
||||||
|
this.data.error_data = e;
|
||||||
|
}
|
||||||
|
this.data.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Template (view/{Feature}_View_Action.jqhtml)
|
||||||
|
Three-state template pattern:
|
||||||
|
|
||||||
|
<Define:Clients_View_Action>
|
||||||
|
<Page>
|
||||||
|
<% if (this.data.loading) { %>
|
||||||
|
<Loading_Spinner $message="Loading..." />
|
||||||
|
<% } else if (this.data.error_data) { %>
|
||||||
|
<Universal_Error_Page_Component
|
||||||
|
$error_data="<%= this.data.error_data %>"
|
||||||
|
$record_type="Client"
|
||||||
|
$back_label="Go back to Clients"
|
||||||
|
$back_url="<%= Rsx.Route('Clients_Index_Action') %>"
|
||||||
|
/>
|
||||||
|
<% } else { %>
|
||||||
|
<Page_Header>
|
||||||
|
<Page_Header_Left>
|
||||||
|
<Page_Title><%= this.data.client.name %></Page_Title>
|
||||||
|
</Page_Header_Left>
|
||||||
|
<Page_Header_Right>
|
||||||
|
<a href="<%= Rsx.Route('Clients_Edit_Action', this.data.client.id) %>" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</a>
|
||||||
|
</Page_Header_Right>
|
||||||
|
</Page_Header>
|
||||||
|
|
||||||
|
<!-- Display fields using this.data.client.* -->
|
||||||
|
<div class="card card-body">
|
||||||
|
<label class="text-muted small">Name</label>
|
||||||
|
<div class="fw-bold"><%= this.data.client.name %></div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</Page>
|
||||||
|
</Define:Clients_View_Action>
|
||||||
|
|
||||||
|
See view_action_patterns(3) for detailed documentation.
|
||||||
|
|
||||||
|
EDIT PAGE (ADD/EDIT COMBINED)
|
||||||
|
|
||||||
|
Dual Route Pattern
|
||||||
|
A single action handles both add and edit via two @route decorators:
|
||||||
|
|
||||||
|
@route('/clients/add')
|
||||||
|
@route('/clients/edit/:id')
|
||||||
|
|
||||||
|
The action detects mode by checking for this.args.id:
|
||||||
|
- Add mode: this.args.id is undefined
|
||||||
|
- Edit mode: this.args.id contains the record ID
|
||||||
|
|
||||||
|
Action Class (edit/{Feature}_Edit_Action.js)
|
||||||
|
@route('/clients/add')
|
||||||
|
@route('/clients/edit/:id')
|
||||||
|
@layout('Frontend_Spa_Layout')
|
||||||
|
@spa('Frontend_Spa_Controller::index')
|
||||||
|
@title('Client')
|
||||||
|
class Clients_Edit_Action extends Spa_Action {
|
||||||
|
on_create() {
|
||||||
|
this.data.is_edit = !!this.args.id;
|
||||||
|
|
||||||
|
// Form data stub with defaults
|
||||||
|
this.data.form_data = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
status: 'active',
|
||||||
|
// ... all fields with defaults
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dropdown options
|
||||||
|
this.data.status_options = {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.data.error_data = null;
|
||||||
|
this.data.loading = this.data.is_edit; // Only load in edit mode
|
||||||
|
}
|
||||||
|
|
||||||
|
async on_load() {
|
||||||
|
if (!this.data.is_edit) {
|
||||||
|
return; // Add mode - nothing to load
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await Client_Model.fetch(this.args.id);
|
||||||
|
|
||||||
|
// Populate form_data from record
|
||||||
|
this.data.form_data = {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
email: record.email,
|
||||||
|
status: record.status || 'active',
|
||||||
|
// ... map all fields
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.data.error_data = e;
|
||||||
|
}
|
||||||
|
this.data.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Template (edit/{Feature}_Edit_Action.jqhtml)
|
||||||
|
<Define:Clients_Edit_Action>
|
||||||
|
<Page>
|
||||||
|
<% if (this.data.loading) { %>
|
||||||
|
<Loading_Spinner $message="Loading..." />
|
||||||
|
<% } else if (this.data.error_data) { %>
|
||||||
|
<Universal_Error_Page_Component ... />
|
||||||
|
<% } else { %>
|
||||||
|
<Page_Header>
|
||||||
|
<Page_Title><%= this.data.is_edit ? 'Edit Client' : 'Add Client' %></Page_Title>
|
||||||
|
</Page_Header>
|
||||||
|
|
||||||
|
<Rsx_Form
|
||||||
|
$data="<%= JSON.stringify(this.data.form_data) %>"
|
||||||
|
$controller="Frontend_Clients_Controller"
|
||||||
|
$method="save">
|
||||||
|
|
||||||
|
<% if (this.data.is_edit) { %>
|
||||||
|
<Form_Hidden_Field $name="id" />
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<Form_Field $name="name" $label="Name" $required=true>
|
||||||
|
<Text_Input />
|
||||||
|
</Form_Field>
|
||||||
|
|
||||||
|
<Form_Field $name="status" $label="Status">
|
||||||
|
<Select_Input $options="<%= JSON.stringify(this.data.status_options) %>" />
|
||||||
|
</Form_Field>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</Rsx_Form>
|
||||||
|
<% } %>
|
||||||
|
</Page>
|
||||||
|
</Define:Clients_Edit_Action>
|
||||||
|
|
||||||
|
RSX_FORM
|
||||||
|
|
||||||
|
The Rsx_Form component provides Ajax form submission with automatic
|
||||||
|
error handling. Required attributes:
|
||||||
|
|
||||||
|
$data - JSON string of initial form values
|
||||||
|
$controller - Controller class name
|
||||||
|
$method - Ajax endpoint method name
|
||||||
|
|
||||||
|
Form Fields
|
||||||
|
<Form_Field $name="fieldname" $label="Label" $required=true>
|
||||||
|
<Text_Input />
|
||||||
|
</Form_Field>
|
||||||
|
|
||||||
|
The $name must match:
|
||||||
|
- The key in $data JSON
|
||||||
|
- The key in server-side $params
|
||||||
|
- The key in validation $errors array
|
||||||
|
|
||||||
|
Hidden Fields
|
||||||
|
For edit mode, include the record ID:
|
||||||
|
|
||||||
|
<% if (this.data.is_edit) { %>
|
||||||
|
<Form_Hidden_Field $name="id" />
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
Available Input Components
|
||||||
|
- Text_Input ($type: text, email, url, password, number, textarea)
|
||||||
|
- Select_Input ($options: array or object)
|
||||||
|
- Checkbox_Input ($label: checkbox text)
|
||||||
|
- Phone_Text_Input (formatted phone input)
|
||||||
|
- Country_Select_Input, State_Select_Input
|
||||||
|
- File_Input (for uploads)
|
||||||
|
|
||||||
|
Validation Display
|
||||||
|
When the server returns response_error(Ajax::ERROR_VALIDATION, $errors),
|
||||||
|
Rsx_Form automatically displays errors next to matching fields.
|
||||||
|
|
||||||
|
Success Handling
|
||||||
|
When the server returns a redirect URL, Rsx_Form navigates there:
|
||||||
|
|
||||||
|
return [
|
||||||
|
'redirect' => Rsx::Route('Clients_View_Action', $record->id),
|
||||||
|
];
|
||||||
|
|
||||||
|
See forms_and_widgets(3) for custom form components.
|
||||||
|
|
||||||
|
TESTING
|
||||||
|
Test each page with rsx:debug:
|
||||||
|
|
||||||
|
php artisan rsx:debug /clients --console
|
||||||
|
php artisan rsx:debug /clients/view/1 --console
|
||||||
|
php artisan rsx:debug /clients/add --console
|
||||||
|
php artisan rsx:debug /clients/edit/1 --console
|
||||||
|
|
||||||
|
Test Ajax endpoints:
|
||||||
|
|
||||||
|
php artisan rsx:ajax Frontend_Clients_Controller datagrid_fetch
|
||||||
|
php artisan rsx:ajax Frontend_Clients_Controller save --args='{"name":"Test"}'
|
||||||
|
|
||||||
|
QUICK REFERENCE
|
||||||
|
|
||||||
|
Files for a "clients" feature:
|
||||||
|
rsx/app/frontend/clients/
|
||||||
|
frontend_clients_controller.php
|
||||||
|
list/
|
||||||
|
Clients_Index_Action.js
|
||||||
|
Clients_Index_Action.jqhtml
|
||||||
|
clients_datagrid.php
|
||||||
|
clients_datagrid.jqhtml
|
||||||
|
view/
|
||||||
|
Clients_View_Action.js
|
||||||
|
Clients_View_Action.jqhtml
|
||||||
|
edit/
|
||||||
|
Clients_Edit_Action.js
|
||||||
|
Clients_Edit_Action.jqhtml
|
||||||
|
rsx/models/
|
||||||
|
client_model.php (with fetch())
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
/clients - List (Clients_Index_Action)
|
||||||
|
/clients/view/:id - View (Clients_View_Action)
|
||||||
|
/clients/add - Add (Clients_Edit_Action)
|
||||||
|
/clients/edit/:id - Edit (Clients_Edit_Action)
|
||||||
|
|
||||||
|
Ajax Endpoints:
|
||||||
|
Frontend_Clients_Controller.datagrid_fetch - DataGrid data
|
||||||
|
Frontend_Clients_Controller.save - Create/update
|
||||||
|
Frontend_Clients_Controller.delete - Soft delete
|
||||||
|
Frontend_Clients_Controller.restore - Restore deleted
|
||||||
|
|
||||||
|
JavaScript Data Loading:
|
||||||
|
const record = await Client_Model.fetch(id);
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
model_fetch(3), view_action_patterns(3), forms_and_widgets(3),
|
||||||
|
spa(3), controller(3), module_organization(3)
|
||||||
|
|
||||||
|
RSX Framework 2025-11-23 CRUD(3)
|
||||||
@@ -111,11 +111,11 @@ users
|
|||||||
Schema: email, password_hash, name, site_id (FK), active status
|
Schema: email, password_hash, name, site_id (FK), active status
|
||||||
|
|
||||||
login_users
|
login_users
|
||||||
Purpose: Authentication session/token tracking
|
Purpose: Authentication identity tracking
|
||||||
Managed by: RsxAuth system
|
Managed by: Session system
|
||||||
Records: Active login sessions with 365-day persistence
|
Records: Login credentials with 365-day session persistence
|
||||||
Usage: Developers query for "active sessions", implement logout-all
|
Usage: Developers query via Session::get_login_user()
|
||||||
Schema: user_id (FK), session_token, ip_address, last_activity, expires_at
|
Schema: email, password_hash, display_name, last_login, is_active
|
||||||
|
|
||||||
user_profiles
|
user_profiles
|
||||||
Purpose: Extended user profile information
|
Purpose: Extended user profile information
|
||||||
@@ -281,7 +281,7 @@ Q: Would changing the schema break the developer-facing API?
|
|||||||
NO → System table (underscore prefix) - implementation detail
|
NO → System table (underscore prefix) - implementation detail
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- User login sessions? System (_login_sessions) - API is RsxAuth methods
|
- User login sessions? System (_login_sessions) - API is Session:: methods
|
||||||
- User accounts? Core (users) - developers extend and query this
|
- User accounts? Core (users) - developers extend and query this
|
||||||
- Search index? System (_search_indexes) - API is Search::query()
|
- Search index? System (_search_indexes) - API is Search::query()
|
||||||
- Client records? Application (clients) - business domain entity
|
- Client records? Application (clients) - business domain entity
|
||||||
|
|||||||
@@ -110,16 +110,23 @@ PHP CONSTANTS
|
|||||||
|
|
||||||
JAVASCRIPT ACCESS
|
JAVASCRIPT ACCESS
|
||||||
|
|
||||||
The manifest system generates JavaScript stub classes with enum support:
|
The framework generates JavaScript stub classes with full enum support.
|
||||||
|
See: php artisan rsx:man model_fetch (JAVASCRIPT CLASS ARCHITECTURE)
|
||||||
|
|
||||||
Constants
|
Static Constants
|
||||||
User_Model.STATUS_ACTIVE // 1
|
Project_Model.STATUS_ACTIVE // 2
|
||||||
User_Model.STATUS_INACTIVE // 2
|
Project_Model.STATUS_PLANNING // 1
|
||||||
|
|
||||||
Static Methods
|
Static Methods
|
||||||
User_Model.status_id_enum_val() // Full enum definitions
|
Project_Model.status_enum_val() // Full enum definitions
|
||||||
User_Model.status_id_enum_select() // Filtered for dropdowns
|
Project_Model.status_enum_select() // Filtered for dropdowns
|
||||||
User_Model.status_id_label_list() // All labels keyed by value
|
Project_Model.status_label_list() // All labels keyed by value
|
||||||
|
|
||||||
|
Instance Properties (after fetch)
|
||||||
|
const project = await Project_Model.fetch(1);
|
||||||
|
project.status // 2 (raw value)
|
||||||
|
project.status_label // "Active"
|
||||||
|
project.status_badge // "bg-success"
|
||||||
|
|
||||||
AJAX/JSON EXPORT
|
AJAX/JSON EXPORT
|
||||||
|
|
||||||
|
|||||||
148
app/RSpade/man/external_api.txt
Executable file
148
app/RSpade/man/external_api.txt
Executable file
@@ -0,0 +1,148 @@
|
|||||||
|
EXTERNAL API
|
||||||
|
============
|
||||||
|
|
||||||
|
RSpade provides a system-level API key authentication mechanism for external API
|
||||||
|
access. API keys are managed as a system table (_api_keys) with an opinionated
|
||||||
|
interface - developers interact with the Session class rather than API key
|
||||||
|
records directly.
|
||||||
|
|
||||||
|
ARCHITECTURE
|
||||||
|
------------
|
||||||
|
|
||||||
|
API keys authenticate requests and establish a session context automatically.
|
||||||
|
The system handles:
|
||||||
|
|
||||||
|
- Key validation and lookup
|
||||||
|
- User context establishment (API key -> user -> session)
|
||||||
|
- Rate limiting (future)
|
||||||
|
- Usage tracking
|
||||||
|
|
||||||
|
Developers never interact with API key records directly. The Session class
|
||||||
|
provides the interface for checking authentication context regardless of
|
||||||
|
whether authentication came from a browser session or API key.
|
||||||
|
|
||||||
|
DATABASE SCHEMA
|
||||||
|
---------------
|
||||||
|
|
||||||
|
System table: _api_keys (not exposed to developers)
|
||||||
|
|
||||||
|
id BIGINT PRIMARY KEY
|
||||||
|
user_id BIGINT NOT NULL (FK to users)
|
||||||
|
name VARCHAR(255) - Human-readable key name
|
||||||
|
key_hash VARCHAR(255) - Hashed API key (never store plaintext)
|
||||||
|
key_prefix VARCHAR(16) - First chars for identification (e.g., "rsk_...")
|
||||||
|
user_role_id BIGINT NULL - Optional role override (see ROLE OVERRIDE below)
|
||||||
|
last_used_at DATETIME NULL
|
||||||
|
expires_at DATETIME NULL
|
||||||
|
is_revoked BOOLEAN DEFAULT FALSE
|
||||||
|
created_at DATETIME
|
||||||
|
updated_at DATETIME
|
||||||
|
|
||||||
|
Keys are tied to users (user_id). A user can have multiple API keys.
|
||||||
|
|
||||||
|
KEY FORMAT
|
||||||
|
----------
|
||||||
|
|
||||||
|
API keys use the format: rsk_{environment}_{random}
|
||||||
|
|
||||||
|
rsk_live_a1b2c3d4e5f6... (production)
|
||||||
|
rsk_test_x7y8z9a0b1c2... (development/test)
|
||||||
|
|
||||||
|
The prefix (rsk_live_, rsk_test_) is stored in key_prefix for identification.
|
||||||
|
Only the hash of the full key is stored.
|
||||||
|
|
||||||
|
ROLE OVERRIDE
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The user_role_id column allows API keys to have reduced permissions compared
|
||||||
|
to the user's actual role. This enables creating restricted-access keys.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- NULL: Key inherits user's actual role
|
||||||
|
- Set value: Key uses specified role IF user has permission to assign it
|
||||||
|
|
||||||
|
Role assignment follows ACL rules - a user cannot create an API key with
|
||||||
|
higher privileges than their own role allows. Even if a privileged role ID
|
||||||
|
is set on an API key record, the system will not grant access beyond what
|
||||||
|
the user themselves has.
|
||||||
|
|
||||||
|
Example: A "Member" user cannot create an API key with "Admin" privileges.
|
||||||
|
If such a record exists (e.g., from direct DB manipulation), the system
|
||||||
|
ignores the elevated role and uses the user's actual permissions.
|
||||||
|
|
||||||
|
This behavior depends on the ACL system implementation (see: acls.txt).
|
||||||
|
|
||||||
|
AUTHENTICATION FLOW (PLANNED)
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
1. Client sends request with API key in header:
|
||||||
|
Authorization: Bearer rsk_live_a1b2c3d4...
|
||||||
|
|
||||||
|
2. System extracts key, hashes it, looks up in _api_keys
|
||||||
|
|
||||||
|
3. If valid and not revoked/expired:
|
||||||
|
- Load associated user
|
||||||
|
- Establish session context (user_id, site_id, role)
|
||||||
|
- Apply role override if set
|
||||||
|
- Update last_used_at
|
||||||
|
|
||||||
|
4. Request proceeds with user context available via Session class
|
||||||
|
|
||||||
|
5. Controller code uses Session::user(), Session::check() as normal -
|
||||||
|
no awareness needed of API vs browser authentication
|
||||||
|
|
||||||
|
USAGE (PLANNED)
|
||||||
|
---------------
|
||||||
|
|
||||||
|
For API consumers:
|
||||||
|
|
||||||
|
curl -H "Authorization: Bearer rsk_live_xxx" https://example.com/api/endpoint
|
||||||
|
|
||||||
|
For developers checking auth context:
|
||||||
|
|
||||||
|
// Works identically for browser sessions and API keys
|
||||||
|
if (Session::check()) {
|
||||||
|
$user = Session::user();
|
||||||
|
// ... handle authenticated request
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current request is API-authenticated
|
||||||
|
if (Session::is_api_request()) {
|
||||||
|
// ... API-specific logic if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
RATE LIMITING (FUTURE)
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
Planned features:
|
||||||
|
- Per-key rate limits
|
||||||
|
- Configurable limits per role/plan
|
||||||
|
- Usage tracking and analytics
|
||||||
|
- Automatic throttling with appropriate HTTP responses
|
||||||
|
|
||||||
|
IP WHITELISTING (FUTURE)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Planned features:
|
||||||
|
- Optional IP restrictions per key
|
||||||
|
- CIDR notation support
|
||||||
|
- Automatic rejection of requests from non-whitelisted IPs
|
||||||
|
|
||||||
|
KEY MANAGEMENT UI
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Users manage their API keys via Settings > API Keys:
|
||||||
|
|
||||||
|
- View existing keys (name, prefix, created, last used)
|
||||||
|
- Generate new keys (name required)
|
||||||
|
- Revoke keys (soft delete via is_revoked flag)
|
||||||
|
- Copy key to clipboard (full key shown only on creation)
|
||||||
|
|
||||||
|
The full API key is shown only once at creation time. Users must copy it
|
||||||
|
immediately as the system only stores the hash.
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
--------
|
||||||
|
|
||||||
|
acls.txt - Access control and role system
|
||||||
|
session.txt - Session management
|
||||||
@@ -211,12 +211,12 @@ WIDGET INTERFACE
|
|||||||
val() {
|
val() {
|
||||||
// Getter - return current value
|
// Getter - return current value
|
||||||
if (arguments.length === 0) {
|
if (arguments.length === 0) {
|
||||||
return this.$id('input').val();
|
return this.$sid('input').val();
|
||||||
}
|
}
|
||||||
// Setter - update value
|
// Setter - update value
|
||||||
else {
|
else {
|
||||||
this.data.value = value || '';
|
this.data.value = value || '';
|
||||||
this.$id('input').val(this.data.value);
|
this.$sid('input').val(this.data.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ CREATING CUSTOM WIDGETS
|
|||||||
<Define:Rating_Input class="Widget">
|
<Define:Rating_Input class="Widget">
|
||||||
<div class="rating">
|
<div class="rating">
|
||||||
<% for (let i = 1; i <= 5; i++) { %>
|
<% for (let i = 1; i <= 5; i++) { %>
|
||||||
<i $id="star_<%= i %>"
|
<i $sid="star_<%= i %>"
|
||||||
class="bi bi-star<%= this.data.value >= i ? '-fill' : '' %>"
|
class="bi bi-star<%= this.data.value >= i ? '-fill' : '' %>"
|
||||||
data-rating="<%= i %>"></i>
|
data-rating="<%= i %>"></i>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ EXAMPLES
|
|||||||
<% if (this.args.show_price === "true") { %>
|
<% if (this.args.show_price === "true") { %>
|
||||||
<p class="price">$<%= this.data.product.price %></p>
|
<p class="price">$<%= this.data.product.price %></p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<button $id="cart_btn">Add to Cart</button>
|
<button $sid="cart_btn">Add to Cart</button>
|
||||||
<% } %>
|
<% } %>
|
||||||
</Define:Product_Card>
|
</Define:Product_Card>
|
||||||
|
|
||||||
|
|||||||
@@ -300,21 +300,21 @@ Your form component must:
|
|||||||
- Extend Jqhtml_Component
|
- Extend Jqhtml_Component
|
||||||
- Implement vals() method for getting/setting values
|
- Implement vals() method for getting/setting values
|
||||||
- Use standard form HTML with name attributes
|
- Use standard form HTML with name attributes
|
||||||
- Include error container: <div $id="error_container"></div>
|
- Include error container: <div $sid="error_container"></div>
|
||||||
|
|
||||||
Example form component (my_form.jqhtml):
|
Example form component (my_form.jqhtml):
|
||||||
|
|
||||||
<Define:My_Form tag="div">
|
<Define:My_Form tag="div">
|
||||||
<div $id="error_container"></div>
|
<div $sid="error_container"></div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Name</label>
|
<label class="form-label">Name</label>
|
||||||
<input type="text" class="form-control" $id="name_input" name="name">
|
<input type="text" class="form-control" $sid="name_input" name="name">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<input type="email" class="form-control" $id="email_input" name="email">
|
<input type="email" class="form-control" $sid="email_input" name="email">
|
||||||
</div>
|
</div>
|
||||||
</Define:My_Form>
|
</Define:My_Form>
|
||||||
|
|
||||||
@@ -334,14 +334,14 @@ Example form component class (my_form.js):
|
|||||||
vals(values) {
|
vals(values) {
|
||||||
if (values) {
|
if (values) {
|
||||||
// Setter
|
// Setter
|
||||||
this.$id('name_input').val(values.name || '');
|
this.$sid('name_input').val(values.name || '');
|
||||||
this.$id('email_input').val(values.email || '');
|
this.$sid('email_input').val(values.email || '');
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
// Getter
|
// Getter
|
||||||
return {
|
return {
|
||||||
name: this.$id('name_input').val(),
|
name: this.$sid('name_input').val(),
|
||||||
email: this.$id('email_input').val()
|
email: this.$sid('email_input').val()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -985,7 +985,7 @@ Modal Won't Close
|
|||||||
- Use Modal.close() to force close
|
- Use Modal.close() to force close
|
||||||
|
|
||||||
Validation Errors Not Showing
|
Validation Errors Not Showing
|
||||||
- Ensure form has <div $id="error_container"></div>
|
- Ensure form has <div $sid="error_container"></div>
|
||||||
- Verify field name attributes match error keys
|
- Verify field name attributes match error keys
|
||||||
- Check that fields are wrapped in .form-group containers
|
- Check that fields are wrapped in .form-group containers
|
||||||
- Use Form_Utils.apply_form_errors(form.$, errors)
|
- Use Form_Utils.apply_form_errors(form.$, errors)
|
||||||
|
|||||||
@@ -99,12 +99,11 @@ Models can opt-in to client-side fetching by implementing fetch() with
|
|||||||
|
|
||||||
JavaScript usage:
|
JavaScript usage:
|
||||||
const product = await Product_Model.fetch(1);
|
const product = await Product_Model.fetch(1);
|
||||||
const products = await Product_Model.fetch([1, 2, 3]);
|
console.log(product.status_label); // Enum properties populated
|
||||||
|
console.log(Product_Model.STATUS_ACTIVE); // Static enum constants
|
||||||
|
|
||||||
Framework automatically splits array IDs into individual fetch() calls for
|
Returns instantiated JS model class with enum properties and optional custom
|
||||||
security (no mass fetching).
|
methods. See: php artisan rsx:man model_fetch
|
||||||
|
|
||||||
See: php artisan rsx:man model_fetch
|
|
||||||
|
|
||||||
RELATIONSHIPS
|
RELATIONSHIPS
|
||||||
|
|
||||||
@@ -349,6 +348,11 @@ Columns:
|
|||||||
- Use BIGINT for all integers, TINYINT(1) for booleans only
|
- Use BIGINT for all integers, TINYINT(1) for booleans only
|
||||||
- All text columns use UTF-8 (utf8mb4_unicode_ci collation)
|
- All text columns use UTF-8 (utf8mb4_unicode_ci collation)
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
- Real-time notifications: Broadcast model changes to connected clients
|
||||||
|
- Revision tracking: Auto-increment revision column via database trigger on update
|
||||||
|
|
||||||
SEE ALSO
|
SEE ALSO
|
||||||
|
|
||||||
php artisan rsx:man model_fetch - Ajax ORM fetch system
|
php artisan rsx:man model_fetch - Ajax ORM fetch system
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
MODEL_FETCH(3) RSX Framework Manual MODEL_FETCH(3)
|
||||||
|
|
||||||
NAME
|
NAME
|
||||||
model_fetch - RSX Ajax ORM with secure model fetching from JavaScript
|
model_fetch - RSX Ajax ORM with secure model fetching from JavaScript
|
||||||
|
|
||||||
@@ -20,6 +22,22 @@ DESCRIPTION
|
|||||||
- Individual authorization checks for each record
|
- Individual authorization checks for each record
|
||||||
- Automatic JavaScript stub generation
|
- Automatic JavaScript stub generation
|
||||||
|
|
||||||
|
STATUS (as of 2025-11-23)
|
||||||
|
The Model Fetch system is fully implemented for application development MVP.
|
||||||
|
All core functionality documented in this manual is production-ready:
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
- fetch() and fetch_or_null() methods
|
||||||
|
- Lazy relationship loading (belongsTo, hasMany, morphTo, etc.)
|
||||||
|
- Enum properties on instances ({column}_{field} pattern)
|
||||||
|
- Static enum constants and accessor methods
|
||||||
|
- Automatic model hydration from Ajax responses
|
||||||
|
- JavaScript class hierarchy with Base_* stubs
|
||||||
|
- Authorization patterns and security model
|
||||||
|
|
||||||
|
Future enhancements (documented below under FUTURE DEVELOPMENT) will be
|
||||||
|
additive and non-breaking to the existing API.
|
||||||
|
|
||||||
SECURITY MODEL
|
SECURITY MODEL
|
||||||
Explicit Opt-In:
|
Explicit Opt-In:
|
||||||
Models must deliberately implement fetch() with the attribute.
|
Models must deliberately implement fetch() with the attribute.
|
||||||
@@ -33,9 +51,9 @@ SECURITY MODEL
|
|||||||
Models can filter sensitive fields before returning data.
|
Models can filter sensitive fields before returning data.
|
||||||
Complete control over what data JavaScript receives.
|
Complete control over what data JavaScript receives.
|
||||||
|
|
||||||
No Mass Fetching:
|
Single Record Fetching:
|
||||||
Framework splits array requests into individual fetch calls.
|
Each fetch() call retrieves exactly one record.
|
||||||
Prevents bulk data extraction without individual authorization.
|
See FUTURE DEVELOPMENT for planned batch fetching.
|
||||||
|
|
||||||
IMPLEMENTING FETCHABLE MODELS
|
IMPLEMENTING FETCHABLE MODELS
|
||||||
Required Components:
|
Required Components:
|
||||||
@@ -43,7 +61,12 @@ IMPLEMENTING FETCHABLE MODELS
|
|||||||
2. Add #[Ajax_Endpoint_Model_Fetch] attribute
|
2. Add #[Ajax_Endpoint_Model_Fetch] attribute
|
||||||
3. Accept exactly one parameter: $id (single ID only)
|
3. Accept exactly one parameter: $id (single ID only)
|
||||||
4. Implement authorization checks
|
4. Implement authorization checks
|
||||||
5. Return model object or false
|
5. Return model object, custom array, or false
|
||||||
|
|
||||||
|
Return Value Options:
|
||||||
|
- Model object: Serialized via to_export_array(), includes __MODEL for hydration
|
||||||
|
- Custom array: Full control over data shape, include computed/formatted fields
|
||||||
|
- false: Record not found or unauthorized (MUST be false, not null)
|
||||||
|
|
||||||
Basic Implementation:
|
Basic Implementation:
|
||||||
use Ajax_Endpoint_Model_Fetch;
|
use Ajax_Endpoint_Model_Fetch;
|
||||||
@@ -54,7 +77,7 @@ IMPLEMENTING FETCHABLE MODELS
|
|||||||
public static function fetch($id)
|
public static function fetch($id)
|
||||||
{
|
{
|
||||||
// Authorization check
|
// Authorization check
|
||||||
if (!RsxAuth::check()) {
|
if (!Session::is_logged_in()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +93,7 @@ IMPLEMENTING FETCHABLE MODELS
|
|||||||
#[Ajax_Endpoint_Model_Fetch]
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
public static function fetch($id)
|
public static function fetch($id)
|
||||||
{
|
{
|
||||||
$user = RsxAuth::user();
|
$user = Session::get_user();
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -95,7 +118,7 @@ IMPLEMENTING FETCHABLE MODELS
|
|||||||
#[Ajax_Endpoint_Model_Fetch]
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
public static function fetch($id)
|
public static function fetch($id)
|
||||||
{
|
{
|
||||||
if (!RsxAuth::check()) {
|
if (!Session::is_logged_in()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,98 +136,180 @@ IMPLEMENTING FETCHABLE MODELS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
JAVASCRIPT USAGE
|
Custom Array Return (recommended for CRUD pages):
|
||||||
Single Record Fetching:
|
class Client_Model extends Rsx_Model
|
||||||
// Fetch single record
|
|
||||||
const product = await Product_Model.fetch(1);
|
|
||||||
if (product) {
|
|
||||||
console.log(product.name);
|
|
||||||
console.log(product.price);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle fetch failure
|
|
||||||
const order = await Order_Model.fetch(999);
|
|
||||||
if (!order) {
|
|
||||||
console.log('Order not found or access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
Multiple Record Fetching:
|
|
||||||
// Framework automatically splits array into individual calls
|
|
||||||
const products = await Product_Model.fetch([1, 2, 3]);
|
|
||||||
products.forEach(product => {
|
|
||||||
if (product) {
|
|
||||||
console.log(product.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mixed results (some succeed, some fail authorization)
|
|
||||||
const orders = await Order_Model.fetch([101, 102, 103]);
|
|
||||||
const validOrders = orders.filter(order => order !== false);
|
|
||||||
|
|
||||||
Error Handling:
|
|
||||||
try {
|
|
||||||
const user = await User_Model.fetch(userId);
|
|
||||||
if (user) {
|
|
||||||
updateUserInterface(user);
|
|
||||||
} else {
|
|
||||||
showAccessDeniedMessage();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fetch failed:', error);
|
|
||||||
showErrorMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
ARRAY HANDLING
|
|
||||||
Framework Behavior:
|
|
||||||
When JavaScript passes an array to fetch(), the framework:
|
|
||||||
1. Splits array into individual IDs
|
|
||||||
2. Calls fetch() once for each ID
|
|
||||||
3. Collects results maintaining array order
|
|
||||||
4. Returns array with same length (false for failed fetches)
|
|
||||||
|
|
||||||
Implementation Rules:
|
|
||||||
- NEVER use is_array($id) checks in fetch() method
|
|
||||||
- Always handle exactly one ID parameter
|
|
||||||
- Framework handles array splitting automatically
|
|
||||||
- Results maintain original array order
|
|
||||||
|
|
||||||
Example Results:
|
|
||||||
// JavaScript call
|
|
||||||
const results = await Product_Model.fetch([1, 2, 999]);
|
|
||||||
|
|
||||||
// Results array (999 not found or unauthorized)
|
|
||||||
[
|
|
||||||
{id: 1, name: "Product A"}, // Successful fetch
|
|
||||||
{id: 2, name: "Product B"}, // Successful fetch
|
|
||||||
false // Failed fetch
|
|
||||||
]
|
|
||||||
|
|
||||||
STUB GENERATION
|
|
||||||
Automatic JavaScript Stubs:
|
|
||||||
The framework automatically generates JavaScript stub classes
|
|
||||||
for models with #[Ajax_Endpoint_Model_Fetch] attributes.
|
|
||||||
|
|
||||||
Stub Class Generation:
|
|
||||||
// Generated stub for Product_Model
|
|
||||||
class Product_Model {
|
|
||||||
static async fetch(id) {
|
|
||||||
// Generated implementation calls PHP fetch() method
|
|
||||||
return await Rsx._internal_api_call('Product_Model', 'fetch', {id});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Bundle Integration:
|
|
||||||
Stubs are automatically included in JavaScript bundles when
|
|
||||||
models are discovered in the bundle's include paths.
|
|
||||||
|
|
||||||
AUTHORIZATION PATTERNS
|
|
||||||
User-Specific Access:
|
|
||||||
class User_Profile_Model extends Rsx_Model
|
|
||||||
{
|
{
|
||||||
#[Ajax_Endpoint_Model_Fetch]
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
public static function fetch($id)
|
public static function fetch($id)
|
||||||
{
|
{
|
||||||
$current_user = RsxAuth::user();
|
$client = static::withTrashed()->find($id);
|
||||||
|
if (!$client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return formatted array with computed fields
|
||||||
|
return [
|
||||||
|
'id' => $client->id,
|
||||||
|
'name' => $client->name,
|
||||||
|
'status' => $client->status,
|
||||||
|
// Computed display fields
|
||||||
|
'status_label' => ucfirst($client->status),
|
||||||
|
'status_badge' => match($client->status) {
|
||||||
|
'active' => 'bg-success',
|
||||||
|
'inactive' => 'bg-secondary',
|
||||||
|
default => 'bg-warning'
|
||||||
|
},
|
||||||
|
// Formatted dates
|
||||||
|
'created_at_formatted' => $client->created_at->format('M d, Y'),
|
||||||
|
'created_at_human' => $client->created_at->diffForHumans(),
|
||||||
|
// Related data
|
||||||
|
'region_name' => $client->region_name(),
|
||||||
|
'country_name' => $client->country_name(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: When returning an array, the JavaScript side receives the plain
|
||||||
|
object (not a hydrated model instance). This is intentional - it gives
|
||||||
|
full control over the data shape for view/edit pages.
|
||||||
|
|
||||||
|
JAVASCRIPT USAGE
|
||||||
|
Single Record Fetching:
|
||||||
|
// fetch() throws if record not found - no need to check for null/false
|
||||||
|
const project = await Project_Model.fetch(1);
|
||||||
|
console.log(project.name);
|
||||||
|
console.log(project.status_label); // Enum properties populated
|
||||||
|
|
||||||
|
// fetch() errors are caught by Universal_Error_Page_Component automatically
|
||||||
|
// in SPA actions, or can be caught with try/catch if needed
|
||||||
|
|
||||||
|
Fetch With Null Fallback:
|
||||||
|
// Use fetch_or_null() when you want graceful handling of missing records
|
||||||
|
const order = await Order_Model.fetch_or_null(999);
|
||||||
|
if (!order) {
|
||||||
|
console.log('Order not found or access denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// order is guaranteed to exist here
|
||||||
|
|
||||||
|
When to Use Each:
|
||||||
|
- fetch() - View/edit pages where record MUST exist (throws on not found)
|
||||||
|
- fetch_or_null() - Optional lookups where missing is valid (returns null)
|
||||||
|
|
||||||
|
Enum Properties on Instances:
|
||||||
|
const project = await Project_Model.fetch(1);
|
||||||
|
|
||||||
|
// All enum helper properties from PHP are available
|
||||||
|
console.log(project.status_id); // 2 (raw value)
|
||||||
|
console.log(project.status_id_label); // "Active"
|
||||||
|
console.log(project.status_id_badge); // "bg-success"
|
||||||
|
|
||||||
|
Static Enum Constants:
|
||||||
|
// Constants available on the class
|
||||||
|
if (project.status_id === Project_Model.STATUS_ACTIVE) {
|
||||||
|
console.log('Project is active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all enum values for dropdowns
|
||||||
|
const statusOptions = Project_Model.status_id_enum_select();
|
||||||
|
// {1: "Planning", 2: "Active", 3: "On Hold", ...}
|
||||||
|
|
||||||
|
// Get full enum config
|
||||||
|
const statusConfig = Project_Model.status_id_enum_val();
|
||||||
|
// {1: {label: "Planning", badge: "bg-info"}, ...}
|
||||||
|
|
||||||
|
Error Handling:
|
||||||
|
// In SPA actions, errors bubble up to Universal_Error_Page_Component
|
||||||
|
// No try/catch needed - just call fetch() and use the result
|
||||||
|
async on_load() {
|
||||||
|
this.data.user = await User_Model.fetch(this.args.id);
|
||||||
|
// If we get here, user exists and we have access
|
||||||
|
}
|
||||||
|
|
||||||
|
// For explicit error handling outside SPA context:
|
||||||
|
try {
|
||||||
|
const user = await User_Model.fetch(userId);
|
||||||
|
updateUserInterface(user);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === Ajax.ERROR_NOT_FOUND) {
|
||||||
|
showNotFoundMessage();
|
||||||
|
} else {
|
||||||
|
showErrorMessage(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JAVASCRIPT CLASS ARCHITECTURE
|
||||||
|
Class Hierarchy:
|
||||||
|
The framework generates a three-level class hierarchy for each model:
|
||||||
|
|
||||||
|
Rsx_Js_Model // Framework base (fetch, refresh, toObject)
|
||||||
|
└── Base_Project_Model // Generated stub (enums, constants, relationships)
|
||||||
|
└── Project_Model // Concrete class (auto-generated or user-defined)
|
||||||
|
|
||||||
|
Base Stub Classes (Auto-Generated):
|
||||||
|
For each PHP model extending Rsx_Model_Abstract, the framework generates
|
||||||
|
a Base_* stub class with:
|
||||||
|
|
||||||
|
class Base_Project_Model extends Rsx_Js_Model {
|
||||||
|
static __MODEL = 'Project_Model'; // PHP class name for API calls
|
||||||
|
|
||||||
|
// Enum constants
|
||||||
|
static STATUS_PLANNING = 1;
|
||||||
|
static STATUS_ACTIVE = 2;
|
||||||
|
|
||||||
|
// Enum accessor methods
|
||||||
|
static status_enum_val() { ... } // Full enum config
|
||||||
|
static status_enum_select() { ... } // For dropdown population
|
||||||
|
|
||||||
|
// Relationship discovery
|
||||||
|
static get_relationships() { ... } // Returns array of names
|
||||||
|
|
||||||
|
// Relationship methods (async, lazy-loaded)
|
||||||
|
async client() { ... } // belongsTo → Model or null
|
||||||
|
async tasks() { ... } // hasMany → Model[]
|
||||||
|
}
|
||||||
|
|
||||||
|
Concrete Classes:
|
||||||
|
Concrete classes (without Base_ prefix) are what you use in application code.
|
||||||
|
They are either auto-generated or user-defined.
|
||||||
|
|
||||||
|
Auto-Generated (default):
|
||||||
|
If no custom JS file exists, the bundle compiler generates:
|
||||||
|
class Project_Model extends Base_Project_Model {}
|
||||||
|
|
||||||
|
User-Defined (optional):
|
||||||
|
Create a JS file with matching class name to add custom methods:
|
||||||
|
|
||||||
|
// rsx/models/Project_Model.js
|
||||||
|
class Project_Model extends Base_Project_Model {
|
||||||
|
get_display_title() {
|
||||||
|
return `${this.name} (${this.status_label})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Custom Base Class (Optional):
|
||||||
|
Configure a custom base class in rsx/resource/config/rsx.php to add
|
||||||
|
application-wide model functionality:
|
||||||
|
|
||||||
|
'js_model_base_class' => 'App_Model_Abstract',
|
||||||
|
|
||||||
|
Hierarchy becomes:
|
||||||
|
Rsx_Js_Model → App_Model_Abstract → Base_Project_Model → Project_Model
|
||||||
|
|
||||||
|
Bundle Integration:
|
||||||
|
- Base_* stubs auto-included when PHP model is in bundle
|
||||||
|
- Concrete classes auto-generated unless user-defined JS exists
|
||||||
|
- User-defined JS classes validated to extend Base_* directly
|
||||||
|
- Error thrown if custom JS exists in manifest but not in bundle
|
||||||
|
|
||||||
|
AUTHORIZATION PATTERNS
|
||||||
|
User-Specific Access:
|
||||||
|
class User_Profile_Model extends Rsx_Model_Abstract
|
||||||
|
{
|
||||||
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
|
public static function fetch($id)
|
||||||
|
{
|
||||||
|
$current_user = Session::get_user();
|
||||||
|
|
||||||
// Users can only fetch their own profile
|
// Users can only fetch their own profile
|
||||||
if (!$current_user || $current_user->id != $id) {
|
if (!$current_user || $current_user->id != $id) {
|
||||||
@@ -216,12 +321,12 @@ AUTHORIZATION PATTERNS
|
|||||||
}
|
}
|
||||||
|
|
||||||
Role-Based Access:
|
Role-Based Access:
|
||||||
class Admin_Report_Model extends Rsx_Model
|
class Admin_Report_Model extends Rsx_Model_Abstract
|
||||||
{
|
{
|
||||||
#[Ajax_Endpoint_Model_Fetch]
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
public static function fetch($id)
|
public static function fetch($id)
|
||||||
{
|
{
|
||||||
$user = RsxAuth::user();
|
$user = Session::get_user();
|
||||||
|
|
||||||
// Only admin users can fetch reports
|
// Only admin users can fetch reports
|
||||||
if (!$user || !$user->hasRole('admin')) {
|
if (!$user || !$user->hasRole('admin')) {
|
||||||
@@ -233,7 +338,7 @@ AUTHORIZATION PATTERNS
|
|||||||
}
|
}
|
||||||
|
|
||||||
Public Data Access:
|
Public Data Access:
|
||||||
class Public_Article_Model extends Rsx_Model
|
class Public_Article_Model extends Rsx_Model_Abstract
|
||||||
{
|
{
|
||||||
#[Ajax_Endpoint_Model_Fetch]
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
public static function fetch($id)
|
public static function fetch($id)
|
||||||
@@ -266,16 +371,51 @@ BASE MODEL PROTECTION
|
|||||||
- Provides clear implementation guidance
|
- Provides clear implementation guidance
|
||||||
- Ensures no models are fetchable by default
|
- Ensures no models are fetchable by default
|
||||||
|
|
||||||
|
CODE QUALITY VALIDATION
|
||||||
|
The framework validates #[Ajax_Endpoint_Model_Fetch] attribute placement at
|
||||||
|
manifest build time. The attribute can ONLY be applied to:
|
||||||
|
|
||||||
|
1. Methods marked with #[Relationship] - exposes relationship to JavaScript
|
||||||
|
2. The static fetch($id) method - enables Model.fetch() in JavaScript
|
||||||
|
|
||||||
|
Invalid Placement:
|
||||||
|
// This will fail code quality check (MODEL-AJAX-FETCH-01)
|
||||||
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
|
public function get_display_name() // Not a relationship or fetch()
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
For Custom Server-Side Methods:
|
||||||
|
If you need a custom method accessible from JavaScript:
|
||||||
|
1. Create a JS class extending Base_{ModelName}
|
||||||
|
2. Create an Ajax endpoint on an appropriate controller
|
||||||
|
3. Add the method to the JS class calling the Ajax endpoint
|
||||||
|
|
||||||
|
Example:
|
||||||
|
// PHP Controller
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function get_project_stats(Request $request, array $params = []) {
|
||||||
|
return Project_Model::calculate_stats($params['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript (rsx/models/Project_Model.js)
|
||||||
|
class Project_Model extends Base_Project_Model {
|
||||||
|
async get_stats() {
|
||||||
|
return await Project_Controller.get_project_stats({id: this.id});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TESTING FETCH METHODS
|
TESTING FETCH METHODS
|
||||||
PHP Testing:
|
PHP Testing:
|
||||||
// Test authorization
|
// Test authorization
|
||||||
$user = User_Model::factory()->create();
|
$user = User_Model::factory()->create();
|
||||||
RsxAuth::login($user);
|
Session::login($user);
|
||||||
|
|
||||||
$product = Product_Model::fetch(1);
|
$product = Product_Model::fetch(1);
|
||||||
$this->assertNotFalse($product);
|
$this->assertNotFalse($product);
|
||||||
|
|
||||||
RsxAuth::logout();
|
Session::logout();
|
||||||
$product = Product_Model::fetch(1);
|
$product = Product_Model::fetch(1);
|
||||||
$this->assertFalse($product);
|
$this->assertFalse($product);
|
||||||
|
|
||||||
@@ -304,7 +444,7 @@ COMMON PATTERNS
|
|||||||
$model = static::find($id);
|
$model = static::find($id);
|
||||||
if (!$model) return false;
|
if (!$model) return false;
|
||||||
|
|
||||||
$user = RsxAuth::user();
|
$user = Session::get_user();
|
||||||
if (!$user || !$user->is_admin) {
|
if (!$user || !$user->is_admin) {
|
||||||
// Remove admin-only fields for non-admin users
|
// Remove admin-only fields for non-admin users
|
||||||
unset($model->internal_notes);
|
unset($model->internal_notes);
|
||||||
@@ -314,6 +454,49 @@ COMMON PATTERNS
|
|||||||
return $model;
|
return $model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AUTOMATIC HYDRATION
|
||||||
|
All Ajax responses are automatically processed to convert objects with a
|
||||||
|
__MODEL property into proper JavaScript class instances. This happens
|
||||||
|
transparently in the Ajax layer.
|
||||||
|
|
||||||
|
How It Works:
|
||||||
|
1. PHP model's toArray() includes __MODEL property with class name
|
||||||
|
2. Ajax layer receives response with __MODEL: "Project_Model"
|
||||||
|
3. Ajax calls Rsx_Js_Model._instantiate_models_recursive() on response
|
||||||
|
4. Hydrator looks up class via Manifest.get_class_by_name()
|
||||||
|
5. If class extends Rsx_Js_Model, creates instance: new Project_Model(data)
|
||||||
|
6. Instance constructor strips __MODEL, assigns remaining properties
|
||||||
|
|
||||||
|
Result:
|
||||||
|
const project = await Project_Model.fetch(1);
|
||||||
|
console.log(project.constructor.name); // "Project_Model"
|
||||||
|
console.log(project instanceof Rsx_Js_Model); // true
|
||||||
|
|
||||||
|
Recursive Hydration:
|
||||||
|
The hydrator processes nested objects and arrays recursively. Any object
|
||||||
|
with __MODEL property at any depth will be instantiated as its class.
|
||||||
|
|
||||||
|
// If PHP returns related models in response:
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Project Alpha",
|
||||||
|
client: {
|
||||||
|
id: 5,
|
||||||
|
name: "Acme Corp",
|
||||||
|
__MODEL: "Client_Model"
|
||||||
|
},
|
||||||
|
__MODEL: "Project_Model"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both objects are hydrated:
|
||||||
|
project instanceof Project_Model; // true
|
||||||
|
project.client instanceof Client_Model; // true
|
||||||
|
|
||||||
|
Global Behavior:
|
||||||
|
This hydration applies to ALL Ajax responses, not just fetch() calls.
|
||||||
|
Any Ajax endpoint returning objects with __MODEL properties will have
|
||||||
|
them automatically instantiated as proper JavaScript class instances.
|
||||||
|
|
||||||
TROUBLESHOOTING
|
TROUBLESHOOTING
|
||||||
Model Not Fetchable:
|
Model Not Fetchable:
|
||||||
- Verify #[Ajax_Endpoint_Model_Fetch] attribute present
|
- Verify #[Ajax_Endpoint_Model_Fetch] attribute present
|
||||||
@@ -322,7 +505,7 @@ TROUBLESHOOTING
|
|||||||
- Verify model included in bundle manifest
|
- Verify model included in bundle manifest
|
||||||
|
|
||||||
Authorization Failures:
|
Authorization Failures:
|
||||||
- Check RsxAuth::check() and RsxAuth::user() values
|
- Check Session::is_logged_in() and Session::get_user() values
|
||||||
- Verify authorization logic in fetch() method
|
- Verify authorization logic in fetch() method
|
||||||
- Test with different user roles and permissions
|
- Test with different user roles and permissions
|
||||||
- Use rsx_dump_die() to debug authorization flow
|
- Use rsx_dump_die() to debug authorization flow
|
||||||
@@ -333,13 +516,458 @@ TROUBLESHOOTING
|
|||||||
- Ensure bundle compiles without errors
|
- Ensure bundle compiles without errors
|
||||||
- Confirm JavaScript bundle loads in browser
|
- Confirm JavaScript bundle loads in browser
|
||||||
|
|
||||||
Array Handling Issues:
|
JAVASCRIPT ORM ARCHITECTURE
|
||||||
- Never use is_array($id) in fetch() method
|
The JavaScript ORM provides secure, read-only access to server-side models
|
||||||
- Framework handles array splitting automatically
|
through explicit opt-in. This section describes the full architecture vision.
|
||||||
- Check for typos in model class names
|
|
||||||
- Verify all IDs in array are valid integers
|
Design Philosophy:
|
||||||
|
- Read-only ORM: No save/create/delete operations from JavaScript
|
||||||
|
- Explicit opt-in: Each model controls its own fetchability
|
||||||
|
- Individual authorization: Per-record security checks
|
||||||
|
- No query builder: Search/filter handled by dedicated Ajax endpoints
|
||||||
|
- Instance methods: Records come as objects with helper methods
|
||||||
|
|
||||||
|
What JavaScript ORM DOES:
|
||||||
|
- Fetch individual records by ID
|
||||||
|
- Provide enum helper methods on instances
|
||||||
|
- Load related records via lazy relationship methods
|
||||||
|
- Access attachments associated with records (see FUTURE DEVELOPMENT)
|
||||||
|
- Receive real-time updates via websocket (see FUTURE DEVELOPMENT)
|
||||||
|
|
||||||
|
What JavaScript ORM does NOT do:
|
||||||
|
- Save or update records (use Ajax endpoints)
|
||||||
|
- Create new records (use Ajax endpoints)
|
||||||
|
- Delete records (use Ajax endpoints)
|
||||||
|
- Search or query records (use DataGrid or Ajax)
|
||||||
|
- Eager load relationships (lazy load only)
|
||||||
|
|
||||||
|
LAZY RELATIONSHIPS
|
||||||
|
Related records load through async methods that mirror PHP relationships.
|
||||||
|
Each relationship method returns a Promise resolving to the related model(s).
|
||||||
|
|
||||||
|
Supported Relationship Types:
|
||||||
|
- belongsTo → Returns single model instance or null
|
||||||
|
- hasOne → Returns single model instance or null
|
||||||
|
- hasMany → Returns array of model instances (may be empty)
|
||||||
|
- morphTo → Returns single model instance or null
|
||||||
|
- morphOne → Returns single model instance or null
|
||||||
|
- morphMany → Returns array of model instances (may be empty)
|
||||||
|
|
||||||
|
JavaScript Usage:
|
||||||
|
// Load belongsTo relationship
|
||||||
|
const contact = await Contact_Model.fetch(123);
|
||||||
|
const client = await contact.client(); // Returns Client_Model or null
|
||||||
|
|
||||||
|
// Load hasMany relationship
|
||||||
|
const client = await Client_Model.fetch(456);
|
||||||
|
const contacts = await client.contacts(); // Returns Contact_Model[]
|
||||||
|
|
||||||
|
// Load morphMany relationship
|
||||||
|
const project = await Project_Model.fetch(1);
|
||||||
|
const tasks = await project.tasks(); // Returns Task_Model[]
|
||||||
|
|
||||||
|
// Chain relationships
|
||||||
|
const contact = await Contact_Model.fetch(123);
|
||||||
|
const client = await contact.client();
|
||||||
|
if (client) {
|
||||||
|
const contacts = await client.contacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Get Available Relationships:
|
||||||
|
// Static method returns array of relationship names
|
||||||
|
const rels = Project_Model.get_relationships();
|
||||||
|
// ["client", "contact", "tasks", "created_by", "owner"]
|
||||||
|
|
||||||
|
Security Model:
|
||||||
|
1. Source model's fetch() called first to verify access to parent record
|
||||||
|
2. Relationship method called to get related IDs (efficient pluck query)
|
||||||
|
3. Each related record passed through its model's fetch() for authorization
|
||||||
|
4. Only records passing fetch() security check are returned
|
||||||
|
|
||||||
|
Enabling Relationships for Ajax Fetch:
|
||||||
|
Relationships require BOTH attributes to be fetchable from JavaScript:
|
||||||
|
|
||||||
|
#[Relationship]
|
||||||
|
#[Ajax_Endpoint_Model_Fetch]
|
||||||
|
public function contacts()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Contact_Model::class, 'client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
Without #[Ajax_Endpoint_Model_Fetch], the relationship will not appear in
|
||||||
|
get_relationships() and attempting to fetch it will return an error message
|
||||||
|
instructing the developer to add the attribute.
|
||||||
|
|
||||||
|
Implementation Notes:
|
||||||
|
- #[Relationship] defines the method as a Laravel relationship
|
||||||
|
- #[Ajax_Endpoint_Model_Fetch] exposes it to the JavaScript ORM
|
||||||
|
- Only relationships with BOTH attributes are included in JS stubs
|
||||||
|
- Related model must also have fetch() with #[Ajax_Endpoint_Model_Fetch]
|
||||||
|
- Singular relationships return null if not found or unauthorized
|
||||||
|
- Plural relationships return empty array if none found/authorized
|
||||||
|
|
||||||
|
ENUM PROPERTIES
|
||||||
|
Enum values are exposed as properties on fetched model instances, mirroring
|
||||||
|
the PHP magic property behavior. Each custom field defined in the enum
|
||||||
|
becomes a property named {column}_{field}.
|
||||||
|
|
||||||
|
PHP Enum Definition:
|
||||||
|
public static $enums = [
|
||||||
|
'status_id' => [
|
||||||
|
1 => ['constant' => 'STATUS_PLANNING', 'label' => 'Planning', 'badge' => 'bg-info'],
|
||||||
|
2 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success'],
|
||||||
|
3 => ['constant' => 'STATUS_ON_HOLD', 'label' => 'On Hold', 'badge' => 'bg-warning'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
Resulting JavaScript Instance Properties:
|
||||||
|
const project = await Project_Model.fetch(123);
|
||||||
|
|
||||||
|
// Raw enum value
|
||||||
|
project.status_id // 2
|
||||||
|
|
||||||
|
// Auto-generated properties from enum definition
|
||||||
|
project.status_id_label // "Active"
|
||||||
|
project.status_id_badge // "bg-success"
|
||||||
|
|
||||||
|
// All custom fields become properties
|
||||||
|
// If enum had 'button_class' => 'btn-success':
|
||||||
|
project.status_id_button_class // "btn-success"
|
||||||
|
|
||||||
|
Static Enum Constants:
|
||||||
|
// Constants available on the class (from 'constant' field)
|
||||||
|
if (project.status_id === Project_Model.STATUS_ACTIVE) {
|
||||||
|
console.log('Project is active');
|
||||||
|
}
|
||||||
|
|
||||||
|
Static Enum Methods:
|
||||||
|
// Get enum values for dropdown population (id => label)
|
||||||
|
const statusOptions = Project_Model.status_id_enum_select();
|
||||||
|
// {1: "Planning", 2: "Active", 3: "On Hold"}
|
||||||
|
|
||||||
|
// Get full enum config (id => all fields)
|
||||||
|
const statusConfig = Project_Model.status_id_enum_val();
|
||||||
|
// {1: {label: "Planning", badge: "bg-info"}, 2: {...}, ...}
|
||||||
|
|
||||||
|
===============================================================================
|
||||||
|
FUTURE DEVELOPMENT
|
||||||
|
===============================================================================
|
||||||
|
|
||||||
|
The features below are planned enhancements. They will be additive and will not
|
||||||
|
break the existing API. Implementation priority and timeline are not committed.
|
||||||
|
|
||||||
|
ATTACHMENTS INTEGRATION (planned)
|
||||||
|
File attachments accessible through model instances.
|
||||||
|
|
||||||
|
JavaScript Usage (API TBD):
|
||||||
|
const project = await Project_Model.fetch(123);
|
||||||
|
|
||||||
|
// Get all attachments
|
||||||
|
const attachments = await project.attachments();
|
||||||
|
|
||||||
|
// Get specific attachment type
|
||||||
|
const documents = await project.attachments('documents');
|
||||||
|
|
||||||
|
// Get attachment URLs
|
||||||
|
attachments.forEach(att => {
|
||||||
|
console.log(att.url); // View URL
|
||||||
|
console.log(att.download_url); // Download URL
|
||||||
|
console.log(att.thumbnail_url); // Thumbnail (images)
|
||||||
|
});
|
||||||
|
|
||||||
|
DATE HANDLING (planned)
|
||||||
|
Date fields converted to JavaScript Date objects or date library instances.
|
||||||
|
Implementation details TBD - may use native Date or dayjs.
|
||||||
|
|
||||||
|
JavaScript Usage (API TBD):
|
||||||
|
const project = await Project_Model.fetch(123);
|
||||||
|
|
||||||
|
// Access date fields
|
||||||
|
project.created_at // Date object or dayjs instance
|
||||||
|
project.due_date // Date object or dayjs instance
|
||||||
|
|
||||||
|
// Format dates
|
||||||
|
project.created_at.format('YYYY-MM-DD') // If dayjs
|
||||||
|
project.created_at.toLocaleDateString() // If native Date
|
||||||
|
|
||||||
|
REAL-TIME UPDATES (planned)
|
||||||
|
Model instances can receive real-time updates via websocket.
|
||||||
|
|
||||||
|
JavaScript Usage (API TBD):
|
||||||
|
const project = await Project_Model.fetch(123);
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
project.subscribe((updatedProject) => {
|
||||||
|
// Called when server broadcasts changes
|
||||||
|
updateUI(updatedProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or use event-based approach
|
||||||
|
project.on('update', (changes) => {
|
||||||
|
// Partial update with changed fields
|
||||||
|
});
|
||||||
|
|
||||||
|
TAGS SYSTEM (future)
|
||||||
|
Tags integration for taggable models (implementation TBD).
|
||||||
|
|
||||||
|
Potential API:
|
||||||
|
const project = await Project_Model.fetch(123);
|
||||||
|
const tags = await project.tags(); // ['urgent', 'q4-2025']
|
||||||
|
|
||||||
|
FETCH BATCHING (planned, Phase 2)
|
||||||
|
Automatic batching of multiple fetch requests made within same tick.
|
||||||
|
Reduces HTTP requests and database queries when loading multiple records.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Multiple Model.fetch() calls in same tick batched into single request
|
||||||
|
- Server groups requests by model, uses IN clause for efficient queries
|
||||||
|
- Only enabled when Ajax request batching is enabled (config)
|
||||||
|
- Integrates with existing Ajax batching system
|
||||||
|
|
||||||
|
Example - Without Batching (3 HTTP requests, 3 queries):
|
||||||
|
const client = await Client_Model.fetch(1);
|
||||||
|
const contact = await Contact_Model.fetch(5);
|
||||||
|
const project = await Project_Model.fetch(10);
|
||||||
|
|
||||||
|
Example - With Batching (1 HTTP request, 3 queries):
|
||||||
|
// Same code, but requests batched automatically
|
||||||
|
// Server receives: {Client_Model: [1], Contact_Model: [5], Project_Model: [10]}
|
||||||
|
|
||||||
|
Example - Same Model Batching (1 HTTP request, 1 query with IN clause):
|
||||||
|
const clients = await Promise.all([
|
||||||
|
Client_Model.fetch(1),
|
||||||
|
Client_Model.fetch(2),
|
||||||
|
Client_Model.fetch(3)
|
||||||
|
]);
|
||||||
|
// Server receives: {Client_Model: [1, 2, 3]}
|
||||||
|
// Executes: SELECT * FROM clients WHERE id IN (1, 2, 3)
|
||||||
|
|
||||||
|
Server Implementation:
|
||||||
|
- Orm_Controller receives batched request with model->ids map
|
||||||
|
- Groups IDs by model class
|
||||||
|
- Executes single query per model using WHERE id IN (...)
|
||||||
|
- Returns results keyed by model and id
|
||||||
|
- Client resolves individual promises from batched response
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Ajax request batching enabled in config
|
||||||
|
- Works alongside existing Ajax.call() batching
|
||||||
|
|
||||||
|
JQHTML CACHE INTEGRATION (planned, Phase 2, low priority)
|
||||||
|
Integration with jqhtml's component caching for instant first renders.
|
||||||
|
Visual polish feature - avoids brief loading indicators, not a performance gain.
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
- jqhtml caches on_load results for instant duplicate component renders
|
||||||
|
- First render still shows loading indicator while fetching
|
||||||
|
- If ORM data already cached from prior fetch, loading indicator unnecessary
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
- Provide jqhtml with mock data fetch functions during first render
|
||||||
|
- Mock functions check ORM cache, return cached data if available
|
||||||
|
- If cache hit: on_load completes synchronously, no loading indicator
|
||||||
|
- If cache miss: falls back to normal async fetch with loading indicator
|
||||||
|
|
||||||
|
Example Scenario:
|
||||||
|
// User views Contact #5, data cached
|
||||||
|
const contact = await Contact_Model.fetch(5);
|
||||||
|
|
||||||
|
// User navigates away, then back to same contact
|
||||||
|
// Without integration: loading indicator shown briefly
|
||||||
|
// With integration: cached data used, renders instantly
|
||||||
|
|
||||||
|
Implementation Notes:
|
||||||
|
- ORM maintains client-side cache of fetched records
|
||||||
|
- jqhtml receives cache-aware fetch stubs during render
|
||||||
|
- Cache keyed by model name + id
|
||||||
|
- Cache invalidation TBD (TTL, manual, websocket push)
|
||||||
|
|
||||||
|
Priority: Low - purely visual improvement, no server/client perf benefit
|
||||||
|
|
||||||
|
BATCH SECURITY OPTIMIZATION (todo)
|
||||||
|
Current Limitation:
|
||||||
|
The static fetch($id) method signature accepts a single ID, which means
|
||||||
|
when fetching multiple related records (e.g., via relationship loading),
|
||||||
|
each record requires a separate fetch($id) call and database query.
|
||||||
|
|
||||||
|
This prevents optimization with IN (id1, id2, ...) queries when loading
|
||||||
|
relationship result sets.
|
||||||
|
|
||||||
|
Problem Scenario:
|
||||||
|
// Loading a client's 50 contacts via relationship
|
||||||
|
// Currently requires 50 individual fetch() calls:
|
||||||
|
foreach ($contact_ids as $id) {
|
||||||
|
$contact = Contact_Model::fetch($id); // Individual query each
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot optimize to:
|
||||||
|
// SELECT * FROM contacts WHERE id IN (1, 2, 3, ...)
|
||||||
|
|
||||||
|
Proposed Solutions:
|
||||||
|
|
||||||
|
Option A - Query Prefetch Cache:
|
||||||
|
Before iterating through relationship results, prefetch all IDs
|
||||||
|
with a single query and cache the results. Individual fetch() calls
|
||||||
|
then hit the cache instead of the database.
|
||||||
|
|
||||||
|
// Pseudocode
|
||||||
|
$ids = $relationship->pluck('id');
|
||||||
|
static::prefetch_cache($ids); // Single IN query, cache results
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$record = static::fetch($id); // Hits cache, not database
|
||||||
|
}
|
||||||
|
|
||||||
|
Benefits: No API change, backwards compatible
|
||||||
|
Drawback: Cache management complexity, memory usage
|
||||||
|
|
||||||
|
Option B - Batch fetch() Variant:
|
||||||
|
Add a new method like fetch_batch($ids) that accepts an array
|
||||||
|
and returns filtered results using single query.
|
||||||
|
|
||||||
|
// fetch_batch must apply same security logic as fetch()
|
||||||
|
public static function fetch_batch(array $ids) {
|
||||||
|
$records = static::whereIn('id', $ids)->get();
|
||||||
|
return $records->filter(fn($r) => static::can_fetch($r));
|
||||||
|
}
|
||||||
|
|
||||||
|
Benefits: Clean API, explicit batch operation
|
||||||
|
Drawback: Requires refactoring existing security logic
|
||||||
|
|
||||||
|
Option C - Automatic Query Batching Layer:
|
||||||
|
Database query layer automatically batches identical queries
|
||||||
|
made within same request cycle using query deduplication.
|
||||||
|
|
||||||
|
Benefits: Transparent, no code changes
|
||||||
|
Drawback: Complex implementation, limited optimization scope
|
||||||
|
|
||||||
|
Recommended Approach:
|
||||||
|
Option A (Query Prefetch Cache) is likely the best balance of
|
||||||
|
simplicity and effectiveness. It can be implemented without changing
|
||||||
|
the fetch() API contract, and the cache can be request-scoped to
|
||||||
|
avoid memory/staleness issues.
|
||||||
|
|
||||||
|
Implementation Priority: Medium
|
||||||
|
Required before relationship fetching is considered production-ready
|
||||||
|
for models with large relationship sets.
|
||||||
|
|
||||||
|
OPT-IN FETCH CACHING (todo)
|
||||||
|
Current Limitation:
|
||||||
|
When a model's fetch() method returns an array (for augmented data),
|
||||||
|
relationship fetching must call the database twice: once via fetch()
|
||||||
|
to verify access, and once via find() to get the actual Eloquent model
|
||||||
|
for relationship method calls.
|
||||||
|
|
||||||
|
Proposed Solution - Request-Scoped Cache:
|
||||||
|
Implement opt-in caching of fetch() results scoped to the current
|
||||||
|
page/action lifecycle. Cache automatically invalidates on navigation
|
||||||
|
or form submission.
|
||||||
|
|
||||||
|
Cache Scope:
|
||||||
|
- Traditional pages: Single page instance (cache lives until navigation)
|
||||||
|
- SPA applications: Single SPA action (cache resets on action navigation)
|
||||||
|
|
||||||
|
Cache Invalidation Events:
|
||||||
|
1. Page navigation (traditional or SPA)
|
||||||
|
2. Rsx_Form submission (any form submit clears cache)
|
||||||
|
3. Developer-initiated (explicit cache clear call)
|
||||||
|
|
||||||
|
Opt-In API (TBD):
|
||||||
|
// Enable caching for a model
|
||||||
|
class Project_Model extends Rsx_Model_Abstract {
|
||||||
|
protected static $fetch_cache_enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual invalidation
|
||||||
|
Project_Model.clear_fetch_cache(); // Clear single model cache
|
||||||
|
Rsx_Js_Model.clear_all_fetch_caches(); // Clear all model caches
|
||||||
|
|
||||||
|
Implementation Notes:
|
||||||
|
- Cache keyed by model class + record ID
|
||||||
|
- Cache stores the raw fetch() result (model or array)
|
||||||
|
- For array results, also cache the underlying Eloquent model
|
||||||
|
- Relationship fetching checks cache before database query
|
||||||
|
- SPA integration via Spa.on('action:change') event
|
||||||
|
- Form integration via Rsx_Form.on('submit') event
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Eliminates duplicate database queries for relationship fetching
|
||||||
|
- Enables instant re-renders when returning to cached records
|
||||||
|
- Predictable invalidation tied to user actions
|
||||||
|
- Opt-in prevents unexpected caching behavior
|
||||||
|
|
||||||
|
Implementation Priority: Medium
|
||||||
|
Required before relationship fetching is considered production-ready
|
||||||
|
for models where fetch() returns augmented arrays.
|
||||||
|
|
||||||
|
PAGINATED RELATIONSHIP RESULTS (todo, low priority)
|
||||||
|
Current Limitation:
|
||||||
|
Relationship methods return all related records at once. For models
|
||||||
|
with large relationship sets (e.g., a client with 500 contacts),
|
||||||
|
this causes excessive data transfer and memory usage.
|
||||||
|
|
||||||
|
Proposed Solution - Paginated Relationship API:
|
||||||
|
Design a JavaScript API for paginated relationship fetching that
|
||||||
|
feels natural and is easy to use for common UI patterns like
|
||||||
|
infinite scroll, "load more" buttons, and traditional pagination.
|
||||||
|
|
||||||
|
API Design Goals:
|
||||||
|
- Simple default case: first page with sensible limit
|
||||||
|
- Easy iteration for "load more" patterns
|
||||||
|
- Support for offset/limit and cursor-based pagination
|
||||||
|
- Chainable or async iterator patterns
|
||||||
|
- Clear indication of "has more" and total count
|
||||||
|
|
||||||
|
Potential API Patterns (TBD):
|
||||||
|
|
||||||
|
Option A - Paginated Method Variant:
|
||||||
|
const page1 = await client.contacts_paginated({limit: 20});
|
||||||
|
// { data: Contact_Model[], has_more: true, total: 150, next_cursor: '...' }
|
||||||
|
|
||||||
|
const page2 = await client.contacts_paginated({cursor: page1.next_cursor});
|
||||||
|
|
||||||
|
Option B - Async Iterator:
|
||||||
|
for await (const contact of client.contacts_iterator({batch: 20})) {
|
||||||
|
// Yields one Contact_Model at a time, fetches in batches
|
||||||
|
}
|
||||||
|
|
||||||
|
Option C - Collection Object:
|
||||||
|
const contacts = await client.contacts({paginate: true, limit: 20});
|
||||||
|
// Returns Paginated_Collection object
|
||||||
|
|
||||||
|
contacts.data // Contact_Model[] (current page)
|
||||||
|
contacts.has_more // boolean
|
||||||
|
contacts.total // number (if available)
|
||||||
|
await contacts.next() // Load next page, returns same collection
|
||||||
|
await contacts.all() // Load all remaining (with warning for large sets)
|
||||||
|
|
||||||
|
Option D - Parameter on Existing Method:
|
||||||
|
const contacts = await client.contacts({limit: 20, offset: 0});
|
||||||
|
// Returns array but truncated to limit
|
||||||
|
|
||||||
|
const more = await client.contacts({limit: 20, offset: 20});
|
||||||
|
|
||||||
|
Server-Side Considerations:
|
||||||
|
- Orm_Controller::fetch_relationship() needs pagination params
|
||||||
|
- Efficient COUNT query for total (optional, can skip for performance)
|
||||||
|
- Cursor-based pagination for stable ordering during iteration
|
||||||
|
- Security: pagination params must not bypass fetch() security checks
|
||||||
|
|
||||||
|
UI Integration Patterns:
|
||||||
|
- DataGrid: Already handles pagination, may not need ORM integration
|
||||||
|
- Detail views: "Show more" buttons for related records
|
||||||
|
- Infinite scroll: Async iterator or cursor-based pagination
|
||||||
|
- Modal lists: Traditional page numbers
|
||||||
|
|
||||||
|
Implementation Priority: Low
|
||||||
|
Most large relationship sets are better served by DataGrid with
|
||||||
|
server-side filtering. This feature is for edge cases where
|
||||||
|
ORM-style access is preferred over DataGrid.
|
||||||
|
|
||||||
SEE ALSO
|
SEE ALSO
|
||||||
controller - Internal API attribute patterns
|
crud(3) - Standard CRUD implementation using Model.fetch()
|
||||||
manifest_api - Model discovery and stub generation
|
controller(3) - Internal API attribute patterns
|
||||||
coding_standards - Security patterns and authorization
|
manifest_api(3) - Model discovery and stub generation
|
||||||
|
coding_standards(3) - Security patterns and authorization
|
||||||
|
enums(3) - Model enum definitions and magic properties
|
||||||
|
|
||||||
|
RSX Framework 2025-11-23 MODEL_FETCH(3)
|
||||||
@@ -187,12 +187,11 @@ JAVASCRIPT INTEGRATION
|
|||||||
JavaScript classes auto-initialize:
|
JavaScript classes auto-initialize:
|
||||||
- Extend appropriate base classes
|
- Extend appropriate base classes
|
||||||
- Use static on_app_ready() method for initialization
|
- Use static on_app_ready() method for initialization
|
||||||
- Use static on_jqhtml_ready() to wait for JQHTML components to load
|
|
||||||
- No manual registration required
|
- No manual registration required
|
||||||
|
|
||||||
Lifecycle timing:
|
Lifecycle timing:
|
||||||
- on_app_ready(): Runs when page initializes, before JQHTML components finish
|
- on_app_ready(): Runs when page is ready for initialization
|
||||||
- on_jqhtml_ready(): Runs after all JQHTML components loaded and rendered
|
- For component readiness: await $(element).component().ready()
|
||||||
|
|
||||||
Important limitation:
|
Important limitation:
|
||||||
JavaScript only executes when bundle is rendered in HTML output.
|
JavaScript only executes when bundle is rendered in HTML output.
|
||||||
|
|||||||
@@ -39,13 +39,16 @@ CORE OPTIONS
|
|||||||
|
|
||||||
CONSOLE OUTPUT
|
CONSOLE OUTPUT
|
||||||
|
|
||||||
--console-log | --console-list
|
--console
|
||||||
Display all browser console output, not just errors. Shows console.log(),
|
Display all browser console output, not just errors. Shows console.log(),
|
||||||
console.warn(), console.info(), and console_debug() output.
|
console.warn(), console.info(), and console_debug() output.
|
||||||
|
|
||||||
|
--console-log
|
||||||
|
Alias for --console.
|
||||||
|
|
||||||
--console-debug-filter=<channel>
|
--console-debug-filter=<channel>
|
||||||
Filter console_debug() output to a specific channel (e.g., AUTH, DISPATCH,
|
Filter console_debug() output to a specific channel (e.g., AUTH, DISPATCH,
|
||||||
BENCHMARK). Automatically enables console_debug and --console-log.
|
BENCHMARK). Automatically enables console_debug and --console.
|
||||||
|
|
||||||
--console-debug-all
|
--console-debug-all
|
||||||
Show all console_debug() channels without filtering.
|
Show all console_debug() channels without filtering.
|
||||||
@@ -177,7 +180,7 @@ OUTPUT FORMAT
|
|||||||
The command outputs in a terse, parseable format:
|
The command outputs in a terse, parseable format:
|
||||||
- Status line with URL and HTTP status code
|
- Status line with URL and HTTP status code
|
||||||
- Console errors (always shown if present)
|
- Console errors (always shown if present)
|
||||||
- Console logs (if --console-log used)
|
- Console logs (if --console used)
|
||||||
- XHR/fetch requests (if --xhr-dump or --xhr-list used)
|
- XHR/fetch requests (if --xhr-dump or --xhr-list used)
|
||||||
- Response headers (if --headers used)
|
- Response headers (if --headers used)
|
||||||
- Response body (unless --no-body used)
|
- Response body (unless --no-body used)
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ SPA ARCHITECTURE
|
|||||||
|
|
||||||
5. Layout Template (.jqhtml)
|
5. Layout Template (.jqhtml)
|
||||||
- Persistent wrapper around actions
|
- Persistent wrapper around actions
|
||||||
- Must have element with $id="content"
|
- Must have element with $sid="content"
|
||||||
- Persists across action navigation
|
- Persists across action navigation
|
||||||
|
|
||||||
6. Layout Class (.js)
|
6. Layout Class (.js)
|
||||||
@@ -114,7 +114,7 @@ SPA ARCHITECTURE
|
|||||||
4. Client JavaScript discovers all actions via manifest
|
4. Client JavaScript discovers all actions via manifest
|
||||||
5. Router matches URL to action class
|
5. Router matches URL to action class
|
||||||
6. Creates layout on <body>
|
6. Creates layout on <body>
|
||||||
7. Creates action inside layout $id="content" area
|
7. Creates action inside layout $sid="content" area
|
||||||
|
|
||||||
Subsequent Navigation:
|
Subsequent Navigation:
|
||||||
1. User clicks link or calls Spa.dispatch()
|
1. User clicks link or calls Spa.dispatch()
|
||||||
@@ -222,7 +222,7 @@ JAVASCRIPT ACTIONS
|
|||||||
|
|
||||||
on_ready() {
|
on_ready() {
|
||||||
// DOM is ready, setup event handlers
|
// DOM is ready, setup event handlers
|
||||||
this.$id('search').on('input', () => this.reload());
|
this.$sid('search').on('input', () => this.reload());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ LAYOUTS
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main $id="content">
|
<main $sid="content">
|
||||||
<!-- Actions render here -->
|
<!-- Actions render here -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ LAYOUTS
|
|||||||
</Define:Frontend_Layout>
|
</Define:Frontend_Layout>
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Must have element with $id="content"
|
- Must have element with $sid="content"
|
||||||
- Content area is where actions render
|
- Content area is where actions render
|
||||||
- Layout persists across navigation
|
- Layout persists across navigation
|
||||||
|
|
||||||
@@ -351,6 +351,78 @@ LAYOUTS
|
|||||||
- Can immediately access this.action properties
|
- Can immediately access this.action properties
|
||||||
- Use await this.action.ready() to wait for action's full loading
|
- Use await this.action.ready() to wait for action's full loading
|
||||||
|
|
||||||
|
SUBLAYOUTS
|
||||||
|
Sublayouts allow nesting multiple persistent layouts. Each layout in the
|
||||||
|
chain persists independently based on navigation context.
|
||||||
|
|
||||||
|
Use Case:
|
||||||
|
A settings section with its own sidebar that persists across all
|
||||||
|
settings pages, while the main application header/footer (outer layout)
|
||||||
|
also persists. Navigating between settings pages preserves both layouts;
|
||||||
|
navigating to dashboard destroys the settings sublayout but keeps the
|
||||||
|
outer layout.
|
||||||
|
|
||||||
|
Defining Sublayouts:
|
||||||
|
Add multiple @layout decorators - first is outermost, subsequent are nested:
|
||||||
|
|
||||||
|
@route('/frontend/settings/profile')
|
||||||
|
@layout('Frontend_Spa_Layout') // Outermost (header/footer)
|
||||||
|
@layout('Settings_Layout') // Nested inside Frontend_Spa_Layout
|
||||||
|
@spa('Frontend_Spa_Controller::index')
|
||||||
|
class Settings_Profile_Action extends Spa_Action { }
|
||||||
|
|
||||||
|
Creating a Sublayout:
|
||||||
|
Sublayouts are Spa_Layout classes with $sid="content":
|
||||||
|
|
||||||
|
// /rsx/app/frontend/settings/Settings_Layout.js
|
||||||
|
class Settings_Layout extends Spa_Layout {
|
||||||
|
async on_action(url, action_name, args) {
|
||||||
|
// Update sidebar highlighting based on current action
|
||||||
|
this._update_active_nav(action_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- /rsx/app/frontend/settings/Settings_Layout.jqhtml -->
|
||||||
|
<Define:Settings_Layout>
|
||||||
|
<div class="settings-container">
|
||||||
|
<aside class="settings-sidebar">
|
||||||
|
<a href="..." data-page="profile">Profile</a>
|
||||||
|
<a href="..." data-page="security">Security</a>
|
||||||
|
</aside>
|
||||||
|
<main $sid="content"></main>
|
||||||
|
</div>
|
||||||
|
</Define:Settings_Layout>
|
||||||
|
|
||||||
|
Chain Resolution:
|
||||||
|
When navigating, Spa compares current layout chain to target:
|
||||||
|
- Matching layouts from the top are reused
|
||||||
|
- First mismatched layout and everything below is destroyed
|
||||||
|
- New layouts/action created from divergence point down
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
|
||||||
|
Target: Frontend_Spa_Layout > Settings_Layout > Security_Action
|
||||||
|
Result: Reuse both layouts, only replace action
|
||||||
|
|
||||||
|
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
|
||||||
|
Target: Frontend_Spa_Layout > Dashboard_Action
|
||||||
|
Result: Reuse Frontend_Spa_Layout, destroy Settings_Layout, create Dashboard_Action
|
||||||
|
|
||||||
|
on_action Propagation:
|
||||||
|
All layouts in the chain receive on_action() with the final action's info:
|
||||||
|
- url: The navigated URL
|
||||||
|
- action_name: Name of the action class (bottom of chain)
|
||||||
|
- args: URL parameters
|
||||||
|
|
||||||
|
This allows each layout to update its UI (sidebar highlighting, breadcrumbs)
|
||||||
|
based on which action is currently active.
|
||||||
|
|
||||||
|
References:
|
||||||
|
Spa.layout Always the top-level layout
|
||||||
|
Spa.action Always the bottom-level action (not a layout)
|
||||||
|
|
||||||
|
To access intermediate layouts, use DOM traversal or layout hooks.
|
||||||
|
|
||||||
URL GENERATION
|
URL GENERATION
|
||||||
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
|
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
|
||||||
"/contacts" will produce errors.
|
"/contacts" will produce errors.
|
||||||
@@ -534,7 +606,7 @@ EXAMPLES
|
|||||||
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
|
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main $id="content"></main>
|
<main $sid="content"></main>
|
||||||
</div>
|
</div>
|
||||||
</Define:Frontend_Layout>
|
</Define:Frontend_Layout>
|
||||||
|
|
||||||
@@ -632,12 +704,12 @@ TROUBLESHOOTING
|
|||||||
Navigation Not Working:
|
Navigation Not Working:
|
||||||
- Verify using Rsx.Route() not hardcoded URLs
|
- Verify using Rsx.Route() not hardcoded URLs
|
||||||
- Check @spa() decorator references correct bootstrap controller
|
- Check @spa() decorator references correct bootstrap controller
|
||||||
- Ensure layout has $id="content" element
|
- Ensure layout has $sid="content" element
|
||||||
- Test Spa.dispatch() directly
|
- Test Spa.dispatch() directly
|
||||||
|
|
||||||
Layout Not Persisting:
|
Layout Not Persisting:
|
||||||
- Verify all actions in module use same @layout()
|
- Verify all actions in module use same @layout()
|
||||||
- Check layout template has $id="content"
|
- Check layout template has $sid="content"
|
||||||
- Ensure not mixing SPA and traditional routes
|
- Ensure not mixing SPA and traditional routes
|
||||||
|
|
||||||
this.args Empty:
|
this.args Empty:
|
||||||
|
|||||||
47
app/RSpade/man/zindex.txt
Executable file
47
app/RSpade/man/zindex.txt
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
Z-INDEX STANDARDS
|
||||||
|
=================
|
||||||
|
|
||||||
|
RSpade z-index scale, extending Bootstrap 5 defaults.
|
||||||
|
|
||||||
|
SCALE
|
||||||
|
-----
|
||||||
|
z-index Layer Purpose
|
||||||
|
------- ----- -------
|
||||||
|
auto/0 Base Normal page content flow
|
||||||
|
1000 Dropdown Page-level dropdowns (Bootstrap)
|
||||||
|
1020 Sticky Sticky headers (Bootstrap)
|
||||||
|
1030 Fixed Fixed navbars (Bootstrap)
|
||||||
|
1040 Modal backdrop (Bootstrap)
|
||||||
|
1050 Modal (Bootstrap)
|
||||||
|
1070 Popover (Bootstrap)
|
||||||
|
1080 Tooltip (Bootstrap)
|
||||||
|
1090 Toast Notifications (Bootstrap)
|
||||||
|
1100 Modal children Dropdowns/selects inside modals
|
||||||
|
1200 Flash alerts Application flash messages
|
||||||
|
9000+ System/Debug Error overlays, debug panels
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
-----
|
||||||
|
Page elements: Use auto/default stacking. Rarely need explicit z-index.
|
||||||
|
|
||||||
|
Modal children (1100):
|
||||||
|
Components that render dropdowns to <body> (TomSelect, datepickers)
|
||||||
|
need z-index > modal (1050) to appear above the modal.
|
||||||
|
|
||||||
|
Flash alerts (1200):
|
||||||
|
Application-level notifications that should appear above modals.
|
||||||
|
|
||||||
|
System (9000+):
|
||||||
|
Reserved for framework-level UI: uncaught error displays, debug tools.
|
||||||
|
Application code should not use this range.
|
||||||
|
|
||||||
|
ADDING NEW LAYERS
|
||||||
|
-----------------
|
||||||
|
Gaps between values are intentional. When adding new layers:
|
||||||
|
1. Use existing Bootstrap value if applicable
|
||||||
|
2. Otherwise, slot into appropriate range with buffer space
|
||||||
|
3. Document here
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
--------
|
||||||
|
Bootstrap z-index: https://getbootstrap.com/docs/5.3/layout/z-index/
|
||||||
0
app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/blade_client.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/blade_client.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/blade_component_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/blade_component_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/blade_spacer.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/blade_spacer.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/config.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/config.js.map
Normal file → Executable file
@@ -39,7 +39,6 @@ const CONVENTION_METHODS = [
|
|||||||
'on_app_modules_init',
|
'on_app_modules_init',
|
||||||
'on_app_init',
|
'on_app_init',
|
||||||
'on_app_ready',
|
'on_app_ready',
|
||||||
'on_jqhtml_ready'
|
|
||||||
];
|
];
|
||||||
/**
|
/**
|
||||||
* Check if a method is static by examining the line text
|
* Check if a method is static by examining the line text
|
||||||
|
|||||||
0
app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/debug_client.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/debug_client.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/decoration_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/decoration_provider.js.map
Normal file → Executable file
@@ -12,7 +12,7 @@
|
|||||||
* FILE TYPE HANDLERS & RESOLUTION RULES:
|
* FILE TYPE HANDLERS & RESOLUTION RULES:
|
||||||
*
|
*
|
||||||
* 1. ROUTE PATTERNS (all files)
|
* 1. ROUTE PATTERNS (all files)
|
||||||
* Pattern: Rsx::Route('Controller') or Rsx.Route('Controller::method')
|
* Pattern: Rsx::Route('Controller') or Rsx.Route('Controller', 'method')
|
||||||
* Type: 'php_class'
|
* Type: 'php_class'
|
||||||
* Reason: Routes always point to PHP controllers (server-side)
|
* Reason: Routes always point to PHP controllers (server-side)
|
||||||
*
|
*
|
||||||
@@ -658,9 +658,9 @@ class RspadeDefinitionProvider {
|
|||||||
if (wordRange) {
|
if (wordRange) {
|
||||||
const method_name = document.getText(wordRange);
|
const method_name = document.getText(wordRange);
|
||||||
const wordStart = wordRange.start.character;
|
const wordStart = wordRange.start.character;
|
||||||
// Look backwards for "ClassName." pattern before the method
|
// Look backwards for "ClassName." or "ClassName::" pattern before the method
|
||||||
const beforeMethod = line.substring(0, wordStart);
|
const beforeMethod = line.substring(0, wordStart);
|
||||||
const classMatch = beforeMethod.match(/([A-Z][A-Za-z0-9_]*)\.$/);
|
const classMatch = beforeMethod.match(/([A-Z][A-Za-z0-9_]*)(?:\.|::)$/);
|
||||||
if (classMatch) {
|
if (classMatch) {
|
||||||
const class_name = classMatch[1];
|
const class_name = classMatch[1];
|
||||||
// Check if the class name looks like an RSX class (contains underscore)
|
// Check if the class name looks like an RSX class (contains underscore)
|
||||||
|
|||||||
2
app/RSpade/resource/vscode_extension/out/definition_provider.js.map
Normal file → Executable file
2
app/RSpade/resource/vscode_extension/out/definition_provider.js.map
Normal file → Executable file
File diff suppressed because one or more lines are too long
0
app/RSpade/resource/vscode_extension/out/extension.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/extension.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/file_watcher.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/file_watcher.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map
Normal file → Executable file
0
app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map
Normal file → Executable file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user