Metadata-Version: 2.1
Name: txhttputil
Version: 1.4.16
Summary: Flying Ion utility classes for serving a static site with twisted.web with user permissions.
Author-email: Flying Ion Pty Ltd <contact@flyingion.com>
License: MIT
Project-URL: Homepage, https://flyingion.com
Project-URL: Repository, https://gitlab.com/flyingion/txhttputil
Project-URL: Issues, https://gitlab.com/flyingion/txhttputil/-/issues
Keywords: twisted,resource,file,download,flying-ion
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: pytz
Requires-Dist: txwebsocket>=1.0.1
Requires-Dist: filetype
Requires-Dist: pem
Requires-Dist: pytmpdir
Requires-Dist: treelib>=1.6.4
Requires-Dist: cryptography
Requires-Dist: pyOpenSSL
Provides-Extra: dev
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: ruff>=0.15; extra == "dev"
Requires-Dist: vulture>=2.14; extra == "dev"
Requires-Dist: basedpyright>=1.20; extra == "dev"

# TxHttpUtil

Flying Ion helpers for serving a site with `twisted.web`. Requires Python 3.9 or later.

## Site

`setupSite` listens, wraps the root resource, and configures the request class.

```python
from txhttputil.site.SiteUtil import setupSite

setupSite("Peek Office Site", rootResource, portNum=8002)
```

`setupSite` does all of the following:

* Sets `requestFactory` to `FileUploadRequest`.
* Sets `protocol` to `WebSocketUpgradeHTTPChannel`, so `/vortexws` can upgrade.
* Sets `sessionCookieNameSuffix` from the site name and listen port.
* Wraps the root in `FormBasedAuthSessionWrapper` when `enableLogin` is true (the default).
* Listens with TLS when `enableSsl` is true.
* Starts an HTTP listener that redirects to the TLS port when `redirectFromHttpPort` is set.

```python
setupSite(
    "Peek Office Site",
    rootResource,
    portNum=8002,
    enableLogin=False,
    enableSsl=True,
    sslBundleFilePath="/path/to/key-cert-cachain.pem",
    redirectFromHttpPort=8080,
)
```

Pass `enableProtocolNegotiation=False` when several TLS listeners share one OpenSSL context. That uses `SiteWithoutProtocolNegotiation`, which skips ALPN setup on each new connection.

Mutual TLS uses the same PEM helpers as a hand-built listener:

```python
setupSite(
    "Peek Platform Data Exchange",
    rootResource,
    portNum=8011,
    enableLogin=False,
    enableSsl=True,
    sslBundleFilePath="/path/to/server.pem",
    sslEnableMutualTLS=True,
    sslMutualTLSCertificateAuthorityBundleFilePath="/path/to/root-cas.pem",
    sslMutualTLSTrustedPeerCertificateBundleFilePath="/path/to/peers.pem",
    dhParamPemFilePath="/path/to/dhparam.pem",
)
```

## Session cookies

Browsers store cookies by host name. They do not store them by port. Two Twisted processes on `localhost` share one cookie jar when both use `TWISTED_SESSION` or `TWISTED_SECURE_SESSION`.

`FileUploadRequest.getSession` appends a suffix when the site sets `sessionCookieNameSuffix`. The suffix is `<serviceSlug>_<port>`. The slug is the service name in lower case. Each run of non-alphanumeric characters becomes `_`.

| Listener | Cookie name |
| --- | --- |
| HTTP | `TWISTED_SESSION_<serviceSlug>_<port>` |
| TLS | `TWISTED_SECURE_SESSION_<serviceSlug>_<port>` |

Examples: `TWISTED_SESSION_peek_office_site_8002`, `TWISTED_SECURE_SESSION_attuneops_8443`.

The port in the name is the Twisted listen port. A webpack dev server port such as `4202` is not used. The dev server proxies to the Twisted port.

A bare `TWISTED_SESSION` or `TWISTED_SECURE_SESSION` means the suffix was not set, or the process is still running code from before txhttputil 1.4.14.

### Build the site yourself

`setupSite` is optional. Set `requestFactory` and the suffix yourself. Set the suffix after `listenTCP` or `listenSSL` returns. Use `port.getHost().port` when the listen port is `0`. That is the port the process actually bound.

```python
from twisted.internet import reactor
from twisted.web import server

from txhttputil.site.FileUploadRequest import FileUploadRequest
from txhttputil.site.SessionCookieUtil import SessionCookieUtil
from txwebsocket.txws import WebSocketUpgradeHTTPChannel

site = server.Site(rootResource)
site.protocol = WebSocketUpgradeHTTPChannel
site.requestFactory = FileUploadRequest

port = yield reactor.listenSSL(
    httpsPort, site, contextFactory=sslContextFactory
)
site.sessionCookieNameSuffix = (
    SessionCookieUtil.buildSessionCookieNameSuffix(
        "AttuneOps", port.getHost().port
    )
)
```

On a TLS listener the cookie name is `TWISTED_SECURE_SESSION_attuneops_<boundPort>`.

### Check the cookie

1. Restart the Twisted process after installing txhttputil 1.4.14 or later.
2. Trigger a finished HTTP response that calls `request.getSession()`.
3. Read `Set-Cookie` on that response.
4. Confirm the name includes the service slug and the Twisted listen port.

