Initial commit: ifconfig.me clone on the kaya framework

This commit is contained in:
2026-09-04 19:57:31 +08:00
committed by woggioni
commit b8f4e3026b
21 changed files with 828 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Keep the build context lean: exclude the local venv, caches, tests,
# and anything not needed to build the pyfconfig wheel.
.git/
.gitignore
.env
.env.example
.venv/
__pycache__/
*.pyc
*.pyo
*.egg-info/
.mypy_cache/
.pytest_cache/
dist/
build/
tests/
Dockerfile
.dockerignore
opencode.json
docker-compose.yml
+7
View File
@@ -0,0 +1,7 @@
# App server
APP_HOST=0.0.0.0
APP_PORT=8000
# Name used in the HTML page title/heading and the curl examples
# (e.g. "ifconfig.me" when deployed under that domain).
SITE_NAME=pyfconfig
+38
View File
@@ -0,0 +1,38 @@
name: CI
on:
push:
tags:
- 'release/*'
jobs:
build_and_push_docker_image:
name: "Build and push docker image"
runs-on: hostinger
steps:
- name: Checkout sources
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea container registry
uses: docker/login-action@v3
with:
registry: gitea.woggioni.net
username: woggioni-opencode-agent
password: ${{ secrets.PUBLISHER_TOKEN }}
- name: Extract metadata for image tags
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.woggioni.net/woggioni-opencode-agent/pyfconfig
flavor: latest=false
tags: |
type=match,pattern=release/(.*),group=1
type=raw,value=latest
- name: Build and push docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
pull: true
tags: ${{ steps.meta.outputs.tags }}
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
.env
*.egg-info/
dist/
build/
.mypy_cache/
.pytest_cache/
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
# Multi-stage production build for pyfconfig on the kaya framework.
# Base: alpine:3.24 (python3 = 3.14). Deps come from the kaya Gitea registry
# (primary) with PyPI as fallback. granian ships musllinux wheels, so no
# compiler is strictly required; build-base + python3-dev are kept in the
# builder only as a safety net and are discarded in the runtime image.
#
# apk and pip both use BuildKit cache mounts (type=cache): the package
# caches persist in the builder's cache across builds instead of being
# re-downloaded, and never land in the image layers. Requires BuildKit
# (default for `docker build` / `docker buildx` on modern daemons).
# --- Builder ---------------------------------------------------------------
FROM alpine:3.24 AS builder
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 py3-pip build-base python3-dev
WORKDIR /build
COPY pyproject.toml README.md requirements.txt ./
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt .
# --- Runtime ---------------------------------------------------------------
FROM alpine:3.24
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 ca-certificates tzdata \
&& addgroup -S app && adduser -S -G app app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \
GRANIAN_INTERFACE=rsgi
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
CMD ["granian", "pyfconfig.app:app"]
+110
View File
@@ -0,0 +1,110 @@
# pyfconfig
A clone of [ifconfig.me](https://ifconfig.me/) built on the
[kaya](https://gitea.woggioni.net/woggioni/kaya) framework
(`kaya-core` + `kaya-rsgi`), served by [Granian](https://github.com/emmett-framework/granian)
over the RSGI protocol.
## Stack
- **kaya-core** — routing and HTTP request/response handling
- **kaya-rsgi** — Granian (RSGI) adapter
- **granian** — application server
- **httpx + pwo** — test client over kaya's ASGI transport
No database, sessions, or authentication — the app is stateless.
## Endpoints
| Method | Path | Response |
|---|---|---|
| GET | `/` | HTML page for browsers (`Accept: text/html`), plain-text IP otherwise |
| GET | `/ip` | Client IP address |
| GET | `/ua` | `User-Agent` header |
| GET | `/lang` | `Accept-Language` header |
| GET | `/encoding` | `Accept-Encoding` header |
| GET | `/mime` | `Accept` header |
| GET | `/charset` | `Accept-Charset` header |
| GET | `/forwarded` | `X-Forwarded-For` header |
| GET | `/all` | All connection fields as `key: value` lines (empty fields included; `remote_host` shown as `unavailable`) |
| GET | `/all.json` | Same fields as a JSON object, empty fields omitted |
| GET | `/api/health` | Liveness probe (`{"status":"ok"}`) |
The `/all` field order mirrors the reference site: `ip_addr`,
`remote_host`, `user_agent`, `port`, `language`, `referer`, `connection`,
`keep_alive`, `method`, `encoding`, `mime`, `charset`, `via`, `forwarded`.
Example:
```console
$ curl http://localhost:8080/
203.0.113.7
$ curl http://localhost:8080/all.json
{"ip_addr":"203.0.113.7","user_agent":"curl/8.21.0","port":"51342","method":"GET","mime":"*/*"}
```
## Running
### With Docker Compose (recommended)
```bash
docker compose up -d --build
# App published at http://localhost:8080
```
### On the host
```bash
cp .env.example .env # optional; all settings have defaults
python -m venv .venv && . .venv/bin/activate
pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \
--extra-index-url https://pypi.org/simple \
-e .
granian --interface rsgi --host 0.0.0.0 --port 8000 pyfconfig.app:app
```
### Configuration
Environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `APP_HOST` | `0.0.0.0` | Bind host (informational; granian is configured via `GRANIAN_*`) |
| `APP_PORT` | `8000` | Bind port (informational) |
| `SITE_NAME` | `pyfconfig` | Public name used in the HTML page title and the curl examples (set to your domain, e.g. `ifconfig.example.com`) |
## Tests
```bash
. .venv/bin/activate
python -m unittest discover -s tests -t .
```
The tests drive the app in-process through kaya's ASGI interface using
`httpx.ASGITransport`; no external services are needed.
## Project layout
```
src/pyfconfig/
├── app.py # KayaApp assembly; imports route modules
├── config.py # env-driven Settings dataclass
├── page.py # HTML landing page builder (kaya has no templating)
└── routes/
├── health.py # GET /api/health
├── root.py # GET / with content negotiation
└── connection.py # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json
tests/
└── test_routes.py # unittest + httpx.ASGITransport
```
Route modules import the module-level `app` from `pyfconfig.app` and
register handlers with `@app.GET(...)` decorators at import time —
`pyfconfig.app` imports them last to complete the wiring.
## Deployment
The `Dockerfile` is a multi-stage alpine build producing a minimal,
non-root runtime image; pushing a `release/*` tag triggers the Gitea
Actions workflow in `.gitea/workflows/build.yaml`, which builds and
publishes the image to `gitea.woggioni.net/woggioni-opencode-agent/pyfconfig`.
+11
View File
@@ -0,0 +1,11 @@
services:
pyfconfig:
build: .
image: pyfconfig:latest
restart: unless-stopped
environment:
# Public name shown in the HTML page and the curl examples; set to the
# deployment domain (e.g. "ifconfig.example.com").
SITE_NAME: ${SITE_NAME:-pyfconfig}
ports:
- "127.0.0.1:8080:8080"
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"references": {
"kaya": {
"path": "../kaya",
"description": "Use for Kaya project code and resources"
}
}
}
+31
View File
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "pyfconfig"
version = "0.1.0"
description = "A clone of https://ifconfig.me/ built on the kaya framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kaya-core",
"kaya-rsgi",
"granian>=2.0",
"httpx",
"pwo",
]
[project.optional-dependencies]
dev = [
"mypy",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = false
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
+47
View File
@@ -0,0 +1,47 @@
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
#
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
anyio==4.15.0
# via httpx
certifi==2026.7.22
# via
# httpcore
# httpx
click==8.5.0
# via granian
granian==2.8.2
# via
# kaya-rsgi
# pyfconfig (pyproject.toml)
h11==0.16.0
# via httpcore
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via pyfconfig (pyproject.toml)
idna==3.19
# via
# anyio
# httpx
kaya-core==0.0.1
# via
# kaya-rsgi
# pyfconfig (pyproject.toml)
kaya-rsgi==0.0.1
# via pyfconfig (pyproject.toml)
pwo==0.1.2
# via
# kaya-core
# kaya-rsgi
# pyfconfig (pyproject.toml)
typing-extensions==4.16.0
# via
# anyio
# kaya-core
# pwo
View File
+10
View File
@@ -0,0 +1,10 @@
"""Application entry point."""
from __future__ import annotations
from kaya.core import KayaApp
app = KayaApp()
# Register routes by importing modules. Order does not matter; each module
# pulls ``app`` from here and decorates its handlers at import time.
from .routes import connection, health, root # noqa: E402,F401
+39
View File
@@ -0,0 +1,39 @@
"""Environment-driven configuration for the pyfconfig application.
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
dataclass. No pydantic-settings, no settings module.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
def _env(name: str, default: Optional[str] = None) -> str:
value = os.environ.get(name)
if value is None or value == "":
if default is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return default
return value
@dataclass(frozen=True)
class Settings:
app_host: str
app_port: int
site_name: str
@staticmethod
def from_env() -> "Settings":
return Settings(
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8000")),
# Public name of the deployment, used in the HTML page title
# and in the command-line examples (e.g. "ifconfig.me").
site_name=_env("SITE_NAME", "pyfconfig"),
)
settings: Settings = Settings.from_env()
+113
View File
@@ -0,0 +1,113 @@
"""HTML landing page for browser visitors.
Kaya has no templating engine, so the page is assembled as a plain
string; every dynamic value is HTML-escaped.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from html import escape
from typing import Dict
_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>What Is My IP Address? - {site}</title>
<style>
body {{ font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 2rem auto; max-width: 52rem; padding: 0 1rem; color: #222; }}
h1 {{ font-size: 1.5rem; }}
h2 {{ font-size: 1.15rem; margin-top: 2rem; border-bottom: 1px solid #ddd;
padding-bottom: .3rem; }}
table {{ border-collapse: collapse; width: 100%; }}
td, th {{ border: 1px solid #ddd; padding: .4rem .6rem; text-align: left;
vertical-align: top; }}
td:first-child {{ font-weight: 600; white-space: nowrap; width: 12rem; }}
code, pre {{ background: #f5f5f5; }}
pre {{ padding: .8rem; overflow-x: auto; border: 1px solid #e2e2e2; }}
footer {{ margin-top: 3rem; color: #777; font-size: .85rem; }}
</style>
</head>
<body>
<h1>What Is My IP Address? - {site}</h1>
<h2>Your Connection</h2>
<table>
<tr><td>IP Address</td><td>{ip_addr}</td></tr>
<tr><td>User Agent</td><td>{user_agent}</td></tr>
<tr><td>Language</td><td>{language}</td></tr>
<tr><td>Referer</td><td>{referer}</td></tr>
<tr><td>Method</td><td>{method}</td></tr>
<tr><td>Encoding</td><td>{encoding}</td></tr>
<tr><td>MIME Type</td><td>{mime}</td></tr>
<tr><td>Charset</td><td>{charset}</td></tr>
<tr><td>X-Forwarded-For</td><td>{forwarded}</td></tr>
</table>
<h2>Command Line Interface</h2>
<pre>$ curl {site}
&#8658; {ip_addr}
$ curl {site}/ip
&#8658; {ip_addr}
$ curl {site}/ua
&#8658; {user_agent}
$ curl {site}/lang
&#8658; {language}
$ curl {site}/encoding
&#8658; {encoding}
$ curl {site}/mime
&#8658; {mime}
$ curl {site}/charset
&#8658; {charset}
$ curl {site}/forwarded
&#8658; {forwarded}
$ curl {site}/all
&#8658; ip_addr: {ip_addr}
remote_host: {remote_host}
user_agent: {user_agent}
port: {port}
language: {language}
referer: {referer}
connection: {connection}
keep_alive: {keep_alive}
method: {method}
encoding: {encoding}
mime: {mime}
charset: {charset}
via: {via}
forwarded: {forwarded}
$ curl {site}/all.json
&#8658; {all_json}</pre>
<footer>&copy; {year} {site}</footer>
</body>
</html>
"""
def render_page(data: Dict[str, str], site: str) -> str:
"""Render the landing page from the connection data mapping."""
escaped = {key: escape(value) for key, value in data.items()}
escaped["remote_host"] = escaped["remote_host"] or "unavailable"
all_json = json.dumps(
{key: value for key, value in data.items() if value},
separators=(",", ":"),
)
return _PAGE.format(
site=escape(site),
year=datetime.now(timezone.utc).year,
all_json=escape(all_json),
**escaped,
)
View File
View File
+105
View File
@@ -0,0 +1,105 @@
"""Connection introspection endpoints — the core of the ifconfig.me clone.
A single :func:`connection_data` helper collects every field from the
request context in the same order the reference site
(https://ifconfig.me/all) uses; the individual endpoints below are thin
views over that mapping.
"""
from __future__ import annotations
import json
from typing import Dict, Optional, Tuple
from kaya.core import HttpContext
from ..app import app
_PLAIN = {"Content-Type": "text/plain; charset=utf-8"}
_JSON = {"Content-Type": "application/json"}
def _header(ctx: HttpContext, name: str) -> str:
values = ctx.headers.get(name)
if not values:
return ""
return ", ".join(values)
def connection_data(ctx: HttpContext) -> Dict[str, str]:
"""Collect the connection fields, ordered like the reference site."""
client: Optional[Tuple[str, int]] = ctx.client
ip_addr = client[0] if client else ""
port = str(client[1]) if client else ""
return {
"ip_addr": ip_addr,
# Reverse DNS resolution is not performed; ``/all`` renders an
# empty value as "unavailable" and ``/all.json`` omits it.
"remote_host": "",
"user_agent": _header(ctx, "user-agent"),
"port": port,
"language": _header(ctx, "accept-language"),
"referer": _header(ctx, "referer"),
"connection": _header(ctx, "connection"),
"keep_alive": _header(ctx, "keep-alive"),
"method": ctx.method.value,
"encoding": _header(ctx, "accept-encoding"),
"mime": _header(ctx, "accept"),
"charset": _header(ctx, "accept-charset"),
"via": _header(ctx, "via"),
"forwarded": _header(ctx, "x-forwarded-for"),
}
async def _send_field(ctx: HttpContext, field: str) -> None:
await ctx.send_str(200, connection_data(ctx)[field] + "\n", _PLAIN)
@app.GET("/ip")
async def ip(ctx: HttpContext) -> None:
await _send_field(ctx, "ip_addr")
@app.GET("/ua")
async def user_agent(ctx: HttpContext) -> None:
await _send_field(ctx, "user_agent")
@app.GET("/lang")
async def language(ctx: HttpContext) -> None:
await _send_field(ctx, "language")
@app.GET("/encoding")
async def encoding(ctx: HttpContext) -> None:
await _send_field(ctx, "encoding")
@app.GET("/mime")
async def mime(ctx: HttpContext) -> None:
await _send_field(ctx, "mime")
@app.GET("/charset")
async def charset(ctx: HttpContext) -> None:
await _send_field(ctx, "charset")
@app.GET("/forwarded")
async def forwarded(ctx: HttpContext) -> None:
await _send_field(ctx, "forwarded")
@app.GET("/all")
async def all_text(ctx: HttpContext) -> None:
data = connection_data(ctx)
lines = [
f"{key}: {value if value else ('unavailable' if key == 'remote_host' else '')}"
for key, value in data.items()
]
await ctx.send_str(200, "\n".join(lines) + "\n", _PLAIN)
@app.GET("/all.json")
async def all_json(ctx: HttpContext) -> None:
data = {key: value for key, value in connection_data(ctx).items() if value}
await ctx.send_str(200, json.dumps(data, separators=(",", ":")) + "\n", _JSON)
+15
View File
@@ -0,0 +1,15 @@
"""Liveness probe."""
from __future__ import annotations
from kaya.core import HttpContext
from ..app import app
@app.GET("/api/health")
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(
200,
b'{"status":"ok"}',
{"content-type": ("application/json",)},
)
+27
View File
@@ -0,0 +1,27 @@
"""Landing page with content negotiation.
Browsers (``Accept: text/html``) get the HTML page; command-line clients
such as curl (``Accept: */*``) get the plain-text IP address, mirroring
the behaviour of https://ifconfig.me/.
"""
from __future__ import annotations
from kaya.core import HttpContext
from ..app import app
from ..config import settings
from ..page import render_page
from .connection import connection_data
_HTML = {"Content-Type": "text/html; charset=utf-8"}
_PLAIN = {"Content-Type": "text/plain; charset=utf-8"}
@app.GET("/")
async def index(ctx: HttpContext) -> None:
data = connection_data(ctx)
accept = ", ".join(ctx.headers.get("accept", ()))
if "text/html" in accept:
await ctx.send_str(200, render_page(data, settings.site_name), _HTML)
else:
await ctx.send_str(200, data["ip_addr"] + "\n", _PLAIN)
+6
View File
@@ -0,0 +1,6 @@
"""Test package init.
pyfconfig's settings all have safe defaults, so no environment overrides
are required before importing :mod:`pyfconfig.app`; this file exists so
``python -m unittest discover -s tests -t .`` treats tests as a package.
"""
+179
View File
@@ -0,0 +1,179 @@
"""Route tests using kaya's ASGI transport via httpx."""
from __future__ import annotations
import json
import unittest
from httpx import ASGITransport, AsyncClient
from pwo import async_test
from pyfconfig.app import app
# httpx's ASGITransport populates the scope with this client tuple.
CLIENT_IP = "127.0.0.1"
CLIENT_PORT = "123"
ALL_HEADERS = {
"User-Agent": "test-agent/1.0",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://example.com/page",
"Connection": "keep-alive",
"Keep-Alive": "timeout=5",
"Accept-Encoding": "gzip, deflate",
"Accept": "text/html,application/xhtml+xml",
"Accept-Charset": "utf-8",
"Via": "1.1 proxy",
"X-Forwarded-For": "203.0.113.7, 10.0.0.1",
}
class RoutesTest(unittest.TestCase):
def setUp(self) -> None:
self.transport = ASGITransport(app=app)
def client(self) -> AsyncClient:
return AsyncClient(transport=self.transport, base_url="http://127.0.0.1")
@async_test
async def test_health(self) -> None:
async with self.client() as client:
r = await client.get("/api/health")
self.assertEqual(200, r.status_code)
self.assertIn("ok", r.text)
@async_test
async def test_ip(self) -> None:
async with self.client() as client:
r = await client.get("/ip")
self.assertEqual(200, r.status_code)
self.assertEqual(CLIENT_IP, r.text.strip())
@async_test
async def test_ua(self) -> None:
async with self.client() as client:
r = await client.get("/ua", headers={"User-Agent": "test-agent/1.0"})
self.assertEqual("test-agent/1.0", r.text.strip())
@async_test
async def test_lang(self) -> None:
async with self.client() as client:
r = await client.get("/lang", headers={"Accept-Language": "en-US"})
self.assertEqual("en-US", r.text.strip())
@async_test
async def test_encoding(self) -> None:
async with self.client() as client:
r = await client.get("/encoding", headers={"Accept-Encoding": "gzip"})
self.assertEqual("gzip", r.text.strip())
@async_test
async def test_mime(self) -> None:
async with self.client() as client:
r = await client.get("/mime", headers={"Accept": "application/json"})
self.assertEqual("application/json", r.text.strip())
@async_test
async def test_charset(self) -> None:
async with self.client() as client:
r = await client.get("/charset", headers={"Accept-Charset": "utf-8"})
self.assertEqual("utf-8", r.text.strip())
@async_test
async def test_forwarded(self) -> None:
async with self.client() as client:
r = await client.get("/forwarded", headers={"X-Forwarded-For": "203.0.113.7"})
self.assertEqual("203.0.113.7", r.text.strip())
@async_test
async def test_forwarded_absent_is_empty(self) -> None:
async with self.client() as client:
r = await client.get("/forwarded")
self.assertEqual("", r.text.strip())
@async_test
async def test_all_text(self) -> None:
async with self.client() as client:
r = await client.get("/all", headers=ALL_HEADERS)
self.assertEqual(200, r.status_code)
lines = r.text.strip().splitlines()
keys = [line.split(":", 1)[0] for line in lines]
self.assertEqual(
[
"ip_addr", "remote_host", "user_agent", "port", "language",
"referer", "connection", "keep_alive", "method", "encoding",
"mime", "charset", "via", "forwarded",
],
keys,
)
values = dict(line.split(": ", 1) for line in lines)
self.assertEqual(CLIENT_IP, values["ip_addr"])
self.assertEqual("unavailable", values["remote_host"])
self.assertEqual("test-agent/1.0", values["user_agent"])
self.assertEqual(CLIENT_PORT, values["port"])
self.assertEqual("en-US,en;q=0.9", values["language"])
self.assertEqual("https://example.com/page", values["referer"])
self.assertEqual("keep-alive", values["connection"])
self.assertEqual("timeout=5", values["keep_alive"])
self.assertEqual("GET", values["method"])
self.assertEqual("gzip, deflate", values["encoding"])
self.assertEqual("text/html,application/xhtml+xml", values["mime"])
self.assertEqual("utf-8", values["charset"])
self.assertEqual("1.1 proxy", values["via"])
self.assertEqual("203.0.113.7, 10.0.0.1", values["forwarded"])
@async_test
async def test_all_json_omits_empty_fields(self) -> None:
async with self.client() as client:
r = await client.get("/all.json", headers=ALL_HEADERS)
self.assertEqual(200, r.status_code)
data = json.loads(r.text)
self.assertEqual(CLIENT_IP, data["ip_addr"])
self.assertEqual("test-agent/1.0", data["user_agent"])
self.assertEqual(CLIENT_PORT, data["port"])
self.assertEqual("GET", data["method"])
# Empty fields are omitted entirely.
data_minimal = json.loads(
(await client.get("/all.json")).text
)
self.assertNotIn("remote_host", data_minimal)
self.assertNotIn("language", data_minimal)
self.assertNotIn("forwarded", data_minimal)
self.assertEqual(CLIENT_IP, data_minimal["ip_addr"])
@async_test
async def test_root_plain_text_for_cli(self) -> None:
async with self.client() as client:
# httpx sends Accept: */* by default, like curl.
r = await client.get("/")
self.assertEqual(200, r.status_code)
self.assertIn("text/plain", r.headers["content-type"])
self.assertEqual(CLIENT_IP, r.text.strip())
@async_test
async def test_root_html_for_browsers(self) -> None:
async with self.client() as client:
r = await client.get("/", headers={"Accept": "text/html"})
self.assertEqual(200, r.status_code)
self.assertIn("text/html", r.headers["content-type"])
self.assertIn("What Is My IP Address?", r.text)
self.assertIn(CLIENT_IP, r.text)
self.assertIn("curl pyfconfig/all.json", r.text)
@async_test
async def test_root_html_escapes_user_input(self) -> None:
async with self.client() as client:
r = await client.get(
"/",
headers={
"Accept": "text/html",
"User-Agent": "<script>alert(1)</script>",
},
)
self.assertNotIn("<script>alert(1)</script>", r.text)
self.assertIn("&lt;script&gt;", r.text)
@async_test
async def test_unknown_path_is_404(self) -> None:
async with self.client() as client:
r = await client.get("/nope")
self.assertEqual(404, r.status_code)