Metadata-Version: 2.4
Name: qhost
Version: 0.1.0
Summary: Self-hosted static-site host with a single trusted publisher.
Author: Jan Fasnacht
License: MIT
Project-URL: Homepage, https://github.com/janfasnacht/qhost
Project-URL: Source, https://github.com/janfasnacht/qhost
Project-URL: Issues, https://github.com/janfasnacht/qhost/issues
Project-URL: Changelog, https://github.com/janfasnacht/qhost/blob/main/CHANGELOG.md
Keywords: static-site,self-hosted,file-sharing,expiring-shares,preview-environments
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
Classifier: Topic :: System :: Archiving :: Mirroring
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pydantic>=2.6
Requires-Dist: httpx>=0.27
Requires-Dist: typer>=0.12
Provides-Extra: server
Requires-Dist: fastapi>=0.111; extra == "server"
Requires-Dist: uvicorn[standard]>=0.30; extra == "server"
Requires-Dist: bcrypt>=4.1; extra == "server"
Requires-Dist: python-multipart>=0.0.9; extra == "server"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: hypothesis>=6; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
Dynamic: license-file

# qhost

qhost publishes a directory of static files — a built site, a rendered
report, a coverage tree — at a URL that requires access and expires on a
schedule. The operator runs one container, points a DNS record at their
own reverse proxy, and manages shares from the command line. It is
access-controlled hosting for a single trusted publisher per instance;
it does not isolate mutually distrusting shares from one another.

## Self-host the server

qhostd ships as `ghcr.io/janfasnacht/qhost-server`. Drop a
`docker-compose.yml` and `.env` somewhere on your box and start it:

```yaml
# docker-compose.yml
services:
  qhostd:
    image: ghcr.io/janfasnacht/qhost-server:0.1.0
    restart: unless-stopped
    environment:
      QHOST_DOMAIN: ${QHOST_DOMAIN:?}
      QHOST_TRUSTED_PROXY: ${QHOST_TRUSTED_PROXY:?}
      QHOST_TOKEN: ${QHOST_TOKEN:-}
    volumes:
      - qhost_data:/data
    ports:
      - "127.0.0.1:8000:8000"

volumes:
  qhost_data:
```

```
# .env
QHOST_DOMAIN=share.example.com
QHOST_TRUSTED_PROXY=127.0.0.1   # whatever IP / CIDR your proxy connects from
QHOST_TOKEN=                    # leave blank to auto-generate on first start
```

```bash
docker compose up -d
docker compose exec qhostd cat /data/operator-token   # if you left it blank
```

qhostd is now on `http://127.0.0.1:8000` of the host, ready to be
proxied. The full env-var surface is in
[`deploy/.env.example`](deploy/.env.example) and the canonical compose
is in [`deploy/docker-compose.yml`](deploy/docker-compose.yml).

## Wire a reverse proxy

qhostd doesn't terminate TLS, doesn't strip incoming `X-Forwarded-For`,
and doesn't enforce a body-size cap before the request reaches Python.
A proxy in front of it has to do those three things, and qhostd has to
trust that proxy via `QHOST_TRUSTED_PROXY` (an IP or CIDR — see above).

Full ready-to-edit examples are in [`deploy/proxies/`](deploy/proxies/).
The interesting line in each:

**Caddy** ([`deploy/proxies/Caddyfile`](deploy/proxies/Caddyfile))

```caddyfile
{$QHOST_DOMAIN} {
    reverse_proxy 127.0.0.1:8000 {
        header_up X-Forwarded-For {http.request.remote.host}   # overwrite, not append
    }
    request_body { max_size 110MB }
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
```

**nginx** ([`deploy/proxies/nginx.conf`](deploy/proxies/nginx.conf))

```nginx
proxy_set_header X-Forwarded-For $remote_addr;   # overwrite, not append
client_max_body_size 110m;
```

**Cloudflare Tunnel** ([`deploy/proxies/cloudflared.md`](deploy/proxies/cloudflared.md))

```yaml
ingress:
  - hostname: share.example.com
    service: http://127.0.0.1:8000
```

`cloudflared` connects from loopback, so `QHOST_TRUSTED_PROXY=127.0.0.1`
is what you want.

## Install the CLI

```bash
pip install qhost
qhost login https://share.example.com
```

`qhost login` prompts for the operator token and writes it to
`~/.config/qhost/config.toml` (mode 0600).

## Publish

```bash
cd path/to/built/site && qhost publish
```

`qhost publish` from a directory containing `index.html` publishes that
directory as-is. From a Quarto project (`_quarto.yml` present) it runs
`quarto render` first. Otherwise it refuses; pass `--dir <path>` to
point at a built output or `--build <name>` to force a specific builder.

`qhost --help` covers the rest: `publish`, `ls`, `info`, `extend`, `rm`,
`open`, `doctor`. Default expiry is seven days. `--password` gates a
share behind HTTP Basic with a generated password; `--public` makes a
guessable URL with no secret; the default `link` mode treats the URL
itself as the credential — `/s/<slug>/` carries ~80 bits of entropy and
is not enumerable.

`qhost publish --sandbox` serves a share under
`Content-Security-Policy: sandbox allow-scripts`, the only
intra-instance isolation primitive. `qhost doctor` flags content that
probably wants it.

## Trust model

One operator token authorizes all publishing on an instance; every
visitor is untrusted. All shares serve from the same browser origin, so
a share's JavaScript can reach any other share on the same instance
with the visitor's ambient Basic-auth credentials. For mutually
distrusting publishers, run separate instances on separate origins.

---

Configuration: [`deploy/.env.example`](deploy/.env.example). Reverse
proxy examples: [`deploy/proxies/`](deploy/proxies/). Vulnerability
reports: [`SECURITY.md`](SECURITY.md). MIT.
