e3c15979b8
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Has been cancelled
Rename top-level src/ directory to source/ and update all references in .gitignore, CLAUDE.md, manifest.xml, docs, and PATH comments. Internal namespace path="src" attributes within extension packages are unchanged (they refer to the package-internal src/ folder). Closes #188 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
523 lines
14 KiB
PHP
523 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* @package MokoWaaS
|
|
* @subpackage com_mokowaas
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
use Joomla\Registry\Registry;
|
|
|
|
/**
|
|
* .htaccess / NginX configuration generator.
|
|
*
|
|
* @since 02.32.00
|
|
*/
|
|
class HtaccessModel extends BaseDatabaseModel
|
|
{
|
|
private const DEFAULTS = [
|
|
// Security
|
|
'disable_directory_listing' => 1,
|
|
'block_sensitive_files' => 1,
|
|
'block_php_in_uploads' => 1,
|
|
'disable_server_signature' => 1,
|
|
'prevent_clickjacking' => 1,
|
|
'prevent_mime_sniffing' => 1,
|
|
'xss_protection' => 1,
|
|
'disable_trace_track' => 1,
|
|
'referrer_policy' => 'strict-origin-when-cross-origin',
|
|
'hsts_enabled' => 0,
|
|
'hsts_max_age' => 31536000,
|
|
'hsts_subdomains' => 0,
|
|
'csp_enabled' => 0,
|
|
'csp_value' => '',
|
|
'permissions_policy' => 0,
|
|
'permissions_value' => '',
|
|
// Performance
|
|
'enable_gzip' => 1,
|
|
'enable_expires' => 1,
|
|
'expires_html' => 3600,
|
|
'expires_css_js' => 2592000,
|
|
'expires_images' => 31536000,
|
|
'etag_control' => 0,
|
|
// SEO
|
|
'www_redirect' => 'off',
|
|
'redirect_index_php' => 1,
|
|
'force_trailing_slash' => 0,
|
|
// Custom
|
|
'custom_rules' => '',
|
|
];
|
|
|
|
/**
|
|
* Get saved options or defaults.
|
|
*/
|
|
public function getOptions(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
|
$db->setQuery($query);
|
|
$params = new Registry($db->loadResult() ?? '{}');
|
|
|
|
$htaccess = $params->get('htaccess', null);
|
|
|
|
if ($htaccess)
|
|
{
|
|
return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true));
|
|
}
|
|
|
|
return self::DEFAULTS;
|
|
}
|
|
|
|
/**
|
|
* Save options to component params.
|
|
*/
|
|
public function saveOptions(array $options): array
|
|
{
|
|
try
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
|
$db->setQuery($query);
|
|
$params = new Registry($db->loadResult() ?? '{}');
|
|
|
|
$clean = [];
|
|
|
|
foreach (self::DEFAULTS as $key => $default)
|
|
{
|
|
$clean[$key] = $options[$key] ?? $default;
|
|
}
|
|
|
|
$params->set('htaccess', $clean);
|
|
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update($db->quoteName('#__extensions'))
|
|
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
|
)->execute();
|
|
|
|
return ['success' => true, 'message' => 'Options saved.'];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the current .htaccess file.
|
|
*/
|
|
public function readCurrentHtaccess(): string
|
|
{
|
|
$path = JPATH_ROOT . '/.htaccess';
|
|
|
|
return file_exists($path) ? file_get_contents($path) : '';
|
|
}
|
|
|
|
/**
|
|
* Write .htaccess to disk with backup.
|
|
*/
|
|
public function saveHtaccess(string $content): array
|
|
{
|
|
$path = JPATH_ROOT . '/.htaccess';
|
|
$backup = JPATH_ROOT . '/.htaccess.mokowaas.bak';
|
|
|
|
try
|
|
{
|
|
// Backup existing
|
|
if (file_exists($path))
|
|
{
|
|
copy($path, $backup);
|
|
}
|
|
|
|
$result = file_put_contents($path, $content);
|
|
|
|
if ($result === false)
|
|
{
|
|
// Restore backup
|
|
if (file_exists($backup))
|
|
{
|
|
copy($backup, $path);
|
|
}
|
|
|
|
return ['success' => false, 'message' => '.htaccess is not writable.'];
|
|
}
|
|
|
|
return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak'];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
if (file_exists($backup))
|
|
{
|
|
@copy($backup, $path);
|
|
}
|
|
|
|
return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate .htaccess content from options.
|
|
*/
|
|
public function generateHtaccess(array $opts): string
|
|
{
|
|
$lines = [];
|
|
$lines[] = '##';
|
|
$lines[] = '## MokoWaaS Generated .htaccess';
|
|
$lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC';
|
|
$lines[] = '## DO NOT EDIT — regenerate from MokoWaaS > .htaccess Maker';
|
|
$lines[] = '##';
|
|
$lines[] = '';
|
|
|
|
// --- Security ---
|
|
if (!empty($opts['disable_directory_listing']))
|
|
{
|
|
$lines[] = '## Disable directory listing';
|
|
$lines[] = 'Options -Indexes';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['disable_server_signature']))
|
|
{
|
|
$lines[] = '## Hide server signature';
|
|
$lines[] = 'ServerSignature Off';
|
|
$lines[] = '<IfModule mod_headers.c>';
|
|
$lines[] = ' Header unset X-Powered-By';
|
|
$lines[] = ' Header unset Server';
|
|
$lines[] = '</IfModule>';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['block_sensitive_files']))
|
|
{
|
|
$lines[] = '## Block access to sensitive files';
|
|
$lines[] = '<FilesMatch "^(htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt|joomla\.xml|robots\.txt\.dist)$">';
|
|
$lines[] = ' <IfModule mod_authz_core.c>';
|
|
$lines[] = ' Require all denied';
|
|
$lines[] = ' </IfModule>';
|
|
$lines[] = '</FilesMatch>';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['block_php_in_uploads']))
|
|
{
|
|
$lines[] = '## Block PHP execution in upload directories';
|
|
$dirs = ['images', 'media', 'tmp', 'cache', 'logs'];
|
|
|
|
foreach ($dirs as $dir)
|
|
{
|
|
$lines[] = '<Directory "' . $dir . '">';
|
|
$lines[] = ' <FilesMatch "\.php$">';
|
|
$lines[] = ' <IfModule mod_authz_core.c>';
|
|
$lines[] = ' Require all denied';
|
|
$lines[] = ' </IfModule>';
|
|
$lines[] = ' </FilesMatch>';
|
|
$lines[] = '</Directory>';
|
|
}
|
|
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['disable_trace_track']))
|
|
{
|
|
$lines[] = '## Disable TRACE and TRACK methods';
|
|
$lines[] = '<IfModule mod_rewrite.c>';
|
|
$lines[] = ' RewriteEngine On';
|
|
$lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)';
|
|
$lines[] = ' RewriteRule .* - [F]';
|
|
$lines[] = '</IfModule>';
|
|
$lines[] = '';
|
|
}
|
|
|
|
// Security headers
|
|
$headers = [];
|
|
|
|
if (!empty($opts['prevent_clickjacking']))
|
|
{
|
|
$headers[] = ' Header always set X-Frame-Options "SAMEORIGIN"';
|
|
}
|
|
|
|
if (!empty($opts['prevent_mime_sniffing']))
|
|
{
|
|
$headers[] = ' Header always set X-Content-Type-Options "nosniff"';
|
|
}
|
|
|
|
if (!empty($opts['xss_protection']))
|
|
{
|
|
$headers[] = ' Header always set X-XSS-Protection "1; mode=block"';
|
|
}
|
|
|
|
$referrer = $opts['referrer_policy'] ?? '';
|
|
|
|
if (!empty($referrer) && $referrer !== 'off')
|
|
{
|
|
$headers[] = ' Header always set Referrer-Policy "' . $referrer . '"';
|
|
}
|
|
|
|
if (!empty($opts['hsts_enabled']))
|
|
{
|
|
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
|
|
$hsts = 'max-age=' . $maxAge;
|
|
|
|
if (!empty($opts['hsts_subdomains']))
|
|
{
|
|
$hsts .= '; includeSubDomains';
|
|
}
|
|
|
|
$headers[] = ' Header always set Strict-Transport-Security "' . $hsts . '"';
|
|
}
|
|
|
|
if (!empty($opts['csp_enabled']) && !empty($opts['csp_value']))
|
|
{
|
|
$headers[] = ' Header always set Content-Security-Policy "' . str_replace('"', '', $opts['csp_value']) . '"';
|
|
}
|
|
|
|
if (!empty($opts['permissions_policy']) && !empty($opts['permissions_value']))
|
|
{
|
|
$headers[] = ' Header always set Permissions-Policy "' . str_replace('"', '', $opts['permissions_value']) . '"';
|
|
}
|
|
|
|
if (!empty($headers))
|
|
{
|
|
$lines[] = '## Security headers';
|
|
$lines[] = '<IfModule mod_headers.c>';
|
|
$lines = array_merge($lines, $headers);
|
|
$lines[] = '</IfModule>';
|
|
$lines[] = '';
|
|
}
|
|
|
|
// --- Performance ---
|
|
if (!empty($opts['enable_gzip']))
|
|
{
|
|
$lines[] = '## GZip compression';
|
|
$lines[] = '<IfModule mod_deflate.c>';
|
|
$lines[] = ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css';
|
|
$lines[] = ' AddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript';
|
|
$lines[] = ' AddOutputFilterByType DEFLATE application/json application/xml application/rss+xml';
|
|
$lines[] = ' AddOutputFilterByType DEFLATE image/svg+xml application/font-woff application/font-woff2';
|
|
$lines[] = '</IfModule>';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['enable_expires']))
|
|
{
|
|
$html = (int) ($opts['expires_html'] ?? 3600);
|
|
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
|
|
$images = (int) ($opts['expires_images'] ?? 31536000);
|
|
|
|
$lines[] = '## Browser caching';
|
|
$lines[] = '<IfModule mod_expires.c>';
|
|
$lines[] = ' ExpiresActive On';
|
|
$lines[] = ' ExpiresDefault "access plus ' . $html . ' seconds"';
|
|
$lines[] = ' ExpiresByType text/html "access plus ' . $html . ' seconds"';
|
|
$lines[] = ' ExpiresByType text/css "access plus ' . $cssJs . ' seconds"';
|
|
$lines[] = ' ExpiresByType text/javascript "access plus ' . $cssJs . ' seconds"';
|
|
$lines[] = ' ExpiresByType application/javascript "access plus ' . $cssJs . ' seconds"';
|
|
$lines[] = ' ExpiresByType image/jpeg "access plus ' . $images . ' seconds"';
|
|
$lines[] = ' ExpiresByType image/png "access plus ' . $images . ' seconds"';
|
|
$lines[] = ' ExpiresByType image/gif "access plus ' . $images . ' seconds"';
|
|
$lines[] = ' ExpiresByType image/webp "access plus ' . $images . ' seconds"';
|
|
$lines[] = ' ExpiresByType image/svg+xml "access plus ' . $images . ' seconds"';
|
|
$lines[] = ' ExpiresByType font/woff2 "access plus ' . $images . ' seconds"';
|
|
$lines[] = '</IfModule>';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['etag_control']))
|
|
{
|
|
$lines[] = '## Disable ETags (for load-balanced environments)';
|
|
$lines[] = '<IfModule mod_headers.c>';
|
|
$lines[] = ' Header unset ETag';
|
|
$lines[] = '</IfModule>';
|
|
$lines[] = 'FileETag None';
|
|
$lines[] = '';
|
|
}
|
|
|
|
// --- SEO / Redirects ---
|
|
$wwwRedirect = $opts['www_redirect'] ?? 'off';
|
|
|
|
if ($wwwRedirect !== 'off' || !empty($opts['redirect_index_php']) || !empty($opts['force_trailing_slash']))
|
|
{
|
|
$lines[] = '## SEO redirects';
|
|
$lines[] = '<IfModule mod_rewrite.c>';
|
|
$lines[] = ' RewriteEngine On';
|
|
|
|
if ($wwwRedirect === 'www')
|
|
{
|
|
$lines[] = '';
|
|
$lines[] = ' ## Force www';
|
|
$lines[] = ' RewriteCond %{HTTP_HOST} !^www\. [NC]';
|
|
$lines[] = ' RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]';
|
|
}
|
|
elseif ($wwwRedirect === 'non-www')
|
|
{
|
|
$lines[] = '';
|
|
$lines[] = ' ## Force non-www';
|
|
$lines[] = ' RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]';
|
|
$lines[] = ' RewriteRule ^(.*)$ https://%1/$1 [R=301,L]';
|
|
}
|
|
|
|
if (!empty($opts['redirect_index_php']))
|
|
{
|
|
$lines[] = '';
|
|
$lines[] = ' ## Redirect /index.php to root';
|
|
$lines[] = ' RewriteCond %{THE_REQUEST} ^[A-Z]{3,}\s/+index\.php\s [NC]';
|
|
$lines[] = ' RewriteRule ^index\.php/?(.*)$ /$1 [R=301,L]';
|
|
}
|
|
|
|
if (!empty($opts['force_trailing_slash']))
|
|
{
|
|
$lines[] = '';
|
|
$lines[] = ' ## Force trailing slash';
|
|
$lines[] = ' RewriteCond %{REQUEST_FILENAME} !-f';
|
|
$lines[] = ' RewriteCond %{REQUEST_URI} !(.*)/$';
|
|
$lines[] = ' RewriteRule ^(.*)$ /$1/ [R=301,L]';
|
|
}
|
|
|
|
$lines[] = '</IfModule>';
|
|
$lines[] = '';
|
|
}
|
|
|
|
// --- Custom rules ---
|
|
$custom = trim($opts['custom_rules'] ?? '');
|
|
|
|
if (!empty($custom))
|
|
{
|
|
$lines[] = '## Custom rules';
|
|
$lines[] = $custom;
|
|
$lines[] = '';
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Generate equivalent NginX configuration snippet.
|
|
*/
|
|
public function generateNginx(array $opts): string
|
|
{
|
|
$lines = [];
|
|
$lines[] = '## MokoWaaS Generated NginX Configuration';
|
|
$lines[] = '## Add these directives inside your server { } block';
|
|
$lines[] = '';
|
|
|
|
if (!empty($opts['disable_directory_listing']))
|
|
{
|
|
$lines[] = '# Disable directory listing';
|
|
$lines[] = 'autoindex off;';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['disable_server_signature']))
|
|
{
|
|
$lines[] = '# Hide server version';
|
|
$lines[] = 'server_tokens off;';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['block_sensitive_files']))
|
|
{
|
|
$lines[] = '# Block sensitive files';
|
|
$lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {';
|
|
$lines[] = ' deny all;';
|
|
$lines[] = '}';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['block_php_in_uploads']))
|
|
{
|
|
$lines[] = '# Block PHP in upload directories';
|
|
$lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {';
|
|
$lines[] = ' deny all;';
|
|
$lines[] = '}';
|
|
$lines[] = '';
|
|
}
|
|
|
|
// Headers
|
|
$hdrs = [];
|
|
|
|
if (!empty($opts['prevent_clickjacking']))
|
|
{
|
|
$hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;';
|
|
}
|
|
|
|
if (!empty($opts['prevent_mime_sniffing']))
|
|
{
|
|
$hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;';
|
|
}
|
|
|
|
if (!empty($opts['xss_protection']))
|
|
{
|
|
$hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;';
|
|
}
|
|
|
|
$referrer = $opts['referrer_policy'] ?? '';
|
|
|
|
if (!empty($referrer) && $referrer !== 'off')
|
|
{
|
|
$hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;';
|
|
}
|
|
|
|
if (!empty($opts['hsts_enabled']))
|
|
{
|
|
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
|
|
$hsts = 'max-age=' . $maxAge;
|
|
|
|
if (!empty($opts['hsts_subdomains']))
|
|
{
|
|
$hsts .= '; includeSubDomains';
|
|
}
|
|
|
|
$hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;';
|
|
}
|
|
|
|
if (!empty($hdrs))
|
|
{
|
|
$lines[] = '# Security headers';
|
|
$lines = array_merge($lines, $hdrs);
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['enable_gzip']))
|
|
{
|
|
$lines[] = '# GZip compression';
|
|
$lines[] = 'gzip on;';
|
|
$lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;';
|
|
$lines[] = 'gzip_min_length 256;';
|
|
$lines[] = '';
|
|
}
|
|
|
|
if (!empty($opts['enable_expires']))
|
|
{
|
|
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
|
|
$images = (int) ($opts['expires_images'] ?? 31536000);
|
|
|
|
$lines[] = '# Browser caching';
|
|
$lines[] = 'location ~* \.(css|js)$ {';
|
|
$lines[] = ' expires ' . round($cssJs / 86400) . 'd;';
|
|
$lines[] = '}';
|
|
$lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {';
|
|
$lines[] = ' expires ' . round($images / 86400) . 'd;';
|
|
$lines[] = '}';
|
|
$lines[] = '';
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
}
|