The WebSocket `101` preamble does not copy `Set-Cookie` from the request. A missing cookie on `/vortexws` does not show whether the suffix is set. Use a normal HTTP response that calls `getSession`.

## Login

`setupSite(..., enableLogin=True)` wraps the root in `FormBasedAuthSessionWrapper`. Unauthenticated requests render `LoginElement`. A `POST` with `username` and `password` calls `AuthCredentials.check`. `AllowAllAuthCredentials` accepts any password.

This form login is not suitable for a public network.

`RedirectToLoginPageResource` is a site root for the same idea without that wrapper. Unauthorized paths redirect to `loginPath`. The login path renders `loginResource`. Authorized requests use `authorizedRoot`.

```python
from txhttputil.site.RedirectToLoginPageResource import (
    RedirectToLoginPageResource,
)

root = RedirectToLoginPageResource(
    b"/login",
    loginResource,
    appRoot,
    isAuthorized=request_is_logged_in,
)
```

`isAuthorized` returns true when that request may see `appRoot`. Pass `formLoginFactory` to handle a `POST` of `username` and `password` before the redirect.

## Resources

`BasicResource` is the resource-tree node. `putChild` registers a child. `deleteChild` removes one. Pass `allowedIpsList` to answer other client addresses with HTTP 403. Set `isGzipped = True` to gzip that resource.

`FileUnderlayResource` resolves a URL in this order:

1. A child resource.
2. A file under a directory added with `addFileSystemRoot`.
3. The single-page `index.html`, when the last path segment has no dot.
4. `NoResource`.

Paths that escape a filesystem root are rejected. `addRedirectionRule` maps one path to another. `enableSinglePageApplication` turns on the index fallback.

```python
from txhttputil.site.FileUnderlayResource import FileUnderlayResource
from txhttputil.site.RedirectionRule import RedirectionRule

root = FileUnderlayResource()
root.addFileSystemRoot("/srv/app/dist")
root.enableSinglePageApplication("index.html")
root.addRedirectionRule(RedirectionRule("/old", "/new"))
root.putChild(b"api", apiResource)
```

`StaticFileResource` serves one file. It sets a content type, cache headers, and writes the body in chunks.

`RedirectToHttpsResource` redirects the same path to `https` on another port. `setupSite` uses it when `redirectFromHttpPort` is set.

## Uploads

`FileUploadRequest` parses `multipart/form-data`. Each part is a `SpooledNamedTemporaryFile`. The part stays in memory until it passes 1 MB, or until `name` or `fileno` is used. It then rolls to a temporary file. `setupSite` installs this request class. A hand-built site must set `site.requestFactory = FileUploadRequest`.

A part larger than that limit can use more memory than 1 MB before the roll to disk.

## HTTP client

`HttpFileDownloader` GETs a URL with Twisted `Agent` and returns a spooled file.

```python
from txhttputil.downloader.HttpFileDownloader import HttpFileDownloader

tmpFile = yield HttpFileDownloader("https://example.com/file.bin").run()
```

`HttpResourceProxy` forwards the incoming request to another host and writes the response back. Set `useSsl` and the mutual TLS bundle paths to authenticate to that host.

```python
from txhttputil.downloader.HttpResourceProxy import HttpResourceProxy

proxy = HttpResourceProxy("10.1.1.8", 8011, useSsl=True)
root.putChild(b"upstream", proxy)
```

## TLS material

`parsePemBundleForServer` reads a server key and certificate chain. `parsePemBundleForClient` reads a client chain. `parseTrustRootFromBundle` reads CA certificates. `parsePemBundleForTrustedPeers` reads peer certificates. `parseDiffieHellmanParameter` reads DH parameters. `generateDiffieHellmanParameterBytes` writes a new DH file.

`buildCertificateOptionsForTwisted` turns a parsed server bundle into Twisted `CertificateOptions`. `buildSSLContextFactoryForMutualTLS` builds the client context used by `HttpResourceProxy`.

## Other helpers

`deferToThreadWrap` runs a blocking function on the reactor thread pool and logs failures. `maybeDeferredWrap` turns a synchronous return value into a `Deferred`. `vortexLogFailure` logs a `Failure` once.

`txhttputil.compat.cgiCompat` parses `multipart/form-data` and `Content-Type` headers. The stdlib `cgi` module is gone in Python 3.13. `FileUploadRequest` uses this module.

`filterModules` yields importable module names in a package directory. It skips `__init__` and names that end in `Test`.

## Tests

Trial tests live next to the modules they cover:

* `BasicResourceTest`: child tree, render, IP filter
* `FileUnderlayResourceTest`: underlay files, directory traversal, `HttpFileDownloader`
* `RedirectToLoginPageResourceTest`: login redirect and authorized root
* `SessionCookieUtilTest`: cookie suffix and cookie name

Run them from the repository root:

```bash
python -m twisted.trial txhttputil.site.BasicResourceTest txhttputil.site.FileUnderlayResourceTest txhttputil.site.RedirectToLoginPageResourceTest txhttputil.site.SessionCookieUtil_test
```

`python txhttputil_manual_test_server.py` listens on port 8000 as `txHttpUtil Test Siteport`.

## License

Copyright (c) 2016-2026 [Flying Ion Pty Ltd](https://flyingion.com). Released under the [MIT License](LICENSE).
