Files
MokoSuiteClient/source/packages/com_mokowaas/admin/src/Model/HtaccessModel.php
T
Jonathan Miller 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
chore: rename src/ to source/ per moko-platform standards (#188)
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>
2026-06-06 07:43:59 -05:00

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);
}
}