Metadata-Version: 2.5
Name: webmd
Version: 0.2.1
Summary: Serve a directory in the browser, rendering Markdown files as web pages.
Project-URL: Homepage, https://github.com/lets-qa/webmd
Project-URL: Repository, https://github.com/lets-qa/webmd
Project-URL: Issues, https://github.com/lets-qa/webmd/issues
Project-URL: Changelog, https://github.com/lets-qa/webmd/blob/main/CHANGELOG.md
Author-email: Randall Morse <randymorse@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cli,docs,https,markdown,preview,server
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: OS Independent
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Text Processing :: Markup :: Markdown
Requires-Python: >=3.9
Requires-Dist: cryptography>=42
Description-Content-Type: text/markdown

# webmd

[![CI](https://github.com/lets-qa/webmd/actions/workflows/ci.yml/badge.svg)](https://github.com/lets-qa/webmd/actions/workflows/ci.yml)

Serve any directory in your browser, with Markdown files rendered as proper web
pages. Point it at a folder of notes, docs, or a repo and browse it like a
website: folders become clickable listings, `.md` files render GitHub-style,
and everything else (images, PDFs, source files) is served as-is.

Pure Python, with a single dependency
([`cryptography`](https://cryptography.io/), used for HTTPS certificates).
Everything the browser needs is bundled, so it works fully offline.

```bash
cd ~/notes
webmd --open
```

## Features

- **GitHub-flavored Markdown rendering**: headings, tables, task lists,
  strikethrough, autolinks, and fenced code blocks with syntax highlighting.
- **Automatic light/dark mode** that follows your OS setting.
- **Directory browsing** with breadcrumbs, sorted folders → Markdown → other
  files. Dotfiles are hidden unless you pass `--all`.
- **Heading anchors**: every heading gets an `id`, so `page.md#some-section`
  links work, just like on GitHub.
- **Raw view**: append `?raw` to any Markdown URL (or click **raw** in the
  header) to see the source.
- **Static files served as-is**, so images and relative links inside your
  Markdown just work.
- **Works offline**: the renderer, sanitizer, styles and syntax highlighter
  ship inside the package. No CDN, no internet required.
- **Picks a free port**: if port 8000 is busy, it starts on a random free port
  and prints the URL.
- **Safe by default**:
  - binds to `127.0.0.1` (localhost only) unless you say otherwise
  - auto-enables **HTTPS** and **HTTP Basic Auth** whenever it's exposed
    beyond localhost, with a generated self-signed certificate and password,
    and slows down repeated wrong passwords
  - **treats Markdown as untrusted**: HTML inside `.md` files is sanitized,
    so scripts, event handlers and `javascript:` links never run
  - sends a strict Content-Security-Policy and other security headers
  - refuses paths that escape the served directory (`../`, symlinks out)
  - never serves or lists `.env` / `.env.*` files
- **No build step, no config**. Works on Python 3.9+ on Linux, macOS and
  Windows.

## Install

webmd is a command-line tool, so the recommended way is an isolated tool
install with [uv](https://docs.astral.sh/uv/) or [pipx](https://pipx.pypa.io/).

From PyPI:

```bash
uv tool install webmd
# or
pipx install webmd
# or, into the current environment
pip install webmd
```

Straight from GitHub:

```bash
uv tool install git+https://github.com/lets-qa/webmd.git
# or
pipx install git+https://github.com/lets-qa/webmd.git
```

From a local clone (add `--editable` to pick up code changes without
reinstalling):

```bash
git clone git@github.com:lets-qa/webmd.git
cd webmd
uv tool install .
```

If your shell says `webmd: command not found` after installing, the tool
directory isn't on your `PATH`. Run `uv tool update-shell` (or
`pipx ensurepath`) and open a new terminal.

Check it worked:

```bash
webmd --version
```

To upgrade or remove:

```bash
uv tool upgrade webmd     # pipx upgrade webmd
uv tool uninstall webmd   # pipx uninstall webmd
```

## Usage

```
webmd [DIR] [-p PORT] [-b BIND] [--all] [--open] [--no-auth] [--env-file PATH]
      [--tls | --no-tls] [--cert PATH --key PATH]
```

| Option | Default | Description |
|---|---|---|
| `DIR` | `.` | Directory to serve |
| `-p`, `--port` | `8000` | Port to listen on. See [Ports](#ports) |
| `-b`, `--bind` | `127.0.0.1` | Address to bind. Any non-loopback address turns on HTTPS and auth |
| `--all` | off | Show dotfiles in directory listings |
| `--open` | off | Open your browser when the server starts |
| `--no-auth` | off | Disable Basic Auth even when bound to a non-loopback address |
| `--env-file` | `~/.config/webmd/.env` | Where credentials are read from / generated to |
| `--tls` / `--no-tls` | on if non-loopback | Force HTTPS on (even on localhost) or off |
| `--cert`, `--key` | self-signed in `~/.config/webmd/` | Use your own certificate and private key (PEM) |
| `-V`, `--version` | | Print the version and exit |

### Examples

```bash
webmd                         # serve the current dir at http://127.0.0.1:8000
webmd ~/projects/docs         # serve a specific directory
webmd -p 9000 --open          # this exact port (error if it's busy), open the browser
webmd --all                   # include dotfiles in listings
python -m webmd               # same as `webmd`, no console script needed
```

### Ports

With no `-p`, webmd tries port **8000**. If that's taken, it tries up to 10
random ports in the dynamic range **49152–65535**, never repeating one. It
binds each candidate directly (no separate "is it free?" check that could race
with another program) and prints the URL it actually got:

```
  port 8000 is in use; trying a random port in 49152-65535
webmd serving /Users/you/notes
  -> http://127.0.0.1:53817/  (Ctrl-C to stop)
```

If you pass **`-p` explicitly, that port is used or webmd exits** with an error.
It never silently moves to a different port you didn't ask for, which matters
for bookmarks, firewall rules and reverse-proxy configs. `-p 0` asks the OS for
any free port.

Only "port already in use" triggers a fallback. Other errors, such as
permission denied, an address that isn't on this machine, or a hostname that
doesn't resolve, stop immediately with a message. The bind address (`-b`) is
always kept as-is.

### URLs

| URL | What you get |
|---|---|
| `/` or `/some/dir/` | Directory listing |
| `/notes/todo.md` | Rendered Markdown page |
| `/notes/todo.md?raw` | Raw Markdown source |
| `/notes/todo.md#next-steps` | Rendered page, scrolled to that heading |
| `/images/diagram.png` | The file itself |

## Sharing on your network (HTTPS + auth)

By default webmd only listens on localhost, so nothing else on your network
can reach it. To share it, bind to another address. **HTTPS and Basic Auth
then both turn on automatically**:

```bash
webmd -b 0.0.0.0              # reachable from your LAN at https://, login required
```

**First start:** webmd generates a self-signed certificate and a random
password into `~/.config/webmd/` (honours `$XDG_CONFIG_HOME`), and prints both:

```
webmd serving /Users/you/notes
  -> https://0.0.0.0:8000/  (Ctrl-C to stop)
  tls: generated self-signed certificate /Users/you/.config/webmd/cert.pem
       SHA-256 79:6F:B0:A8:...:67:51:53
  auth: generated credentials in /Users/you/.config/webmd/.env
        user=webmd password=<random-generated-password>
```

| File | Mode (Linux/macOS) | Contents |
|---|---|---|
| `~/.config/webmd/.env` | `0600` | Basic Auth username and password |
| `~/.config/webmd/cert.pem` | `0644` | Self-signed certificate |
| `~/.config/webmd/key.pem` | `0600` | Certificate private key |

On **Linux and macOS**, the password file and private key are created
owner-only (`0600`) from the start, and replaced atomically when rewritten.

On **Windows**, file modes don't exist. The files go in
`%USERPROFILE%\.config\webmd\` and inherit that folder's permissions. On a
standard install, your profile folder is readable only by you and
administrators. webmd doesn't change Windows ACLs itself, so if you point
`XDG_CONFIG_HOME` or `--env-file` somewhere shared, protect that folder
yourself.

**Every start after that** reuses the existing file. Edit it to choose your
own credentials; changes apply on the next restart:

```bash
# ~/.config/webmd/.env
WEBMD_USER=me
WEBMD_PASSWORD=correct-horse-battery-staple
```

**Credential precedence**, highest first:

1. `WEBMD_USER` / `WEBMD_PASSWORD` environment variables
2. The env file (`--env-file PATH`, default `~/.config/webmd/.env`)
3. Generated on first boot (user `webmd`, random password)

**Turning auth off** for a trusted network:

```bash
webmd -b 0.0.0.0 --no-auth    # prints a warning on start
```

**Wrong-password protection.** Each client gets 5 free wrong attempts. After
that, each further miss doubles a wait (1s, 2s, 4s, … up to 60s). During
the wait, webmd answers `429 Too Many Requests` with a `Retry-After` header and
doesn't check the password at all, so guessing faster gains nothing.
Across all clients combined, wrong guesses are also capped at about one every
two seconds once a burst of 60 is used up. There is no permanent lockout: waits
expire on their own, the right password works again once the wait is over, and
a successful login resets that client's count. Clients are identified by their
real network address, never by headers like `X-Forwarded-For`, which a client
could fake. Passwords are never logged.

### HTTPS and the self-signed certificate

The generated certificate covers `localhost`, `127.0.0.1`, `::1`, your
hostname (plus `<hostname>.local`), your LAN IP, and the `-b` address. It's
valid for 825 days and is **regenerated automatically** when it's within 30
days of expiry or no longer covers your current hostname or IP (for example
after you join a different network). Otherwise the same certificate is reused,
so its fingerprint stays stable.

Because it's self-signed, **browsers will warn "Your connection is not
private"** the first time on each device. That's expected:

1. Compare the SHA-256 fingerprint printed at startup with the one the browser
   shows (click the warning → view certificate).
2. If they match, proceed. If they don't, something is intercepting the
   connection, so don't continue.

Visiting the `http://` URL of an HTTPS server returns a short
"This server only speaks HTTPS" message.

webmd does **not** send `Strict-Transport-Security` (HSTS). With a self-signed
certificate, HSTS would make the browser refuse the "proceed anyway" option,
and it would stick to the hostname for a year, breaking any later plain-HTTP
use of `localhost`. If you put webmd behind a reverse proxy with a real
certificate, set HSTS there.

**No warnings, using your own certificate.** With
[mkcert](https://github.com/FiloSottile/mkcert) you can create a certificate
your own devices trust:

```bash
mkcert -install
mkcert localhost 192.168.1.20 my-laptop.local
webmd -b 0.0.0.0 --cert localhost+2.pem --key localhost+2-key.pem
```

Any PEM certificate and key work, including ones from Let's Encrypt.

**Other combinations:**

```bash
webmd --tls                   # HTTPS on localhost too
webmd -b 0.0.0.0 --no-tls     # plain HTTP on the LAN (warns: password not encrypted)
```

> **Note:** with `--no-tls`, Basic Auth credentials travel base64-encoded,
> not encrypted. Keep TLS on unless something else (a reverse proxy,
> Tailscale, cloudflared) already provides HTTPS in front of webmd.

## Security model

webmd assumes **the Markdown files may be untrusted**: a cloned repo, a
downloaded doc, or files someone else can write to.

**Markdown rendering.** Markdown is turned into HTML in the browser by
[marked](https://marked.js.org/), then **sanitized by
[DOMPurify](https://github.com/cure53/DOMPurify)** before anything reaches the
page. This removes:

- `<script>`, inline event handlers (`onerror`, `onload`, …), and `javascript:`,
  `vbscript:` and `data:text/html` links
- `<iframe>`, `<object>`, `<embed>`, `<form>`, `<style>`, `<base>`, `<meta>`,
  `<template>`, and `style="..."` attributes (no page-wide restyling or fake
  login overlays)
- MathML, and dangerous SVG content (scripts, event handlers, `<use>`,
  `<foreignObject>`, animation elements)

It keeps normal Markdown and safe HTML: tables, task lists, `<details>`,
`<kbd>`, `<sub>`/`<sup>`, `<mark>`, images, links, and plain inline SVG. Code
blocks are highlighted by [highlight.js](https://highlightjs.org/), and its
output goes through a second, stricter sanitizer pass that only allows
`<span class>`. If the sanitizer fails to load, the page shows an error
instead of unsanitized HTML.

> Sanitizing can't be turned off. There's no `--unsafe-html` flag, because
> nothing in webmd needs raw HTML to run. If something you rely on is being
> stripped, please open an issue.

**Security headers.** Every response gets:

| Header | Value | Why |
|---|---|---|
| `Content-Security-Policy` | rendered pages: `script-src 'self'`, Trusted Types, `default-src 'none'`, … | Even if a sanitizer bug let markup through, inline scripts, `eval`-style string execution and raw `innerHTML` writes are refused by the browser |
| `Content-Security-Policy` | files from your folder: `sandbox` | An `.html` or `.svg` file in the folder can't run script with webmd's origin or credentials. PDFs are exempt because Chrome won't show them sandboxed, and PDFs can't run script on the page anyway |
| `X-Content-Type-Options` | `nosniff` | The browser can't reinterpret a file as a different type |
| `Referrer-Policy` | `no-referrer` | Your file paths don't leak to external sites through links or images |
| `X-Frame-Options` + CSP `frame-ancestors` | `DENY` / `'none'` | webmd pages can't be embedded by other sites (clickjacking) |
| `Cross-Origin-Opener-Policy` | `same-origin` | Isolates the page from windows opened by other sites |

Images may still load from any URL (`img-src *`), because Markdown commonly
embeds remote badges and screenshots. Those requests reveal your IP to the
image host, but carry no referrer and can't run code.

**Behind a reverse proxy** (nginx, Caddy, Traefik, …):

- **Keep the headers.** Make sure the proxy passes webmd's headers through. If
  it adds its own `Content-Security-Policy`, browsers enforce *both* policies.
  Don't add a weaker one expecting it to replace webmd's.
- **Login slowdown counts the proxy, not the user.** The backoff is keyed on
  the network address webmd sees, which behind a proxy is the proxy's address.
  All users then share one backoff, so put rate limiting at the proxy instead.
  webmd ignores `X-Forwarded-For` on purpose, because a client could fake it.
- **Set HSTS at the proxy** if it terminates TLS with a real certificate.

**Remaining limits.** Basic Auth has no logout and no session expiry. Anyone
who can read `~/.config/webmd/.env` can log in. Remote images (see above) can
reveal that a page was viewed.

## How it works

webmd extends Python's built-in `http.server`. In HTTPS mode the TLS
handshake runs on each connection's own thread, so a slow or stalled client
can't block anyone else. When a request hits a Markdown file, the server
embeds the raw source in a small HTML page. The browser renders it with
marked, sanitizes it with DOMPurify, styles it with
[github-markdown-css](https://github.com/sindresorhus/github-markdown-css), and
highlights code with highlight.js.

All four libraries are **bundled in the package** (`webmd/static/vendor/`) and
served from webmd's own `/__webmd__/static/` path, so pages render with no
internet access at all. They are pinned to exact versions and verified
against npm's published SHA-512 hashes by
[`scripts/vendor_assets.py`](scripts/vendor_assets.py). See
[`src/webmd/static/vendor/README.md`](src/webmd/static/vendor/README.md) for
versions and licenses.

The only network access a page can make is loading images that your Markdown
links to on other sites.

## Development

```bash
git clone git@github.com:lets-qa/webmd.git
cd webmd
uv tool install --editable .              # `webmd` now runs your working copy
uv run --group dev playwright install chromium   # once: browser for the rendering tests
uv run --group dev pytest                 # run the test suite
uv run --group dev pytest --cov           # ...with coverage (fails under 90%)
uv run --group dev ruff check .           # lint
uv run --group dev ruff format .          # format (CI runs `ruff format --check .`)
uv build                                  # build sdist + wheel into dist/
uv run --no-project python scripts/check_dist.py dist   # check both contain the bundled assets
```

### Tests

| File | What it covers |
|---|---|
| `test_server.py` | HTTP behaviour: listings, rendering, `?raw`, bundled assets, blocked paths, security headers |
| `test_ports.py` | Default-port fallback, explicit `-p`, bind errors, platform differences |
| `test_auth.py` | Failed-login backoff, the global cap, cleanup, concurrency, `429`s |
| `test_tls.py` | Certificate generation, renewal, fingerprint, plain-HTTP-on-HTTPS-port |
| `test_cli_errors.py` | Startup errors (bad dir, `--cert`/`--key`, unloadable cert), credentials file handling |
| `test_permissions.py` | Owner-only files on POSIX, Windows behaviour, atomic replace |
| `test_render_browser.py` | **Playwright/Chromium:** Markdown rendering, ~25 XSS payloads, CSP and Trusted Types, offline |
| `test_e2e_https.py` | **Playwright/Chromium:** self-signed HTTPS and the generated password, end to end |

The browser tests run the real client-side code in headless Chromium, with
every request outside the webmd server blocked. The HTTPS tests don't switch
off certificate checking. A normal browser must reject webmd's self-signed
certificate. A browser told to trust *only that certificate's key* (pinned with
Chromium's `--ignore-certificate-errors-spki-list`) must accept it, and the
certificate it receives must match the SHA-256 fingerprint webmd printed. The
generated password must log in, and a wrong user, wrong password or no
password must not. Locally, browser tests are skipped if Chromium isn't
installed. CI sets `WEBMD_REQUIRE_BROWSER=1` so a missing browser fails the job
instead.

### Coverage

`pytest --cov` measures branch coverage of `src/webmd`, including the webmd
server processes the tests start. The settings in `pyproject.toml`
(`patch = ["subprocess"]`, `sigterm = true`) make coverage follow those
processes and save their data when the tests stop them. Coverage fails below
**90%**. In CI, each OS/Python job uploads its data, and a `coverage` job
combines all 18 and enforces the floor on the total. Windows-only and
POSIX-only code can only be covered across platforms, so that's the number
that counts. The combined report appears in the run summary, and an HTML
report is uploaded as the `coverage-html` artifact. Browser JavaScript
(`webmd.js`) isn't measured; the Playwright tests exercise it.

To update a bundled library, change its pin (URL and npm `integrity`) in
`scripts/vendor_assets.py` and run `python scripts/vendor_assets.py`. CI checks
that the committed files match the pins.

The version lives in `src/webmd/__init__.py`. User-facing changes go in
[`CHANGELOG.md`](CHANGELOG.md).

### CI

Every push to `main` and every pull request runs
[`.github/workflows/ci.yml`](.github/workflows/ci.yml):

- **lint:** `ruff check`, `ruff format --check`, and a check that the bundled
  assets match their pinned upstream versions
- **test:** pytest with coverage, including the headless-Chromium tests, on
  **Linux, macOS and Windows** with Python 3.9 through 3.14 (18 jobs)
- **coverage:** combines coverage from all 18 test jobs and fails below 90%
- **build:** builds the sdist and wheel, runs `twine check --strict`, and
  checks both contain every bundled asset
- **install:** installs the built wheel into a clean environment on each of
  the three OSes, starts it, and fetches a page and all of its assets
- **ci-passed:** a final gate that succeeds only if every job above succeeded.
  A failed, skipped or cancelled job fails it. The one exception is when the
  `reuse` job found that this exact code already passed (see below).

### Merge protection

`main` is protected by a repository ruleset, **"main: require CI"** (*Settings →
Rules → Rulesets*):

- Changes reach `main` only through a pull request; direct pushes are blocked.
- A PR can merge only when the **`ci-passed`** check is green. That covers
  lint, all 18 test jobs, combined coverage ≥ 90%, the build, and the install
  smoke tests. Branch protection requires just that one check, so adding or
  renaming matrix jobs doesn't need a settings change.
- PRs don't have to be up to date with `main` first, and no approving review
  is required.
- Force-pushes to `main` and deleting it are blocked.
- Repository admins can bypass the rule in an emergency. GitHub records each
  bypass.

PRs targeting other branches (for example, a PR stacked on a release branch)
still run CI, but merging them isn't gated. The gate applies when the work
reaches `main`.

### Avoiding repeat runs

A full CI run takes about 45 runner-minutes, two-thirds of it on Windows. CI
skips work it has already done, without ever skipping code it hasn't
tested.

**What counts as "already tested":** a full CI pass on code with the **same
git tree**, meaning every file and path is byte-identical, including tests,
workflows and `pyproject.toml`. Different commits can share a tree. A merge
commit, a squash or an empty commit that changes nothing still counts as
tested. Any edit to any file makes a new tree, and the suite runs.

**How it's recorded:** when every job passes, `ci-passed` uploads a small
artifact named `tested-tree-<tree>` for the tree **it actually checked out and
tested**. That matters for pull requests: a `pull_request` run tests a
temporary merge of the PR into its target branch, not the PR's head commit,
and that merge is exactly what lands on `main` if nothing else merges first.
Markers last 90 days. Markers from fork PRs are ignored, because a fork
controls its copy of the workflow.

**When CI skips:** on the first attempt of a push or PR run, the `reuse` job
looks for a marker for the commit's tree. If it finds one, every other job is
skipped, and `ci-passed` reports success with a link to the earlier run. In
practice:

- **Merging a green PR into `main`:** if `main` didn't move since the PR's last
  run, the merged code is the code that run tested, so the push to `main` is
  skipped. If `main` gained other commits in between, the merged code is new
  and gets tested.
- **A PR updated with a no-op push** (for example, an empty commit) is skipped.
- **Re-running a workflow** (*Re-run all jobs*) always runs the full suite.
  So do **manual runs** (*Actions → CI → Run workflow*). Use either to re-check
  on new runner images or after a dependency release.

**Releases:** pushing a `v*` tag runs [`release.yml`](.github/workflows/release.yml).
Its `verify` job checks for a marker for the tagged tree. If there is one, it
links that run and goes straight to build and publish. If not, it runs the
full CI suite first (the same jobs as a PR, called as a reusable workflow) and
publishes only if that passes. A release can never publish code that hasn't
passed the full suite at least once.

**When in doubt, it tests.** If the lookup fails (an API error, no marker, a
fork marker), the full suite runs. To see why a run was skipped, open its
`reuse` (CI) or `verify` (release) job, which links the run being reused.

### Releasing

Releases publish to PyPI from
[`.github/workflows/release.yml`](.github/workflows/release.yml) using
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so no API
token is stored in GitHub.

**One-time setup:**

1. On PyPI, go to *Your projects → Publishing* (or, before the first release,
   *Account → Publishing → Add a new pending publisher*) and add:
   - Owner: `lets-qa`, Repository: `webmd`
   - Workflow: `release.yml`, Environment: `pypi`
2. In GitHub, create an environment named `pypi` under *Settings →
   Environments*. Optionally add yourself as a required reviewer so each
   publish waits for approval.

**Each release:**

```bash
# bump __version__ in src/webmd/__init__.py, move the CHANGELOG's
# "Unreleased" notes under the new version, commit, merge to main, then:
git tag vX.Y.Z
git push origin vX.Y.Z
```

The workflow checks the tag matches `__version__`, makes sure this exact code
has passed the full CI suite (reusing an earlier pass if there is one; see
"Avoiding repeat runs"), builds, publishes to PyPI, and creates a GitHub
release with the built files attached.

### Homebrew

A Homebrew formula is prepared in
[`packaging/homebrew/`](packaging/homebrew/), to be published through a
dedicated tap after a release is on PyPI. See that directory's README.

## License

webmd itself is [MIT](LICENSE). The bundled frontend libraries keep their own
licenses: marked (MIT), DOMPurify (MPL-2.0 or Apache-2.0), highlight.js
(BSD-3-Clause), and github-markdown-css (MIT). Their license texts ship in
`src/webmd/static/vendor/`.
