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;
|
||||
|
||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
||||
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||
|
||||
/**
|
||||
* Bootstrap 5 CDN Bundle
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\RSpade\Bundles;
|
||||
|
||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
||||
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\RSpade\Bundles;
|
||||
|
||||
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
|
||||
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -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\Support\CacheManager;
|
||||
use App\RSpade\CodeQuality\Support\FileSanitizer;
|
||||
use App\RSpade\CodeQuality\Support\Js_CodeQuality_Rpc;
|
||||
use App\RSpade\CodeQuality\Support\ViolationCollector;
|
||||
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
|
||||
*/
|
||||
protected static function lint_javascript_file(string $file_path): bool
|
||||
@@ -428,70 +429,60 @@ class CodeQualityChecker
|
||||
if (str_contains($file_path, '/resource/vscode_extension/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 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';
|
||||
if (!is_dir($cache_dir)) {
|
||||
mkdir($cache_dir, 0755, true);
|
||||
}
|
||||
|
||||
|
||||
// Generate flag file path (no .js extension to avoid IDE detection)
|
||||
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
|
||||
$relative_path = str_replace($base_path . '/', '', $file_path);
|
||||
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
|
||||
|
||||
|
||||
// Check if lint was already passed
|
||||
if (file_exists($flag_path)) {
|
||||
$source_mtime = filemtime($file_path);
|
||||
$flag_mtime = filemtime($flag_path);
|
||||
|
||||
|
||||
if ($flag_mtime >= $source_mtime) {
|
||||
// File hasn't changed since last successful lint
|
||||
return false; // No errors
|
||||
}
|
||||
}
|
||||
|
||||
// Run JavaScript syntax check using Node.js
|
||||
$linter_path = $base_path . '/bin/js-linter.js';
|
||||
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);
|
||||
|
||||
|
||||
// Lint via RPC server (lazy starts if not running)
|
||||
$error = Js_CodeQuality_Rpc::lint($file_path);
|
||||
|
||||
// Check if there's a syntax error
|
||||
if ($output && trim($output) !== '') {
|
||||
if ($error !== null) {
|
||||
// Delete flag file if it exists (file now has errors)
|
||||
if (file_exists($flag_path)) {
|
||||
unlink($flag_path);
|
||||
}
|
||||
|
||||
// Parse error message for line number if available
|
||||
$line_number = 0;
|
||||
if (preg_match('/Line (\d+)/', $output, $matches)) {
|
||||
$line_number = (int)$matches[1];
|
||||
}
|
||||
|
||||
|
||||
$line_number = $error['line'] ?? 0;
|
||||
$message = $error['message'] ?? 'Unknown syntax error';
|
||||
|
||||
static::$collector->add(
|
||||
new CodeQuality_Violation(
|
||||
'JS-SYNTAX',
|
||||
$file_path,
|
||||
$line_number,
|
||||
trim($output),
|
||||
$message,
|
||||
'critical',
|
||||
null,
|
||||
'Fix the JavaScript syntax error before running other checks.'
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
return true; // Error found
|
||||
}
|
||||
|
||||
|
||||
// Create flag file to indicate successful lint
|
||||
touch($flag_path);
|
||||
|
||||
|
||||
return false; // No errors
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ class LifecycleMethodsStatic_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
'on_app_modules_init',
|
||||
'on_app_init',
|
||||
'on_app_ready',
|
||||
'on_jqhtml_ready',
|
||||
];
|
||||
|
||||
public function get_id(): string
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\RSpade\CodeQuality\Rules\JavaScript;
|
||||
|
||||
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||
use App\RSpade\CodeQuality\Support\Js_CodeQuality_Rpc;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Uses external parser script stored in resources directory
|
||||
* Analyze JavaScript file for 'this' usage violations via RPC server
|
||||
*/
|
||||
private function parse_with_acorn(string $file_path): array
|
||||
{
|
||||
@@ -125,33 +125,8 @@ class ThisUsage_CodeQualityRule extends CodeQualityRule_Abstract
|
||||
}
|
||||
}
|
||||
|
||||
// Path to the parser script
|
||||
$parser_script = __DIR__ . '/resource/this-usage-parser.js';
|
||||
|
||||
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'] ?? [];
|
||||
// Analyze via RPC server (lazy starts if not running)
|
||||
$violations = Js_CodeQuality_Rpc::analyze_this($file_path);
|
||||
|
||||
// Cache the result
|
||||
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
|
||||
$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
|
||||
{
|
||||
// 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
|
||||
$class_metadata = null;
|
||||
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
|
||||
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) {
|
||||
return "Use ES6 class lifecycle methods:\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_modules_init() - For module initialization\n" .
|
||||
" - on_modules_define() - For module metadata registration\n" .
|
||||
@@ -61,7 +60,6 @@ class InitializationSuggestions
|
||||
{
|
||||
return "Use user code lifecycle methods instead:\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_modules_init() - For module initialization\n" .
|
||||
" - 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');
|
||||
|
||||
// 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();
|
||||
$bundle_classes = [];
|
||||
|
||||
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;
|
||||
if ($fqcn) {
|
||||
$bundle_classes[$fqcn] = $file_info['class'] ?? $fqcn;
|
||||
|
||||
@@ -31,7 +31,7 @@ use App\RSpade\Core\Debug\Debugger;
|
||||
* KEY FEATURES:
|
||||
* - 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
|
||||
* - 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
|
||||
* - Element verification: Check DOM elements with --expect-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)
|
||||
* - Response headers (if --headers used)
|
||||
* - 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)
|
||||
* - Input elements (if --input-elements 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)}
|
||||
{--follow-redirects : Follow HTTP redirects and show full redirect chain}
|
||||
{--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)}
|
||||
{--input-elements : List all form input elements with values and attributes}
|
||||
{--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
|
||||
$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-debug-all') ||
|
||||
$this->option('console-debug-filter') ||
|
||||
@@ -224,8 +226,8 @@ class Route_Debug_Command extends Command
|
||||
// Get headers flag
|
||||
$headers = $this->option('headers');
|
||||
|
||||
// Get console-log flag
|
||||
$console_log = $this->option('console-log');
|
||||
// Get console flag (--console or --console-log alias)
|
||||
$console_log = $this->option('console') || $this->option('console-log');
|
||||
|
||||
// Get xhr-dump flag
|
||||
$xhr_dump = $this->option('xhr-dump');
|
||||
@@ -539,8 +541,8 @@ class Route_Debug_Command extends Command
|
||||
$this->line('');
|
||||
|
||||
$this->comment('DEBUGGING OUTPUT:');
|
||||
$this->line(' php artisan rsx:debug / --console-log # All console output');
|
||||
$this->line(' php artisan rsx:debug / --console-list # Alias for --console-log');
|
||||
$this->line(' php artisan rsx:debug / --console # All console output');
|
||||
$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-all # Show all console_debug channels');
|
||||
$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: "..."},
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* @auth-exempt Auth is handled per-call inside Ajax::internal()
|
||||
*/
|
||||
class Ajax_Batch_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
@@ -34,7 +36,6 @@ class Ajax_Batch_Controller extends Rsx_Controller_Abstract
|
||||
* @param array $params
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
#[Auth('Permission::anybody()')]
|
||||
#[Route('/_ajax/_batch', methods: ['POST'])]
|
||||
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.
|
||||
* 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
|
||||
*/
|
||||
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);
|
||||
|
||||
// Detect artisan commands by checking if running from CLI and the script name contains 'artisan'
|
||||
|
||||
@@ -7,6 +7,8 @@ use RecursiveCallbackFilterIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
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\Manifest\Manifest;
|
||||
|
||||
@@ -91,6 +93,12 @@ class BundleCompiler
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -129,6 +137,7 @@ class BundleCompiler
|
||||
|
||||
// Step 2: Mark the bundle we're compiling as already resolved
|
||||
$this->resolved_includes[$bundle_class] = true;
|
||||
$this->root_bundle_class = $bundle_class;
|
||||
|
||||
// Step 3: Process required bundles first
|
||||
$this->_process_required_bundles();
|
||||
@@ -467,18 +476,73 @@ class BundleCompiler
|
||||
$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
|
||||
*
|
||||
* @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
|
||||
if (!method_exists($bundle_class, 'define')) {
|
||||
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();
|
||||
|
||||
// Process bundle includes
|
||||
@@ -732,6 +796,8 @@ class BundleCompiler
|
||||
|
||||
/**
|
||||
* Add all files from a directory
|
||||
*
|
||||
* Also auto-discovers Asset Bundles in the directory and processes them.
|
||||
*/
|
||||
protected function _add_directory(string $path): void
|
||||
{
|
||||
@@ -742,6 +808,9 @@ class BundleCompiler
|
||||
// Get excluded directories from config
|
||||
$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
|
||||
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
|
||||
@@ -763,7 +832,42 @@ class BundleCompiler
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -1109,6 +1361,29 @@ class BundleCompiler
|
||||
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
|
||||
$relative = str_replace(base_path() . '/', '', $file);
|
||||
$file_data = $manifest_files[$relative] ?? null;
|
||||
@@ -1211,6 +1486,35 @@ class BundleCompiler
|
||||
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
|
||||
*
|
||||
@@ -1419,7 +1723,13 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
* Here we:
|
||||
* 1. Filter to only .js and .css files
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
// 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'])) {
|
||||
$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') {
|
||||
// Add NPM import declarations at the very beginning
|
||||
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
|
||||
// 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
|
||||
// Add compiled jqhtml files
|
||||
// These are JavaScript files generated from .jqhtml templates
|
||||
foreach ($this->jqhtml_compiled_files as $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
|
||||
$manifest_file = $this->_create_javascript_manifest($files['js'] ?? []);
|
||||
if ($manifest_file) {
|
||||
@@ -2020,8 +2340,38 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
|
||||
|
||||
// Analyze each JavaScript file for class information
|
||||
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/')) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -24,8 +24,10 @@ class Core_Bundle extends Rsx_Bundle_Abstract
|
||||
__DIR__,
|
||||
'app/RSpade/Core/Js',
|
||||
'app/RSpade/Core/Data',
|
||||
'app/RSpade/Core/Database',
|
||||
'app/RSpade/Core/SPA',
|
||||
'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:
|
||||
- `Rsx_Manifest` - Manifest management
|
||||
- `Rsx_Cache` - Client-side caching
|
||||
- `Rsx_Storage` - Browser storage (session/local) with scoping
|
||||
- `Rsx` - Core framework utilities
|
||||
- All classes in `/app/RSpade/Core/Js/`
|
||||
|
||||
|
||||
@@ -17,10 +17,4 @@ class {{ js_class }} {
|
||||
static on_app_ready() {
|
||||
{{ 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:
|
||||
* let countries = await Rsx_Reference_Data_Controller.countries();
|
||||
* 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
|
||||
{
|
||||
|
||||
@@ -110,13 +110,8 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
||||
$metadata = isset($manifest_data['data']['files'][$file_path]) ? $manifest_data['data']['files'][$file_path] : [];
|
||||
|
||||
// Generate stub filename and paths
|
||||
$stub_filename = static::_sanitize_model_stub_filename($class_name) . '.js';
|
||||
|
||||
// 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;
|
||||
// Always use Base_ prefix - concrete classes are handled at bundle compilation time
|
||||
$stub_class_name = 'Base_' . $class_name;
|
||||
$stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js';
|
||||
|
||||
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename;
|
||||
@@ -267,26 +262,6 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
||||
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
|
||||
*/
|
||||
@@ -311,8 +286,19 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
||||
// Get model instance to introspect
|
||||
$model = new $fqcn();
|
||||
|
||||
// Get relationships
|
||||
$relationships = $fqcn::get_relationships();
|
||||
// Get relationships that are Ajax-fetchable
|
||||
// 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
|
||||
$enums = $fqcn::$enums ?? [];
|
||||
@@ -323,18 +309,22 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
||||
$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
|
||||
$content = "/**\n";
|
||||
$content .= " * Auto-generated JavaScript stub for {$class_name}\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
|
||||
$content .= " static get name() {\n";
|
||||
$content .= " return '{$class_name}';\n";
|
||||
$content .= " }\n\n";
|
||||
// Add static __MODEL property for PHP model name resolution
|
||||
$content .= " static __MODEL = '{$class_name}';\n\n";
|
||||
|
||||
// Generate enum constants and methods
|
||||
foreach ($enums as $column => $enum_values) {
|
||||
@@ -440,25 +430,31 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
|
||||
$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
|
||||
foreach ($relationships as $relationship) {
|
||||
$content .= " /**\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 .= " async {$relationship}() {\n";
|
||||
$content .= " if (!this.id) {\n";
|
||||
$content .= " shouldnt_happen('Cannot fetch relationship without id property');\n";
|
||||
$content .= " }\n\n";
|
||||
$content .= " const response = await $.ajax({\n";
|
||||
$content .= " url: `/_fetch_rel/{$class_name}/\${this.id}/{$relationship}`,\n";
|
||||
$content .= " method: 'POST',\n";
|
||||
$content .= " dataType: 'json'\n";
|
||||
$content .= " });\n\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";
|
||||
$content .= " return await Orm_Controller.fetch_relationship({\n";
|
||||
$content .= " model: '{$class_name}',\n";
|
||||
$content .= " id: this.id,\n";
|
||||
$content .= " relationship: '{$relationship}'\n";
|
||||
$content .= " });\n";
|
||||
$content .= " }\n\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,43 @@ abstract class Rsx_Model_Abstract extends Model
|
||||
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
|
||||
*
|
||||
|
||||
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.
|
||||
* 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
|
||||
{
|
||||
|
||||
@@ -17,6 +17,8 @@ use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @auth-exempt Routes requests to #[Ajax_Endpoint] methods - each endpoint has its own auth checks
|
||||
*/
|
||||
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()
|
||||
*/
|
||||
#[Auth('Permission::anybody()')]
|
||||
#[Route('/_ajax/:controller/:action', methods: ['POST'])]
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'];
|
||||
$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:
|
||||
// 1. Extra parameters (usually empty, lowest priority)
|
||||
// 2. GET parameters (from query string)
|
||||
@@ -301,43 +295,9 @@ class Dispatcher
|
||||
// Load and validate handler class
|
||||
static::__load_handler_class($handler_class);
|
||||
|
||||
// Check controller pre_dispatch Auth attributes
|
||||
$pre_dispatch_requires = Manifest::get_pre_dispatch_requires($handler_class);
|
||||
foreach ($pre_dispatch_requires as $require_attr) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
// Permission checks are now handled manually in controller pre_dispatch() methods
|
||||
// and within individual route methods. See: php artisan rsx:man auth
|
||||
// A code quality rule (rsx:check) verifies auth checks exist.
|
||||
|
||||
// Call pre_dispatch hooks
|
||||
$pre_dispatch_result = static::__call_pre_dispatch($handler_class, $handler_method, $params, $request);
|
||||
@@ -1034,422 +994,4 @@ class Dispatcher
|
||||
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.
|
||||
*
|
||||
* @auth-exempt Auth is handled via event hooks (file.upload.authorize, file.download.authorize, etc.)
|
||||
*
|
||||
* ================================================================================================
|
||||
* UPLOAD ENDPOINT
|
||||
* ================================================================================================
|
||||
*
|
||||
* POST /_upload
|
||||
* AUTH: Permission::anybody() - Authorization is handled via event hooks
|
||||
* AUTH: Handled via file.upload.authorize event hook
|
||||
*
|
||||
* REQUEST PARAMETERS:
|
||||
* - file (required) - The file to upload
|
||||
@@ -947,7 +949,6 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
|
||||
* @return Response PNG image data
|
||||
*/
|
||||
#[Route('/_icon_by_extension/:extension', methods: ['GET'])]
|
||||
#[Auth('Permission::anybody()')]
|
||||
public static function icon_by_extension(Request $request, array $params = [])
|
||||
{
|
||||
$extension = $params['extension'] ?? '';
|
||||
|
||||
@@ -14,8 +14,9 @@ class Ajax {
|
||||
static ERROR_AUTH_REQUIRED = 'auth_required';
|
||||
static ERROR_FATAL = 'fatal';
|
||||
static ERROR_GENERIC = 'generic';
|
||||
static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
|
||||
static ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
|
||||
static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
|
||||
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
|
||||
@@ -208,7 +209,8 @@ class Ajax {
|
||||
resolve(processed_value);
|
||||
} else {
|
||||
// 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 metadata = response.metadata || {};
|
||||
|
||||
@@ -217,13 +219,26 @@ class Ajax {
|
||||
error.code = error_code;
|
||||
error.metadata = metadata;
|
||||
|
||||
// Handle fatal errors specially
|
||||
// Handle fatal errors specially - detect PHP exceptions
|
||||
if (error_code === Ajax.ERROR_FATAL) {
|
||||
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
|
||||
Debugger.log_error({
|
||||
@@ -375,7 +390,8 @@ class Ajax {
|
||||
});
|
||||
} else {
|
||||
// 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 metadata = call_response.metadata || {};
|
||||
|
||||
@@ -383,6 +399,28 @@ class Ajax {
|
||||
error.code = error_code;
|
||||
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.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
|
||||
* const user = await User_Model.fetch(123);
|
||||
*
|
||||
* // Fetch multiple records
|
||||
* const users = await User_Model.fetch([1, 2, 3]);
|
||||
*
|
||||
* // Create instance with data
|
||||
* const user = new User_Model({id: 1, name: 'John'});
|
||||
*
|
||||
* @Instantiatable
|
||||
* @Instantiatable
|
||||
*/
|
||||
class Rsx_Js_Model {
|
||||
/**
|
||||
@@ -42,40 +39,58 @@ class Rsx_Js_Model {
|
||||
* The backend model must have a fetch() method with the
|
||||
* #[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
|
||||
* @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) {
|
||||
const CurrentClass = this;
|
||||
// Get the model class name from the current class
|
||||
const modelName = CurrentClass.name;
|
||||
// Get the PHP model name from the static __MODEL property
|
||||
// This allows Base_Project_Model to correctly identify as "Project_Model" to PHP
|
||||
const modelName = CurrentClass.__MODEL || CurrentClass.name;
|
||||
|
||||
const response = await $.ajax({
|
||||
url: `/_fetch/${modelName}`,
|
||||
method: 'POST',
|
||||
data: { id: id },
|
||||
dataType: 'json',
|
||||
});
|
||||
// Use the standard Ajax endpoint pattern via Orm_Controller
|
||||
const response = await Orm_Controller.fetch({ model: modelName, id: id });
|
||||
|
||||
// Handle response based on type
|
||||
if (response === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Response is already hydrated by Ajax layer (Ajax.js calls _instantiate_models_recursive)
|
||||
// Just return the response directly
|
||||
return 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
|
||||
*
|
||||
* @returns {string} The class name
|
||||
* @returns {string} The PHP model class name
|
||||
*/
|
||||
static getModelName() {
|
||||
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
|
||||
*
|
||||
* 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)
|
||||
* @returns {*} The data with ORM objects instantiated
|
||||
*/
|
||||
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
|
||||
if (data === null || data === undefined) {
|
||||
return data;
|
||||
@@ -159,12 +166,12 @@ class Rsx_Js_Model {
|
||||
if (typeof data === 'object') {
|
||||
// Check if this object has a __MODEL property
|
||||
if (data.__MODEL && typeof data.__MODEL === 'string') {
|
||||
// Try to find the model class in the global scope
|
||||
const ModelClass = window[data.__MODEL];
|
||||
// Look up the model class in the Manifest registry
|
||||
const ModelClass = Manifest.get_class_by_name(data.__MODEL);
|
||||
|
||||
// If the model class exists and extends Rsx_Js_Model, instantiate it
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,6 +591,21 @@ class Manifest
|
||||
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
|
||||
*
|
||||
@@ -625,6 +640,13 @@ class Manifest
|
||||
|
||||
$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
|
||||
if (!isset(static::$data['data']['js_classes'][$current_class])) {
|
||||
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\Models\Login_User_Model;
|
||||
use App\RSpade\Core\Models\Site_Model;
|
||||
use App\RSpade\Core\Models\User_Permission_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.
|
||||
* A single login identity (Login_User_Model) can have multiple User_Model records
|
||||
* for different sites, allowing different names, roles, and profiles per organization.
|
||||
*
|
||||
* Contains: first_name, last_name, phone, site_id, role_id, user_role_id
|
||||
* References: login_user_id → Login_User_Model (authentication identity)
|
||||
* ACL System:
|
||||
* - 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()
|
||||
* @property-read mixed $role_id_constant
|
||||
* @property-read mixed $role_id_label
|
||||
* @method static mixed user_role_id_enum()
|
||||
* @method static mixed user_role_id_enum_select()
|
||||
* @method static mixed user_role_id_enum_ids()
|
||||
* @property-read mixed $user_role_id_constant
|
||||
* @property-read mixed $user_role_id_label
|
||||
* @property-read mixed $user_role_id_order
|
||||
* @property-read mixed $role_id_permissions
|
||||
* @property-read mixed $role_id_can_admin_roles
|
||||
* @property-read mixed $role_id_selectable
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class User_Model extends Rsx_Site_Model_Abstract
|
||||
{
|
||||
/** __AUTO_GENERATED: */
|
||||
const ROLE_OWNER = 1;
|
||||
const ROLE_ADMIN = 2;
|
||||
const ROLE_MEMBER = 3;
|
||||
const ROLE_VIEWER = 4;
|
||||
const USER_ROLE_READ_ONLY = 1;
|
||||
const USER_ROLE_STANDARD = 2;
|
||||
const USER_ROLE_ADMIN = 3;
|
||||
const USER_ROLE_BILLING_ADMIN = 4;
|
||||
const USER_ROLE_ROOT_ADMIN = 5;
|
||||
const ROLE_DEVELOPER = 100;
|
||||
const ROLE_ROOT_ADMIN = 200;
|
||||
const ROLE_SITE_OWNER = 300;
|
||||
const ROLE_SITE_ADMIN = 400;
|
||||
const ROLE_MANAGER = 500;
|
||||
const ROLE_USER = 600;
|
||||
const ROLE_VIEWER = 700;
|
||||
const ROLE_DISABLED = 800;
|
||||
/** __/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_ACCEPTED = 'accepted';
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public static $enums = [
|
||||
'role_id' => [
|
||||
1 => [
|
||||
'constant' => 'ROLE_OWNER',
|
||||
'label' => 'Owner',
|
||||
// ROLE_DEVELOPER = 100
|
||||
100 => [
|
||||
'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 => [
|
||||
'constant' => 'ROLE_ADMIN',
|
||||
'label' => 'Admin',
|
||||
// ROLE_ROOT_ADMIN = 200
|
||||
200 => [
|
||||
'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 => [
|
||||
'constant' => 'ROLE_MEMBER',
|
||||
'label' => 'Member',
|
||||
// ROLE_SITE_OWNER = 300
|
||||
300 => [
|
||||
'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',
|
||||
'label' => 'Viewer',
|
||||
'permissions' => [7], // VIEW(7) only
|
||||
'can_admin_roles' => [],
|
||||
],
|
||||
],
|
||||
'user_role_id' => [
|
||||
1 => [
|
||||
'constant' => 'USER_ROLE_READ_ONLY',
|
||||
'label' => 'Read Only',
|
||||
'order' => 1,
|
||||
],
|
||||
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,
|
||||
// ROLE_DISABLED = 800
|
||||
800 => [
|
||||
'constant' => 'ROLE_DISABLED',
|
||||
'label' => 'Disabled',
|
||||
'permissions' => [],
|
||||
'can_admin_roles' => [],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -133,22 +185,9 @@ class User_Model extends Rsx_Site_Model_Abstract
|
||||
*/
|
||||
protected $table = 'users';
|
||||
|
||||
/**
|
||||
* Column metadata for special handling
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $columnMeta = [
|
||||
'role_id' => [
|
||||
'type' => 'enum',
|
||||
'values' => [
|
||||
1 => 'owner',
|
||||
2 => 'admin',
|
||||
3 => 'member',
|
||||
4 => 'viewer',
|
||||
],
|
||||
],
|
||||
];
|
||||
// =========================================================================
|
||||
// RELATIONSHIPS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -213,17 +401,9 @@ class User_Model extends Rsx_Site_Model_Abstract
|
||||
return 'Unknown User';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role name
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_role_name()
|
||||
{
|
||||
$roles = $this->get_enum_values('role_id');
|
||||
|
||||
return $roles[$this->role_id] ?? null;
|
||||
}
|
||||
// =========================================================================
|
||||
// STATUS METHODS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -281,20 +430,12 @@ class User_Model extends Rsx_Site_Model_Abstract
|
||||
* Scope to get users with a specific role
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param int|string $role
|
||||
* @param int $role_id Role constant
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeWithRole($query, $role)
|
||||
public function scopeWithRole($query, int $role_id)
|
||||
{
|
||||
if (is_string($role)) {
|
||||
$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);
|
||||
return $query->where('role_id', $role_id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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
|
||||
* 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) {
|
||||
// Parse URL to get path and query params
|
||||
@@ -210,10 +213,11 @@ class Spa {
|
||||
// Try exact match first
|
||||
const exact_pattern = '/' + path;
|
||||
if (Spa.routes[exact_pattern]) {
|
||||
const action_class = Spa.routes[exact_pattern];
|
||||
return {
|
||||
action_class: Spa.routes[exact_pattern],
|
||||
action_class: action_class,
|
||||
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)
|
||||
// This matches the PHP Dispatcher behavior where route params override GET params
|
||||
const args = { ...parsed.query_params, ...match };
|
||||
const action_class = Spa.routes[pattern];
|
||||
|
||||
return {
|
||||
action_class: Spa.routes[pattern],
|
||||
action_class: action_class,
|
||||
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:', {
|
||||
action_class: route_match?.action_class?.name,
|
||||
args: route_match?.args,
|
||||
layout: route_match?.layout,
|
||||
layouts: route_match?.layouts,
|
||||
});
|
||||
|
||||
// Check if action's @spa() attribute matches current SPA bootstrap
|
||||
@@ -647,8 +652,8 @@ class Spa {
|
||||
Spa.path = parsed.path;
|
||||
Spa.params = route_match.args;
|
||||
|
||||
// Get layout name and action info
|
||||
const layout_name = route_match.layout;
|
||||
// Get layout chain and action info
|
||||
const target_layouts = route_match.layouts;
|
||||
const action_class = route_match.action_class;
|
||||
const action_name = action_class.name;
|
||||
|
||||
@@ -658,43 +663,12 @@ class Spa {
|
||||
path: parsed.path,
|
||||
params: route_match.args,
|
||||
action: action_name,
|
||||
layout: layout_name,
|
||||
layouts: target_layouts,
|
||||
history_mode: opts.history
|
||||
});
|
||||
|
||||
// Check if we need a new layout
|
||||
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
|
||||
// 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();
|
||||
// Resolve layout chain - find divergence point and reuse matching layouts
|
||||
await Spa._resolve_layout_chain(target_layouts, action_name, route_match.args, url);
|
||||
|
||||
// Scroll Restoration #1: Immediate (after action starts)
|
||||
// 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
|
||||
// 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) {
|
||||
console.error('[Spa] Dispatch error:', error);
|
||||
// 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
|
||||
* This shouldn't happen - prevents infinite redirect loops
|
||||
|
||||
@@ -30,16 +30,26 @@ function route(pattern) {
|
||||
|
||||
/**
|
||||
* @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:
|
||||
* @layout('Frontend_Layout')
|
||||
* class Contacts_Index_Action extends Spa_Action { }
|
||||
* @layout('Frontend_Layout') // Outermost (header/footer)
|
||||
* @layout('Settings_Layout') // Nested inside Frontend_Layout
|
||||
* class Settings_Profile_Action extends Spa_Action { }
|
||||
*/
|
||||
function layout(layout_name) {
|
||||
return function (target) {
|
||||
// Store layout name on the class
|
||||
target._spa_layout = layout_name;
|
||||
// Store layout names as array for sublayout chain support
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
/**
|
||||
* Spa_Layout - Base class for Spa layouts
|
||||
*
|
||||
* Layouts provide the persistent wrapper (header, nav, footer) around actions.
|
||||
* They render directly to body and contain a content area where actions render.
|
||||
* Layouts provide the persistent wrapper (header, nav, footer) around actions or sublayouts.
|
||||
* 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:
|
||||
* - Must have an element with $id="content" where actions will render
|
||||
* - Persists across action navigations (only re-created when layout changes)
|
||||
* - Must have an element with $sid="content" where children (sublayouts or actions) render
|
||||
* - Persists across navigations as long as it remains in the layout chain
|
||||
*
|
||||
* Lifecycle events triggered for actions:
|
||||
* - before_action_init, action_init
|
||||
* - before_action_render, action_render
|
||||
* - before_action_ready, action_ready
|
||||
* Properties set by Spa:
|
||||
* - this.action - Reference to the current action (bottom of chain)
|
||||
*
|
||||
* 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Override this in subclasses to react to action changes.
|
||||
@@ -223,6 +99,6 @@ class Spa_Layout extends Component {
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Loads existing session but does not create new one
|
||||
@@ -159,7 +150,7 @@ class Session extends Rsx_System_Model_Abstract
|
||||
self::$_has_init = true;
|
||||
|
||||
// CLI mode: do nothing
|
||||
if (self::__is_cli() || self::__is_fpc_client()) {
|
||||
if (self::__is_cli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,8 +206,8 @@ class Session extends Rsx_System_Model_Abstract
|
||||
}
|
||||
self::$_has_activate = true;
|
||||
|
||||
// CLI & FPC mode: do nothing
|
||||
if (self::__is_cli() || self::__is_fpc_client()) {
|
||||
// CLI mode: do nothing
|
||||
if (self::__is_cli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,33 @@ use App\RSpade\Core\Controller\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 $last_names = 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
|
||||
json_response([
|
||||
'found' => false,
|
||||
|
||||
@@ -172,7 +172,6 @@ class Jqhtml_Integration {
|
||||
if (is_top_level) {
|
||||
(async () => {
|
||||
await Promise.all(promises);
|
||||
await Rsx._rsx_call_all_classes('on_jqhtml_ready');
|
||||
Rsx.trigger('jqhtml_ready');
|
||||
})();
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
z-index: 1200; // Flash alerts layer - see rsx:man zindex
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
|
||||
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');
|
||||
|
||||
// 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:
|
||||
- fatal: Shows file:line and full message
|
||||
|
||||
@@ -793,85 +793,119 @@ API REFERENCE
|
||||
|
||||
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')]
|
||||
#[Auth('Permission::authenticated()')]
|
||||
public static function dashboard(Request $request, array $params = [])
|
||||
A code quality rule (PHP-AUTH-01) verifies that all endpoints have auth
|
||||
checks, either in the method body or in the controller's pre_dispatch().
|
||||
|
||||
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
|
||||
$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
|
||||
public static function pre_dispatch(Request $request, array $params = [])
|
||||
{
|
||||
if (!RsxAuth::check()) {
|
||||
return false;
|
||||
if (!Session::is_logged_in()) {
|
||||
return response_unauthorized();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$user_id = RsxAuth::id();
|
||||
$site_id = Session::get_site_id();
|
||||
#[Route('/dashboard')]
|
||||
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)
|
||||
->where('site_id', $site_id)
|
||||
->first();
|
||||
|
||||
return $user && $user->role_id <= 2; // OWNER or ADMIN
|
||||
#[Ajax_Endpoint]
|
||||
public static function save_data(Request $request, array $params = [])
|
||||
{
|
||||
// Also protected by pre_dispatch
|
||||
}
|
||||
}
|
||||
|
||||
Usage in routes:
|
||||
Method-Level Authentication:
|
||||
|
||||
#[Route('/admin/users')]
|
||||
#[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:
|
||||
Add auth check at the start of individual methods:
|
||||
|
||||
#[Ajax_Endpoint]
|
||||
#[Auth('Permission::authenticated()')]
|
||||
public static function save_settings(Request $request, array $params = [])
|
||||
{
|
||||
$user_id = RsxAuth::id();
|
||||
if (!Session::is_logged_in()) {
|
||||
return response_unauthorized();
|
||||
}
|
||||
|
||||
// Process request
|
||||
}
|
||||
|
||||
On authentication failure:
|
||||
Regular routes: Redirect to /login
|
||||
Ajax endpoints: Return JSON: {success: false, error_type: "permission_denied"}
|
||||
Response Helpers:
|
||||
|
||||
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 = [])
|
||||
{
|
||||
// Require login for all /app/* routes
|
||||
if (str_starts_with($request->path(), 'app/')) {
|
||||
if (!RsxAuth::check()) {
|
||||
return redirect('/login');
|
||||
}
|
||||
}
|
||||
Permission Checks:
|
||||
|
||||
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
|
||||
|
||||
Overview:
|
||||
|
||||
@@ -4,23 +4,35 @@ NAME
|
||||
Bundle - RSX asset compilation and management system
|
||||
|
||||
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
|
||||
{
|
||||
return [
|
||||
'include' => [
|
||||
'jquery', // Module alias
|
||||
'Bootstrap5_Bundle', // Bundle class
|
||||
'rsx/app/myapp', // Directory
|
||||
'rsx/lib/utils.js', // Specific file
|
||||
'bootstrap5', // Module alias
|
||||
'Quill_Bundle', // Asset bundle (explicit)
|
||||
'rsx/theme', // Directory (auto-discovers asset bundles)
|
||||
__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
|
||||
{!! My_Bundle::render() !!}
|
||||
|
||||
@@ -64,51 +76,123 @@ DESCRIPTION
|
||||
- Zero configuration SCSS compilation
|
||||
|
||||
CREATING A BUNDLE
|
||||
1. Extend Rsx_Bundle_Abstract
|
||||
2. Implement define() method
|
||||
3. Return configuration array with 'include' key
|
||||
CREATING A MODULE BUNDLE (page entry point):
|
||||
1. Extend Rsx_Module_Bundle_Abstract
|
||||
2. Implement define() method
|
||||
3. Return configuration array with 'include' key
|
||||
|
||||
Example:
|
||||
class Dashboard_Bundle extends Rsx_Bundle_Abstract
|
||||
{
|
||||
public static function define(): array
|
||||
Example:
|
||||
class Dashboard_Bundle extends Rsx_Module_Bundle_Abstract
|
||||
{
|
||||
return [
|
||||
'include' => [
|
||||
'jquery',
|
||||
'lodash',
|
||||
'bootstrap5',
|
||||
'rsx/app/dashboard',
|
||||
],
|
||||
'config' => [
|
||||
'api_version' => '2.0',
|
||||
],
|
||||
];
|
||||
public static function define(): array
|
||||
{
|
||||
return [
|
||||
'include' => [
|
||||
'bootstrap5',
|
||||
'rsx/app/dashboard',
|
||||
],
|
||||
'config' => [
|
||||
'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
|
||||
Bundles exist ONLY at the top-level module directory. Never create
|
||||
bundles in subdirectories or submodules.
|
||||
MODULE BUNDLES exist at top-level module directories:
|
||||
|
||||
CORRECT:
|
||||
/rsx/app/login/login_bundle.php ✓ Top-level module
|
||||
/rsx/app/dashboard/dashboard_bundle.php ✓ Top-level module
|
||||
/rsx/app/frontend/frontend_bundle.php ✓ Top-level module
|
||||
/rsx/app/login/login_bundle.php - Module Bundle
|
||||
/rsx/app/frontend/frontend_bundle.php - Module Bundle
|
||||
|
||||
INCORRECT:
|
||||
/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
|
||||
ASSET BUNDLES live alongside their components:
|
||||
|
||||
WHY TOP-LEVEL ONLY:
|
||||
Including __DIR__ in a top-level bundle automatically includes all
|
||||
files in that module directory and all subdirectories recursively.
|
||||
This provides complete coverage without needing multiple bundles.
|
||||
CORRECT:
|
||||
/rsx/theme/components/inputs/select/Tom_Select_Bundle.php
|
||||
/rsx/theme/bootstrap5_src_bundle.php
|
||||
|
||||
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:
|
||||
// /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
|
||||
{
|
||||
@@ -124,16 +208,9 @@ BUNDLE PLACEMENT
|
||||
/rsx/app/login/login_controller.php
|
||||
/rsx/app/login/login_index.blade.php
|
||||
/rsx/app/login/login_index.js
|
||||
/rsx/app/login/accept_invite/accept_invite_controller.php
|
||||
/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/accept_invite/...
|
||||
/rsx/app/login/signup/...
|
||||
... and all other files in subdirectories
|
||||
|
||||
The __DIR__ constant resolves to the bundle's directory, making it
|
||||
self-referential and automatically including all module content.
|
||||
... and all files in subdirectories
|
||||
|
||||
INCLUDE TYPES
|
||||
Module Aliases
|
||||
|
||||
@@ -159,7 +159,7 @@ TROUBLESHOOTING
|
||||
Console_debug not showing:
|
||||
- Check CONSOLE_DEBUG_ENABLED=true
|
||||
- Verify filter settings
|
||||
- Use php artisan rsx:debug --console-log
|
||||
- Use php artisan rsx:debug --console
|
||||
|
||||
Bundles not updating:
|
||||
- Clear bundle cache in storage/rsx-build/bundles
|
||||
|
||||
@@ -5,17 +5,24 @@ NAME
|
||||
|
||||
SYNOPSIS
|
||||
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
|
||||
use App\RSpade\Core\Session\Session;
|
||||
|
||||
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'])]
|
||||
public static function index(Request $request, array $params = [])
|
||||
{
|
||||
return rsx_view('User_List');
|
||||
}
|
||||
|
||||
#[Auth('Permission::authenticated()')]
|
||||
#[Ajax_Endpoint]
|
||||
public static function get_profile(Request $request, array $params = [])
|
||||
{
|
||||
@@ -98,95 +105,73 @@ ROUTE PARAMETERS
|
||||
$name = $request->input('name');
|
||||
$email = $request->post('email');
|
||||
|
||||
REQUIRE ATTRIBUTE
|
||||
#[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)
|
||||
AUTHENTICATION
|
||||
|
||||
All routes MUST have at least one #[Auth] attribute, either on:
|
||||
- The route method itself
|
||||
- The controller's pre_dispatch() method (applies to all routes)
|
||||
- Both (pre_dispatch Require runs first, then route Require)
|
||||
RSX uses manual authentication checks rather than declarative attributes.
|
||||
Auth checks are placed directly in controller code for visibility.
|
||||
|
||||
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:
|
||||
public static function method_name(Request $request, array $params, ...$args): mixed
|
||||
Controller-Wide Authentication (Recommended):
|
||||
|
||||
Returns:
|
||||
- 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 = [])
|
||||
class Dashboard_Controller extends Rsx_Controller_Abstract
|
||||
{
|
||||
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 = [])
|
||||
{
|
||||
if (!Session::is_logged_in()) {
|
||||
return response_unauthorized();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// All routes in this controller require admin role
|
||||
#[Route('/admin/users')]
|
||||
public static function users(Request $request, array $params = [])
|
||||
#[Route('/dashboard')]
|
||||
public static function index(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
|
||||
{
|
||||
public static function anybody(Request $request, array $params): mixed
|
||||
@@ -208,23 +193,21 @@ REQUIRE ATTRIBUTE
|
||||
}
|
||||
}
|
||||
|
||||
AJAX ENDPOINTS AND REQUIRE
|
||||
Ajax endpoints also require #[Auth] attributes.
|
||||
For Ajax endpoints, redirect parameters are ignored and JSON errors returned:
|
||||
AJAX ENDPOINTS AND AUTHENTICATION
|
||||
Ajax endpoints use the same auth pattern as routes.
|
||||
The pre_dispatch() check covers all methods in the controller:
|
||||
|
||||
#[Auth('Permission::authenticated()',
|
||||
message: 'Login required')]
|
||||
#[Ajax_Endpoint]
|
||||
public static function get_data(Request $request, array $params = [])
|
||||
{
|
||||
// Auth checked in pre_dispatch
|
||||
return ['data' => 'value'];
|
||||
}
|
||||
|
||||
On failure, returns:
|
||||
On response_unauthorized(), Ajax returns:
|
||||
{
|
||||
"success": false,
|
||||
"error": "Login required",
|
||||
"error_type": "permission_denied"
|
||||
"error_code": "unauthorized"
|
||||
}
|
||||
|
||||
HTTP Status: 403 Forbidden
|
||||
@@ -296,7 +279,7 @@ PRE_DISPATCH HOOK
|
||||
public static function pre_dispatch(Request $request, array $params = [])
|
||||
{
|
||||
// Check authentication
|
||||
if (!RsxAuth::check()) {
|
||||
if (!Session::is_logged_in()) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
@@ -322,11 +305,11 @@ RESPONSE TYPES
|
||||
return response()->json(['key' => 'value']);
|
||||
|
||||
AUTHENTICATION
|
||||
Use RsxAuth in pre_dispatch:
|
||||
Use Session:: in pre_dispatch:
|
||||
|
||||
public static function pre_dispatch(Request $request, array $params = [])
|
||||
{
|
||||
if (!RsxAuth::check()) {
|
||||
if (!Session::is_logged_in()) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
@@ -436,26 +419,25 @@ TROUBLESHOOTING
|
||||
- Run php artisan rsx:routes to list all
|
||||
- Clear cache: php artisan rsx:clean
|
||||
|
||||
Missing #[Auth] attribute error:
|
||||
- Add #[Auth('Permission::anybody()')] to route method
|
||||
- OR add #[Auth] to pre_dispatch() for controller-wide access
|
||||
- Rebuild manifest: php artisan rsx:manifest:build
|
||||
PHP-AUTH-01 code quality error:
|
||||
- Add auth check to pre_dispatch() (recommended)
|
||||
- OR add auth check at start of method
|
||||
- OR add @auth-exempt comment for public endpoints
|
||||
- Run: php artisan rsx:check
|
||||
|
||||
Permission denied (403):
|
||||
- Check permission method logic returns true
|
||||
- Verify Session::is_logged_in() for authenticated routes
|
||||
- Add message parameter for clearer errors
|
||||
- Check permission method exists in rsx/permission.php
|
||||
Permission denied / Unauthorized:
|
||||
- Check Session::is_logged_in() returns true
|
||||
- Verify Permission helpers in rsx/permission.php
|
||||
- Check user has required role/permission
|
||||
|
||||
JavaScript stub missing:
|
||||
- Ensure Ajax_Endpoint attribute present
|
||||
- Ensure #[Auth] attribute present on Ajax method
|
||||
- Rebuild manifest: php artisan rsx:manifest:build
|
||||
- Check storage/rsx-build/js-stubs/
|
||||
|
||||
Authentication issues:
|
||||
- Implement pre_dispatch hook
|
||||
- Use Permission::authenticated() in Require
|
||||
- Implement pre_dispatch() with Session::is_logged_in() check
|
||||
- Return response_unauthorized() on failure
|
||||
- Verify session configuration
|
||||
|
||||
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
|
||||
|
||||
login_users
|
||||
Purpose: Authentication session/token tracking
|
||||
Managed by: RsxAuth system
|
||||
Records: Active login sessions with 365-day persistence
|
||||
Usage: Developers query for "active sessions", implement logout-all
|
||||
Schema: user_id (FK), session_token, ip_address, last_activity, expires_at
|
||||
Purpose: Authentication identity tracking
|
||||
Managed by: Session system
|
||||
Records: Login credentials with 365-day session persistence
|
||||
Usage: Developers query via Session::get_login_user()
|
||||
Schema: email, password_hash, display_name, last_login, is_active
|
||||
|
||||
user_profiles
|
||||
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
|
||||
|
||||
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
|
||||
- Search index? System (_search_indexes) - API is Search::query()
|
||||
- Client records? Application (clients) - business domain entity
|
||||
|
||||
@@ -110,16 +110,23 @@ PHP CONSTANTS
|
||||
|
||||
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
|
||||
User_Model.STATUS_ACTIVE // 1
|
||||
User_Model.STATUS_INACTIVE // 2
|
||||
Static Constants
|
||||
Project_Model.STATUS_ACTIVE // 2
|
||||
Project_Model.STATUS_PLANNING // 1
|
||||
|
||||
Static Methods
|
||||
User_Model.status_id_enum_val() // Full enum definitions
|
||||
User_Model.status_id_enum_select() // Filtered for dropdowns
|
||||
User_Model.status_id_label_list() // All labels keyed by value
|
||||
Project_Model.status_enum_val() // Full enum definitions
|
||||
Project_Model.status_enum_select() // Filtered for dropdowns
|
||||
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
|
||||
|
||||
|
||||
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() {
|
||||
// Getter - return current value
|
||||
if (arguments.length === 0) {
|
||||
return this.$id('input').val();
|
||||
return this.$sid('input').val();
|
||||
}
|
||||
// Setter - update value
|
||||
else {
|
||||
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">
|
||||
<div class="rating">
|
||||
<% for (let i = 1; i <= 5; i++) { %>
|
||||
<i $id="star_<%= i %>"
|
||||
<i $sid="star_<%= i %>"
|
||||
class="bi bi-star<%= this.data.value >= i ? '-fill' : '' %>"
|
||||
data-rating="<%= i %>"></i>
|
||||
<% } %>
|
||||
|
||||
@@ -209,7 +209,7 @@ EXAMPLES
|
||||
<% if (this.args.show_price === "true") { %>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -300,21 +300,21 @@ Your form component must:
|
||||
- Extend Jqhtml_Component
|
||||
- Implement vals() method for getting/setting values
|
||||
- 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):
|
||||
|
||||
<Define:My_Form tag="div">
|
||||
<div $id="error_container"></div>
|
||||
<div $sid="error_container"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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>
|
||||
</Define:My_Form>
|
||||
|
||||
@@ -334,14 +334,14 @@ Example form component class (my_form.js):
|
||||
vals(values) {
|
||||
if (values) {
|
||||
// Setter
|
||||
this.$id('name_input').val(values.name || '');
|
||||
this.$id('email_input').val(values.email || '');
|
||||
this.$sid('name_input').val(values.name || '');
|
||||
this.$sid('email_input').val(values.email || '');
|
||||
return null;
|
||||
} else {
|
||||
// Getter
|
||||
return {
|
||||
name: this.$id('name_input').val(),
|
||||
email: this.$id('email_input').val()
|
||||
name: this.$sid('name_input').val(),
|
||||
email: this.$sid('email_input').val()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -985,7 +985,7 @@ Modal Won't Close
|
||||
- Use Modal.close() to force close
|
||||
|
||||
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
|
||||
- Check that fields are wrapped in .form-group containers
|
||||
- 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:
|
||||
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
|
||||
security (no mass fetching).
|
||||
|
||||
See: php artisan rsx:man model_fetch
|
||||
Returns instantiated JS model class with enum properties and optional custom
|
||||
methods. See: php artisan rsx:man model_fetch
|
||||
|
||||
RELATIONSHIPS
|
||||
|
||||
@@ -349,6 +348,11 @@ Columns:
|
||||
- Use BIGINT for all integers, TINYINT(1) for booleans only
|
||||
- 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
|
||||
|
||||
php artisan rsx:man model_fetch - Ajax ORM fetch system
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
MODEL_FETCH(3) RSX Framework Manual MODEL_FETCH(3)
|
||||
|
||||
NAME
|
||||
model_fetch - RSX Ajax ORM with secure model fetching from JavaScript
|
||||
|
||||
@@ -20,6 +22,22 @@ DESCRIPTION
|
||||
- Individual authorization checks for each record
|
||||
- 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
|
||||
Explicit Opt-In:
|
||||
Models must deliberately implement fetch() with the attribute.
|
||||
@@ -33,9 +51,9 @@ SECURITY MODEL
|
||||
Models can filter sensitive fields before returning data.
|
||||
Complete control over what data JavaScript receives.
|
||||
|
||||
No Mass Fetching:
|
||||
Framework splits array requests into individual fetch calls.
|
||||
Prevents bulk data extraction without individual authorization.
|
||||
Single Record Fetching:
|
||||
Each fetch() call retrieves exactly one record.
|
||||
See FUTURE DEVELOPMENT for planned batch fetching.
|
||||
|
||||
IMPLEMENTING FETCHABLE MODELS
|
||||
Required Components:
|
||||
@@ -43,7 +61,12 @@ IMPLEMENTING FETCHABLE MODELS
|
||||
2. Add #[Ajax_Endpoint_Model_Fetch] attribute
|
||||
3. Accept exactly one parameter: $id (single ID only)
|
||||
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:
|
||||
use Ajax_Endpoint_Model_Fetch;
|
||||
@@ -54,7 +77,7 @@ IMPLEMENTING FETCHABLE MODELS
|
||||
public static function fetch($id)
|
||||
{
|
||||
// Authorization check
|
||||
if (!RsxAuth::check()) {
|
||||
if (!Session::is_logged_in()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -70,7 +93,7 @@ IMPLEMENTING FETCHABLE MODELS
|
||||
#[Ajax_Endpoint_Model_Fetch]
|
||||
public static function fetch($id)
|
||||
{
|
||||
$user = RsxAuth::user();
|
||||
$user = Session::get_user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
@@ -95,7 +118,7 @@ IMPLEMENTING FETCHABLE MODELS
|
||||
#[Ajax_Endpoint_Model_Fetch]
|
||||
public static function fetch($id)
|
||||
{
|
||||
if (!RsxAuth::check()) {
|
||||
if (!Session::is_logged_in()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -113,98 +136,180 @@ IMPLEMENTING FETCHABLE MODELS
|
||||
}
|
||||
}
|
||||
|
||||
JAVASCRIPT USAGE
|
||||
Single Record Fetching:
|
||||
// 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
|
||||
Custom Array Return (recommended for CRUD pages):
|
||||
class Client_Model extends Rsx_Model
|
||||
{
|
||||
#[Ajax_Endpoint_Model_Fetch]
|
||||
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
|
||||
if (!$current_user || $current_user->id != $id) {
|
||||
@@ -216,12 +321,12 @@ AUTHORIZATION PATTERNS
|
||||
}
|
||||
|
||||
Role-Based Access:
|
||||
class Admin_Report_Model extends Rsx_Model
|
||||
class Admin_Report_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
#[Ajax_Endpoint_Model_Fetch]
|
||||
public static function fetch($id)
|
||||
{
|
||||
$user = RsxAuth::user();
|
||||
$user = Session::get_user();
|
||||
|
||||
// Only admin users can fetch reports
|
||||
if (!$user || !$user->hasRole('admin')) {
|
||||
@@ -233,7 +338,7 @@ AUTHORIZATION PATTERNS
|
||||
}
|
||||
|
||||
Public Data Access:
|
||||
class Public_Article_Model extends Rsx_Model
|
||||
class Public_Article_Model extends Rsx_Model_Abstract
|
||||
{
|
||||
#[Ajax_Endpoint_Model_Fetch]
|
||||
public static function fetch($id)
|
||||
@@ -266,16 +371,51 @@ BASE MODEL PROTECTION
|
||||
- Provides clear implementation guidance
|
||||
- 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
|
||||
PHP Testing:
|
||||
// Test authorization
|
||||
$user = User_Model::factory()->create();
|
||||
RsxAuth::login($user);
|
||||
Session::login($user);
|
||||
|
||||
$product = Product_Model::fetch(1);
|
||||
$this->assertNotFalse($product);
|
||||
|
||||
RsxAuth::logout();
|
||||
Session::logout();
|
||||
$product = Product_Model::fetch(1);
|
||||
$this->assertFalse($product);
|
||||
|
||||
@@ -304,7 +444,7 @@ COMMON PATTERNS
|
||||
$model = static::find($id);
|
||||
if (!$model) return false;
|
||||
|
||||
$user = RsxAuth::user();
|
||||
$user = Session::get_user();
|
||||
if (!$user || !$user->is_admin) {
|
||||
// Remove admin-only fields for non-admin users
|
||||
unset($model->internal_notes);
|
||||
@@ -314,6 +454,49 @@ COMMON PATTERNS
|
||||
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
|
||||
Model Not Fetchable:
|
||||
- Verify #[Ajax_Endpoint_Model_Fetch] attribute present
|
||||
@@ -322,7 +505,7 @@ TROUBLESHOOTING
|
||||
- Verify model included in bundle manifest
|
||||
|
||||
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
|
||||
- Test with different user roles and permissions
|
||||
- Use rsx_dump_die() to debug authorization flow
|
||||
@@ -333,13 +516,458 @@ TROUBLESHOOTING
|
||||
- Ensure bundle compiles without errors
|
||||
- Confirm JavaScript bundle loads in browser
|
||||
|
||||
Array Handling Issues:
|
||||
- Never use is_array($id) in fetch() method
|
||||
- Framework handles array splitting automatically
|
||||
- Check for typos in model class names
|
||||
- Verify all IDs in array are valid integers
|
||||
JAVASCRIPT ORM ARCHITECTURE
|
||||
The JavaScript ORM provides secure, read-only access to server-side models
|
||||
through explicit opt-in. This section describes the full architecture vision.
|
||||
|
||||
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
|
||||
controller - Internal API attribute patterns
|
||||
manifest_api - Model discovery and stub generation
|
||||
coding_standards - Security patterns and authorization
|
||||
crud(3) - Standard CRUD implementation using Model.fetch()
|
||||
controller(3) - Internal API attribute patterns
|
||||
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:
|
||||
- Extend appropriate base classes
|
||||
- Use static on_app_ready() method for initialization
|
||||
- Use static on_jqhtml_ready() to wait for JQHTML components to load
|
||||
- No manual registration required
|
||||
|
||||
Lifecycle timing:
|
||||
- on_app_ready(): Runs when page initializes, before JQHTML components finish
|
||||
- on_jqhtml_ready(): Runs after all JQHTML components loaded and rendered
|
||||
- on_app_ready(): Runs when page is ready for initialization
|
||||
- For component readiness: await $(element).component().ready()
|
||||
|
||||
Important limitation:
|
||||
JavaScript only executes when bundle is rendered in HTML output.
|
||||
|
||||
@@ -39,13 +39,16 @@ CORE OPTIONS
|
||||
|
||||
CONSOLE OUTPUT
|
||||
|
||||
--console-log | --console-list
|
||||
--console
|
||||
Display all browser console output, not just errors. Shows console.log(),
|
||||
console.warn(), console.info(), and console_debug() output.
|
||||
|
||||
--console-log
|
||||
Alias for --console.
|
||||
|
||||
--console-debug-filter=<channel>
|
||||
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
|
||||
Show all console_debug() channels without filtering.
|
||||
@@ -177,7 +180,7 @@ OUTPUT FORMAT
|
||||
The command outputs in a terse, parseable format:
|
||||
- Status line with URL and HTTP status code
|
||||
- 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)
|
||||
- Response headers (if --headers used)
|
||||
- Response body (unless --no-body used)
|
||||
|
||||
@@ -99,7 +99,7 @@ SPA ARCHITECTURE
|
||||
|
||||
5. Layout Template (.jqhtml)
|
||||
- Persistent wrapper around actions
|
||||
- Must have element with $id="content"
|
||||
- Must have element with $sid="content"
|
||||
- Persists across action navigation
|
||||
|
||||
6. Layout Class (.js)
|
||||
@@ -114,7 +114,7 @@ SPA ARCHITECTURE
|
||||
4. Client JavaScript discovers all actions via manifest
|
||||
5. Router matches URL to action class
|
||||
6. Creates layout on <body>
|
||||
7. Creates action inside layout $id="content" area
|
||||
7. Creates action inside layout $sid="content" area
|
||||
|
||||
Subsequent Navigation:
|
||||
1. User clicks link or calls Spa.dispatch()
|
||||
@@ -222,7 +222,7 @@ JAVASCRIPT ACTIONS
|
||||
|
||||
on_ready() {
|
||||
// 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>
|
||||
</header>
|
||||
|
||||
<main $id="content">
|
||||
<main $sid="content">
|
||||
<!-- Actions render here -->
|
||||
</main>
|
||||
|
||||
@@ -322,7 +322,7 @@ LAYOUTS
|
||||
</Define:Frontend_Layout>
|
||||
|
||||
Requirements:
|
||||
- Must have element with $id="content"
|
||||
- Must have element with $sid="content"
|
||||
- Content area is where actions render
|
||||
- Layout persists across navigation
|
||||
|
||||
@@ -351,6 +351,78 @@ LAYOUTS
|
||||
- Can immediately access this.action properties
|
||||
- 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
|
||||
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
|
||||
"/contacts" will produce errors.
|
||||
@@ -534,7 +606,7 @@ EXAMPLES
|
||||
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main $id="content"></main>
|
||||
<main $sid="content"></main>
|
||||
</div>
|
||||
</Define:Frontend_Layout>
|
||||
|
||||
@@ -632,12 +704,12 @@ TROUBLESHOOTING
|
||||
Navigation Not Working:
|
||||
- Verify using Rsx.Route() not hardcoded URLs
|
||||
- Check @spa() decorator references correct bootstrap controller
|
||||
- Ensure layout has $id="content" element
|
||||
- Ensure layout has $sid="content" element
|
||||
- Test Spa.dispatch() directly
|
||||
|
||||
Layout Not Persisting:
|
||||
- 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
|
||||
|
||||
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_init',
|
||||
'on_app_ready',
|
||||
'on_jqhtml_ready'
|
||||
];
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* 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'
|
||||
* Reason: Routes always point to PHP controllers (server-side)
|
||||
*
|
||||
@@ -658,9 +658,9 @@ class RspadeDefinitionProvider {
|
||||
if (wordRange) {
|
||||
const method_name = document.getText(wordRange);
|
||||
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 classMatch = beforeMethod.match(/([A-Z][A-Za-z0-9_]*)\.$/);
|
||||
const classMatch = beforeMethod.match(/([A-Z][A-Za-z0-9_]*)(?:\.|::)$/);
|
||||
if (classMatch) {
|
||||
const class_name = classMatch[1];
|
||||
// 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