# .htaccess support

URL: https://scalws.com/docs/howto/htaccess/
Source: `docs/howto/htaccess.md` in the ScalWS repository

Turn on Apache `.htaccess` files for a site and know which directives ScalWS applies,
which it ignores and which make it deny access.

ScalWS reads `.htaccess` files itself, without Apache, for applications written for Apache
hosting (WordPress, Joomla, Drupal, single-page applications, redirects maintained by the
site owner). The decision and its limits are in
[ADR-0031](https://scalws.com/docs/adr/0031-htaccess/); the parser and evaluator are the `scalws-htaccess`
crate.

## Turn it on

Per site, with [`htaccess: true`](https://scalws.com/docs/reference/site-file/#htaccess) (in `scalws.yaml`:
[`applications[].htaccess`](/docs/reference/applications/#tenants-.applications-.htaccess)):

```yaml
# /etc/scalws/sites.d/example.com.yaml
domain: example.com
root: /srv/www/example.com
php: {fastcgi: "unix:/run/php/php8.3-fpm.sock"}
htaccess: true
```

or `scalwsctl site add example.com --root /srv/www/example.com --htaccess`. It is off by
default and needs the site's [`root`](https://scalws.com/docs/reference/site-file/#root).

* Files are read from `root` down to the directory of the request. The lookup starts at
  the site's `root`, not at a runtime's own root (for Laravel, `/srv/shop`, not
  `/srv/shop/public`).
* The rules run for every request of the site, whatever the runtime (static, PHP, proxy,
  Node.js, ...), after denied paths, rate limits and `https_redirect`, and before the route
  is chosen. A rewritten path selects the route again, and denied paths are checked again
  on it.
* Parsed files are cached per directory and re-checked at most every 2 seconds: edits take
  effect within 2 seconds, without a reload.
* `.htaccess` itself is never served (dotfiles answer `404`).

## What is supported

| Module | Directives |
|---|---|
| mod_rewrite | `RewriteEngine`, `RewriteBase`, `RewriteCond` (server variables, `%{HTTP:Header}`, `-f`, `-d`, `-s`, `=`, `<`, `>`, regular expressions, `[NC,OR]`), `RewriteRule` with `[L,END,R=code,NC,QSA,QSD,NE,F,G,S=n,C]`, `$n` and `%n` back-references |
| mod_alias | `Redirect`, `RedirectPermanent`, `RedirectTemp`, `RedirectMatch` |
| Access | `Require all granted\|denied`, `Require [not] ip ...`, `Order`/`Allow`/`Deny`, also inside `<Files>` and `<FilesMatch>` |
| Other | `ErrorDocument`, `DirectoryIndex`, `Header set\|append\|add\|unset`, `ExpiresActive`, `ExpiresByType`, `ExpiresDefault`, `<IfModule>`, `Options` (no effect: directories are never listed) |

Server variables available in `RewriteCond`: `REQUEST_URI`, `QUERY_STRING`,
`REQUEST_FILENAME`, `SCRIPT_FILENAME`, `DOCUMENT_ROOT`, `HTTP_HOST`, `SERVER_NAME`,
`SERVER_PORT`, `HTTPS`, `REQUEST_SCHEME`, `REQUEST_METHOD`, `REMOTE_ADDR`, `THE_REQUEST`,
`HTTP_USER_AGENT`, `HTTP_REFERER`, `HTTP_COOKIE`, `HTTP_ACCEPT`, `HTTP_AUTHORIZATION`,
`REDIRECT_STATUS`, and any request header as `%{HTTP:Name}`. Other variables are empty.

Apache's semantics are kept: rewrite rules apply from the deepest `.htaccess` that has
them, per-directory patterns match the path relative to that directory, an internal
rewrite restarts rule processing (at most 10 rounds) unless `[END]`, and other directives
are merged with deeper files winning. PHP still sees the original `REQUEST_URI`.

## What is ignored, and what denies

Ignored, with a log line naming the file:

* proxying with `[P]`, `RewriteMap`, `SetHandler`, `AddHandler`;
* `php_value`, `php_flag`, `php_admin_*` (set PHP options in the pool or in
  [`php.fpm.ini`](https://scalws.com/docs/reference/site-file/#php.fpm.ini) instead);
* a rule whose regular expression the linear-time engine cannot run (look-around,
  back-references inside a pattern): that rule is inactive;
* directives of modules ScalWS does not model.

**Denied with `403`**: access rules ScalWS cannot evaluate are never skipped, so nothing
meant to be protected is published by accident. That covers HTTP authentication
(`AuthType`, `Require valid-user|user|group`), `Require host|env`, access rules inside
`<RequireAll>`, `<RequireAny>`, `<Limit>`, `<LimitExcept>` or `<If>`, and access rules in
an `<IfModule>` for a module ScalWS does not model. The whole directory (or, for a
`<Files>` block, every name) then answers `403`. Protect such areas another way, for
example with a route's [`basic_auth`](https://scalws.com/docs/reference/site-file/#routes-.basic_auth), in the
application, or with [`security.deny_paths`](https://scalws.com/docs/reference/site-file/#security.deny_paths).

Limits: 64 KiB per file and 1000 directives (access rules beyond them deny), 10 rewrite
rounds, at most 16 `DirectoryIndex` names, and bounded regular-expression size and
scanning per request (a request over the scanning budget answers `500`).

## Examples

WordPress's own block works as written:

```apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
```

Redirects kept by the site owner:

```apache
Redirect 301 /old-page https://example.com/new-page
RedirectMatch 301 ^/blog/(\d+)/(.*)$ https://example.com/news/$2
```

Close a directory to everyone but one address, and set a header:

```apache
# /srv/www/example.com/internal/.htaccess
Require ip 192.0.2.10
```

```apache
Header set Strict-Transport-Security "max-age=31536000"
```

## Check it works

```sh
curl -sI -H 'Host: example.com' http://127.0.0.1/old-page         # 301
curl -sI -H 'Host: example.com' http://127.0.0.1/internal/        # 403 from other addresses
journalctl -u scalwsd | grep htaccess                               # ignored lines
sudo grep htaccess /var/lib/scalws-controller/logs/web-0.log       # under scalws-controller
```

## Common problems

* **Rules have no effect**: `htaccess: true` is missing, the file is outside `root`, or
  `scalws` cannot read it. Look for ignored lines in the log.
* **A directory answers 403 for everyone**: it (or a parent) has an access rule ScalWS
  cannot evaluate, such as `AuthType Basic` with `Require valid-user`.
* **A rewrite loops or answers 500**: more than 10 internal rewrite rounds; end the rule
  with `[END]` or add a `RewriteCond` that stops it.
* **A `[P]` proxy rule is ignored**: use a [route](https://scalws.com/docs/howto/reverse-proxy/) with a `proxy`
  runtime.
* **`php_value upload_max_filesize` is ignored**: set it in the PHP-FPM pool or in
  `php.fpm.ini`.

See also: [applications: .htaccess](https://scalws.com/docs/guide/applications/#htaccess),
[security: .htaccess](https://scalws.com/docs/guide/security/#htaccess).
