patx/gitman

adding github pages like feature. rev 1

Commit e28d4c5 · patx · 2026-05-05T22:33:52-04:00

Changeset
e28d4c5f5dd3fe607181f8bacc75c36ca233c390
Parents
a3bc700937b9711b9aa1268657e40f87b0e6c4a0

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 66ed889..db9c77f 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 A small Bottle app that hosts public Git repositories with local user accounts.
 
-Features include public user profiles, repository browsing, commit/branch diffs with comments, README rendering, issues, pull requests, repository contributors, stars, and HTTP Git clone, fetch, and push.
+Features include public user profiles, repository browsing, commit/branch diffs with comments, README rendering, issues, pull requests, repository contributors, stars, Pages-style static sites, and HTTP Git clone, fetch, and push.
 
 ## Run locally
 
@@ -28,6 +28,8 @@ Then open `http://127.0.0.1:8080`, create an account, and create a repository.
 - `/<owner>/<repo>/pulls`: list, create, comment on, close, and merge pull requests.
 - `/<owner>/<repo>/settings`: owner-only repository settings for descriptions, contributors, and deletion.
 - `/git/<owner>/<repo>`: the Git HTTP remote used by `git clone`, `git fetch`, and `git push`.
+- `<username>.gitman.io`: static Pages site from `<username>/<username>.gitman.io`.
+- `<username>.gitman.io/<repo>`: static project docs from `<username>/<repo>/docs` when enabled in repository settings.
 
 Repository pages share a tab bar for Overview, Source, Commits, Issues, Pull requests, Star, Fork, and owner Settings. Repository pages that display code also show a ref picker next to the repository title. Use it to switch branches, tags, commits, or `HEAD`; its footer links to the full Tags and Branches pages.
 
@@ -108,6 +110,13 @@ Repository owners and contributors can comment, close, and merge pull requests.
 - Only the owner can edit repository settings, add or remove contributors, or delete the repository.
 - Deleting a repository permanently removes its database records and Git data from disk.
 
+### Pages sites
+
+- Create `<username>.gitman.io` under your account and push static files to its default branch root. Requests to `<username>.gitman.io` serve those files.
+- For other repositories, add static files under `docs/`, then enable Pages in repository Settings. The docs site is served at `<username>.gitman.io/<repo>/`.
+- Pages serves exact files, directory `index.html`, extensionless `.html` paths, and a repository `404.html` fallback when present.
+- To use a custom domain, add a root `CNAME` file to `<username>/<username>.gitman.io`, open that repository's Settings page, create the shown DNS TXT record, then click Verify DNS. Custom domains also serve enabled project docs paths.
+
 ## Git client use
 
 Clone and fetch are public:
@@ -138,6 +147,7 @@ git remote set-url origin http://<username>@127.0.0.1:8080/git/<owner>/<repo>
 - `GITMAN_MAX_RENDER_BYTES`: maximum README/file/diff preview size. Defaults to `262144`.
 - `GITMAN_MAX_GIT_RESPONSE_BYTES`: maximum buffered Git HTTP response size. Defaults to `268435456`.
 - `GITMAN_GIT_BINARY`: Git executable name or full path. Defaults to `git`.
+- `GITMAN_PAGES_DOMAIN`: wildcard Pages domain. Defaults to `gitman.io`.
 - `GITMAN_RATE_LIMIT_ENABLED`: set to `0` to disable in-memory login/signup/git auth throttling.
 - `GITMAN_RATE_LIMIT_MAX_FAILURES`: failed attempts before throttling. Defaults to `5`.
 - `GITMAN_RATE_LIMIT_WINDOW_SECONDS`: rate limit window. Defaults to `300`.
@@ -266,7 +276,9 @@ sudo nginx -t
 sudo systemctl reload nginx
 ```
 
-Use your DNS provider to point the hostname at the VPS. For HTTPS on a small VPS, I recommend Certbot with Nginx:
+Use your DNS provider to point the hostname at the VPS. For Pages, also point the wildcard hostname for `GITMAN_PAGES_DOMAIN`, such as `*.gitman.io`, at the VPS and include that wildcard in the Nginx `server_name` values. Verified custom domains must also point at the VPS.
+
+For HTTPS on a small VPS, I recommend Certbot with Nginx:
 
 ```sh
 sudo apt install -y certbot python3-certbot-nginx
diff --git a/app.py b/app.py
index db09125..8e3087e 100644
--- a/app.py
+++ b/app.py
@@ -20,6 +20,11 @@ from wsgiref.simple_server import WSGIServer
 
 import bleach
 import markdown
+try:
+    import dns.exception
+    import dns.resolver
+except ImportError:
+    dns = None
 from bottle import (
     Bottle,
     HTTPResponse,
@@ -62,12 +67,14 @@ MAX_FORM_BYTES = env_int("GITMAN_MAX_FORM_BYTES", 64 * 1024)
 MAX_RENDER_BYTES = env_int("GITMAN_MAX_RENDER_BYTES", 256 * 1024)
 MAX_GIT_RESPONSE_BYTES = env_int("GITMAN_MAX_GIT_RESPONSE_BYTES", 256 * 1024 * 1024)
 GIT_BINARY = os.environ.get("GITMAN_GIT_BINARY", "git")
+PAGES_DOMAIN = os.environ.get("GITMAN_PAGES_DOMAIN", "gitman.io").strip().lower().rstrip(".")
 DEFAULT_EXEC_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
 RATE_LIMIT_ENABLED = env_bool("GITMAN_RATE_LIMIT_ENABLED", True)
 RATE_LIMIT_MAX_FAILURES = env_int("GITMAN_RATE_LIMIT_MAX_FAILURES", 5, minimum=1)
 RATE_LIMIT_WINDOW_SECONDS = env_int("GITMAN_RATE_LIMIT_WINDOW_SECONDS", 300, minimum=1)
 RATE_LIMIT_COOLDOWN_SECONDS = env_int("GITMAN_RATE_LIMIT_COOLDOWN_SECONDS", 300, minimum=1)
 SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,62}$")
+HOSTNAME_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
 REV_RE = re.compile(r"^(null|[0-9a-fA-F]{1,40})$")
 REF_TYPE_BRANCH = "branch"
 REF_TYPE_TAG = "tag"
@@ -81,6 +88,8 @@ REF_QUERY_KEYS = {"ref", "ref_type", "ref_value"}
 REF_VALUE_SEPARATOR = "|"
 DEFAULT_BRANCH_CANDIDATES = ("main", "master")
 SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)\b[^>]*>.*?</\1>")
+PAGES_VERIFY_TXT_PREFIX = "_gitman-pages"
+PAGES_VERIFY_VALUE_PREFIX = "gitman-pages-verification="
 RESERVED_USERNAMES = {
     "dashboard",
     "favicon.ico",
@@ -286,6 +295,7 @@ def init_db():
                 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
                 name TEXT NOT NULL,
                 description TEXT NOT NULL DEFAULT '',
+                pages_docs_enabled INTEGER NOT NULL DEFAULT 0,
                 forked_from_repo_id INTEGER REFERENCES repositories(id) ON DELETE SET NULL,
                 forked_at TEXT,
                 forked_from_node TEXT NOT NULL DEFAULT '',
@@ -375,6 +385,19 @@ def init_db():
                 updated_at TEXT NOT NULL
             );
 
+            CREATE TABLE IF NOT EXISTS custom_domains (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                domain TEXT NOT NULL,
+                verification_token TEXT NOT NULL,
+                verified_at TEXT,
+                last_checked_at TEXT,
+                status TEXT NOT NULL DEFAULT '',
+                created_at TEXT NOT NULL,
+                updated_at TEXT NOT NULL,
+                UNIQUE(user_id, domain)
+            );
+
             CREATE INDEX IF NOT EXISTS idx_repositories_owner ON repositories(owner_id);
             CREATE INDEX IF NOT EXISTS idx_issues_repo_number ON issues(repo_id, number);
             CREATE INDEX IF NOT EXISTS idx_issues_repo_status ON issues(repo_id, status);
@@ -385,10 +408,13 @@ def init_db():
             CREATE INDEX IF NOT EXISTS idx_pull_requests_source ON pull_requests(source_repo_id);
             CREATE INDEX IF NOT EXISTS idx_pull_request_comments_pull_request ON pull_request_comments(pull_request_id);
             CREATE INDEX IF NOT EXISTS idx_commit_comments_commit ON commit_comments(repo_id, commit_node);
+            CREATE INDEX IF NOT EXISTS idx_custom_domains_domain ON custom_domains(domain);
+            CREATE INDEX IF NOT EXISTS idx_custom_domains_user ON custom_domains(user_id);
             """
         )
         ensure_user_profile_columns(conn)
         ensure_repository_collaboration_columns(conn)
+        ensure_repository_pages_columns(conn)
         ensure_pull_request_ref_columns(conn)
 
 
@@ -420,6 +446,12 @@ def ensure_repository_collaboration_columns(conn):
     conn.execute("CREATE INDEX IF NOT EXISTS idx_repositories_forked_from ON repositories(forked_from_repo_id)")
 
 
+def ensure_repository_pages_columns(conn):
+    columns = {row["name"] for row in conn.execute("PRAGMA table_info(repositories)")}
+    if "pages_docs_enabled" not in columns:
+        conn.execute("ALTER TABLE repositories ADD COLUMN pages_docs_enabled INTEGER NOT NULL DEFAULT 0")
+
+
 def ensure_pull_request_ref_columns(conn):
     columns = {row["name"] for row in conn.execute("PRAGMA table_info(pull_requests)")}
     ref_columns = {
@@ -613,6 +645,13 @@ def load_current_user():
     request.environ["gitman.user"] = get_user_by_id(user_id) if user_id else None
 
 
[email protected]("before_request")
+def dispatch_pages_host():
+    response_for_host = pages_response_for_request_host()
+    if response_for_host is not None:
+        raise response_for_host
+
+
 @app.hook("before_request")
 def enforce_browser_post_security():
     if request.method != "POST" or is_git_request_path():
@@ -624,6 +663,9 @@ def enforce_browser_post_security():
 
 @app.hook("after_request")
 def add_security_headers():
+    if request.environ.get("gitman.pages_response"):
+        response.headers.setdefault("X-Content-Type-Options", "nosniff")
+        return
     for key, value in SECURITY_HEADERS.items():
         response.headers.setdefault(key, value)
     if not is_git_request_path():
@@ -1574,6 +1616,400 @@ def render_repo_description(text):
     return render_markdown_links(text)
 
 
+def normalize_request_host(value):
+    host = (value or "").split(",", 1)[0].strip().lower()
+    if not host:
+        return ""
+    if host.startswith("["):
+        return host.rstrip(".")
+    if ":" in host:
+        hostname, port = host.rsplit(":", 1)
+        if port.isdigit():
+            host = hostname
+    return host.rstrip(".")
+
+
+def valid_hostname(hostname):
+    if not hostname or len(hostname) > 253:
+        return False
+    labels = hostname.rstrip(".").split(".")
+    return all(HOSTNAME_LABEL_RE.match(label) for label in labels)
+
+
+def normalize_custom_domain(value):
+    raw = (value or "").strip()
+    if not raw:
+        raise ValueError("CNAME must contain a domain name.")
+    if "://" in raw or "/" in raw or "\\" in raw or any(char.isspace() for char in raw):
+        raise ValueError("CNAME must contain only a domain name.")
+    domain = raw.lower().rstrip(".")
+    if not valid_hostname(domain):
+        raise ValueError("CNAME must contain a valid domain name.")
+    if PAGES_DOMAIN and (domain == PAGES_DOMAIN or domain.endswith(f".{PAGES_DOMAIN}")):
+        raise ValueError(f"Use the {PAGES_DOMAIN} Pages host directly instead of a CNAME.")
+    return domain
+
+
+def user_site_repo_name(username):
+    return f"{username}.{PAGES_DOMAIN}"
+
+
+def is_user_site_repo(repo):
+    return bool(repo and PAGES_DOMAIN and repo["name"] == user_site_repo_name(repo["owner_username"]))
+
+
+def pages_host_for_owner(username):
+    return f"{username}.{PAGES_DOMAIN}"
+
+
+def pages_url_for_repo(repo):
+    host = pages_host_for_owner(repo["owner_username"])
+    if is_user_site_repo(repo):
+        return f"https://{host}/"
+    return f"https://{host}/{repo['name']}/"
+
+
+def custom_domain_txt_name(domain):
+    return f"{PAGES_VERIFY_TXT_PREFIX}.{domain}"
+
+
+def custom_domain_txt_value(token):
+    return f"{PAGES_VERIFY_VALUE_PREFIX}{token}"
+
+
+def resolve_dns_txt(record_name):
+    if dns is None:
+        raise ValueError("DNS lookup support is unavailable. Install dnspython to verify custom domains.")
+    try:
+        answers = dns.resolver.resolve(record_name, "TXT", lifetime=5)
+    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
+        return []
+    except dns.exception.DNSException as exc:
+        raise ValueError(f"DNS lookup failed: {exc}") from exc
+
+    values = []
+    for answer in answers:
+        strings = getattr(answer, "strings", None)
+        if strings is not None:
+            values.append(b"".join(strings).decode("utf-8", "replace"))
+        else:
+            values.append(answer.to_text().strip('"'))
+    return values
+
+
+def get_custom_domain_for_user(user_id, domain):
+    with db_connect() as conn:
+        return conn.execute(
+            """
+            SELECT *
+            FROM custom_domains
+            WHERE user_id = ? AND domain = ?
+            """,
+            (user_id, domain),
+        ).fetchone()
+
+
+def ensure_custom_domain_for_user(user_id, domain):
+    domain = normalize_custom_domain(domain)
+    existing = get_custom_domain_for_user(user_id, domain)
+    if existing:
+        return existing
+
+    now = utcnow()
+    token = secrets.token_urlsafe(32)
+    with db_connect() as conn:
+        conn.execute(
+            """
+            INSERT INTO custom_domains (
+                user_id, domain, verification_token, created_at, updated_at
+            )
+            VALUES (?, ?, ?, ?, ?)
+            """,
+            (user_id, domain, token, now, now),
+        )
+    return get_custom_domain_for_user(user_id, domain)
+
+
+def custom_domains_for_domain(domain):
+    with db_connect() as conn:
+        return conn.execute(
+            """
+            SELECT custom_domains.*, users.username AS owner_username
+            FROM custom_domains
+            JOIN users ON users.id = custom_domains.user_id
+            WHERE custom_domains.domain = ?
+            ORDER BY custom_domains.verified_at DESC, custom_domains.id DESC
+            """,
+            (domain,),
+        ).fetchall()
+
+
+def update_custom_domain_check(custom_domain, verified, status):
+    now = utcnow()
+    verified_at = now if verified else None
+    with db_connect() as conn:
+        conn.execute(
+            """
+            UPDATE custom_domains
+            SET verified_at = ?, last_checked_at = ?, status = ?, updated_at = ?
+            WHERE id = ?
+            """,
+            (verified_at, now, status[:500], now, custom_domain["id"]),
+        )
+    return get_custom_domain_for_user(custom_domain["user_id"], custom_domain["domain"])
+
+
+def read_cname_domain_for_repo(repo):
+    if not repo:
+        return "", ""
+    path = repo_path(repo["owner_username"], repo["name"])
+    try:
+        revision = ref_revision(default_code_ref(path))
+        files = git_files(path, revision)
+    except (GitCommandError, OSError):
+        return "", ""
+
+    by_lower = {file_path.lower(): file_path for file_path in files}
+    cname_path = by_lower.get("cname")
+    if not cname_path:
+        return "", ""
+
+    try:
+        content = read_file_bytes(path, cname_path, revision=revision).decode("utf-8", "replace")
+    except GitCommandError:
+        return "", ""
+
+    raw_domain = ""
+    for line in content.splitlines():
+        if line.strip():
+            raw_domain = line.strip()
+            break
+    if not raw_domain:
+        return "", "CNAME is empty."
+    try:
+        return normalize_custom_domain(raw_domain), ""
+    except ValueError as exc:
+        return "", str(exc)
+
+
+def verify_custom_domain_for_repo(repo):
+    if not is_user_site_repo(repo):
+        raise ValueError("Custom domains can only be verified from the user Pages repository.")
+    domain, cname_error = read_cname_domain_for_repo(repo)
+    if cname_error:
+        raise ValueError(cname_error)
+    if not domain:
+        raise ValueError("Add a root CNAME file before verifying a custom domain.")
+
+    custom_domain = ensure_custom_domain_for_user(repo["owner_id"], domain)
+    txt_name = custom_domain_txt_name(domain)
+    expected = custom_domain_txt_value(custom_domain["verification_token"])
+    try:
+        values = resolve_dns_txt(txt_name)
+    except ValueError as exc:
+        update_custom_domain_check(custom_domain, False, str(exc))
+        raise
+
+    if expected not in values:
+        message = f'TXT verification record was not found at "{txt_name}".'
+        update_custom_domain_check(custom_domain, False, message)
+        raise ValueError(message)
+
+    return update_custom_domain_check(custom_domain, True, "Domain verified.")
+
+
+def pages_settings_context(repo):
+    docs_publishable = bool(repo and not is_user_site_repo(repo))
+    context = {
+        "domain": PAGES_DOMAIN,
+        "url": pages_url_for_repo(repo),
+        "docs_publishable": docs_publishable,
+        "docs_enabled": bool(repo["pages_docs_enabled"]) if docs_publishable else False,
+        "is_user_site_repo": is_user_site_repo(repo),
+        "cname_domain": "",
+        "cname_error": "",
+        "custom_domain": None,
+        "txt_name": "",
+        "txt_value": "",
+    }
+    if not context["is_user_site_repo"]:
+        return context
+
+    domain, cname_error = read_cname_domain_for_repo(repo)
+    context["cname_domain"] = domain
+    context["cname_error"] = cname_error
+    if domain and not cname_error:
+        custom_domain = ensure_custom_domain_for_user(repo["owner_id"], domain)
+        context["custom_domain"] = custom_domain
+        context["txt_name"] = custom_domain_txt_name(domain)
+        context["txt_value"] = custom_domain_txt_value(custom_domain["verification_token"])
+    return context
+
+
+def clean_pages_request_path(path_info):
+    raw = unquote(path_info or "/")
+    trailing_slash = raw.endswith("/") and raw != "/"
+    stripped = raw.strip("/")
+    if not stripped:
+        return "", trailing_slash
+    parts = stripped.split("/")
+    if any(part in {"", ".", "..", ".git"} for part in parts):
+        return None, trailing_slash
+    pure_parts = PurePosixPath(stripped).parts
+    if any(part in {"", ".", "..", ".git"} for part in pure_parts):
+        return None, trailing_slash
+    return "/".join(pure_parts), trailing_slash
+
+
+def join_pages_path(*parts):
+    return "/".join(part.strip("/") for part in parts if part)
+
+
+def pages_file_candidates(base_path, request_path, trailing_slash=False):
+    target = join_pages_path(base_path, request_path)
+    candidates = []
+    if target and not trailing_slash:
+        candidates.append(target)
+    index_path = join_pages_path(target, "index.html") if target else "index.html"
+    candidates.append(index_path)
+    if request_path and target and not trailing_slash and "." not in PurePosixPath(request_path).name:
+        candidates.append(f"{target}.html")
+
+    seen = set()
+    unique = []
+    for candidate in candidates:
+        if candidate and candidate not in seen:
+            seen.add(candidate)
+            unique.append(candidate)
+    return unique
+
+
+def pages_content_type(file_path):
+    content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
+    if (
+        content_type.startswith("text/")
+        or content_type in {"application/javascript", "application/json", "application/xml", "image/svg+xml"}
+    ):
+        content_type += "; charset=utf-8"
+    return content_type
+
+
+def pages_http_response(body, status=200, content_type="text/plain; charset=utf-8"):
+    request.environ["gitman.pages_response"] = True
+    if request.method == "HEAD":
+        body = b""
+    return HTTPResponse(body=body, status=status, content_type=content_type)
+
+
+def pages_not_found_response(repo=None, base_path="", revision=None, files=None):
+    if repo and revision and files:
+        not_found_path = join_pages_path(base_path, "404.html")
+        if not_found_path in files:
+            path = repo_path(repo["owner_username"], repo["name"])
+            content = read_file_bytes(path, not_found_path, revision=revision)
+            return pages_http_response(content, status=404, content_type=pages_content_type(not_found_path))
+    return pages_http_response("Not found.\n", status=404)
+
+
+def pages_response_for_repo(repo, base_path, request_path, trailing_slash=False):
+    path = repo_path(repo["owner_username"], repo["name"])
+    try:
+        revision = ref_revision(default_code_ref(path))
+        files = git_files(path, revision)
+    except (GitCommandError, OSError):
+        return pages_not_found_response()
+
+    for candidate in pages_file_candidates(base_path, request_path, trailing_slash=trailing_slash):
+        if candidate in files:
+            content = read_file_bytes(path, candidate, revision=revision)
+            return pages_http_response(content, content_type=pages_content_type(candidate))
+    return pages_not_found_response(repo, base_path=base_path, revision=revision, files=files)
+
+
+def pages_repo_for_request(owner_username, request_path):
+    first, separator, rest = request_path.partition("/")
+    if first:
+        project_repo = get_repo(owner_username, first.lower())
+        if (
+            project_repo
+            and project_repo["pages_docs_enabled"]
+            and not is_user_site_repo(project_repo)
+        ):
+            return project_repo, "docs", rest if separator else ""
+
+    site_repo = get_repo(owner_username, user_site_repo_name(owner_username))
+    if site_repo:
+        return site_repo, "", request_path
+    return None, "", request_path
+
+
+def pages_response_for_owner(owner_username):
+    if request.method not in {"GET", "HEAD"}:
+        request.environ["gitman.pages_response"] = True
+        return HTTPResponse(
+            "Method not allowed.\n",
+            status=405,
+            headers={"Allow": "GET, HEAD"},
+            content_type="text/plain; charset=utf-8",
+        )
+
+    owner_username = (owner_username or "").lower()
+    if not SLUG_RE.match(owner_username) or not get_user_by_username(owner_username):
+        return pages_not_found_response()
+
+    request_path, trailing_slash = clean_pages_request_path(request.environ.get("PATH_INFO", request.path) or "/")
+    if request_path is None:
+        return pages_not_found_response()
+
+    repo, base_path, repo_request_path = pages_repo_for_request(owner_username, request_path)
+    if not repo:
+        return pages_not_found_response()
+    return pages_response_for_repo(repo, base_path, repo_request_path, trailing_slash=trailing_slash)
+
+
+def pages_owner_from_host(host):
+    if not PAGES_DOMAIN or host == PAGES_DOMAIN:
+        return None, False
+    suffix = f".{PAGES_DOMAIN}"
+    if not host.endswith(suffix):
+        return None, False
+    return host[: -len(suffix)], True
+
+
+def custom_domain_site_for_host(host):
+    try:
+        domain = normalize_custom_domain(host)
+    except ValueError:
+        return None, False
+
+    rows = custom_domains_for_domain(domain)
+    if not rows:
+        return None, False
+
+    for row in rows:
+        if not row["verified_at"]:
+            continue
+        site_repo = get_repo(row["owner_username"], user_site_repo_name(row["owner_username"]))
+        cname_domain, cname_error = read_cname_domain_for_repo(site_repo)
+        if not cname_error and cname_domain == domain:
+            return {"owner_username": row["owner_username"], "domain": domain}, True
+    return None, True
+
+
+def pages_response_for_request_host():
+    host = normalize_request_host(request.get_header("Host", ""))
+    owner_username, is_pages_subdomain = pages_owner_from_host(host)
+    if is_pages_subdomain:
+        return pages_response_for_owner(owner_username)
+
+    site, registered_domain = custom_domain_site_for_host(host)
+    if site:
+        return pages_response_for_owner(site["owner_username"])
+    if registered_domain:
+        return pages_not_found_response()
+    return None
+
+
 def is_null_revision(value):
     return (value or "").strip().lower() in {NULL_REV, NULL_NODE}
 
@@ -3025,6 +3461,19 @@ def fork_repo(owner, repo_name):
         )
 
 
+def render_repo_settings_page(repo, path, contributor_username="", error=None, notice=None):
+    return render(
+        "repo_settings.tpl",
+        repo=repo,
+        contributors=list_repo_contributors(repo["id"]),
+        contributor_username=contributor_username,
+        pages_settings=pages_settings_context(repo),
+        error=error,
+        notice=notice,
+        **repo_page_context(repo, path),
+    )
+
+
 @app.route("/<owner>/<repo_name>/settings", method=["GET", "POST"])
 def repo_settings(owner, repo_name):
     user = require_login()
@@ -3036,26 +3485,13 @@ def repo_settings(owner, repo_name):
 
     path = repo_path(owner, repo_name)
     if request.method == "GET":
-        return render(
-            "repo_settings.tpl",
-            repo=repo,
-            contributors=list_repo_contributors(repo["id"]),
-            contributor_username="",
-            **repo_page_context(repo, path),
-        )
+        return render_repo_settings_page(repo, path)
 
     action = request.forms.get("action", "save")
     if action == "delete":
         confirmation = request.forms.get("confirm_name", "").strip()
         if confirmation != repo["name"]:
-            return render(
-                "repo_settings.tpl",
-                repo=repo,
-                contributors=list_repo_contributors(repo["id"]),
-                contributor_username="",
-                error=f'Type "{repo["name"]}" to confirm deletion.',
-                **repo_page_context(repo, path),
-            )
+            return render_repo_settings_page(repo, path, error=f'Type "{repo["name"]}" to confirm deletion.')
         delete_repository(repo, path)
         redirect(f"/{owner}")
 
@@ -3065,14 +3501,7 @@ def repo_settings(owner, repo_name):
             add_repo_contributor(repo, user, contributor_username)
             redirect(f"/{owner}/{repo_name}/settings")
         except ValueError as exc:
-            return render(
-                "repo_settings.tpl",
-                repo=repo,
-                contributors=list_repo_contributors(repo["id"]),
-                contributor_username=contributor_username,
-                error=str(exc),
-                **repo_page_context(repo, path),
-            )
+            return render_repo_settings_page(repo, path, contributor_username=contributor_username, error=str(exc))
 
     if action == "remove_contributor":
         try:
@@ -3082,6 +3511,24 @@ def repo_settings(owner, repo_name):
         remove_repo_contributor(repo, contributor_user_id)
         redirect(f"/{owner}/{repo_name}/settings")
 
+    if action == "update_pages":
+        pages_docs_enabled = int(not is_user_site_repo(repo) and request.forms.get("pages_docs_enabled") == "1")
+        with db_connect() as conn:
+            conn.execute(
+                "UPDATE repositories SET pages_docs_enabled = ?, updated_at = ? WHERE id = ?",
+                (pages_docs_enabled, utcnow(), repo["id"]),
+            )
+        updated_repo = get_repo(owner, repo_name)
+        return render_repo_settings_page(updated_repo, path, notice="Pages settings updated.")
+
+    if action == "verify_custom_domain":
+        try:
+            verify_custom_domain_for_repo(repo)
+            repo = get_repo(owner, repo_name)
+            return render_repo_settings_page(repo, path, notice="Custom domain verified.")
+        except ValueError as exc:
+            return render_repo_settings_page(repo, path, error=str(exc))
+
     description = request.forms.get("description", "").strip()[:500]
     with db_connect() as conn:
         conn.execute(
@@ -3090,14 +3537,7 @@ def repo_settings(owner, repo_name):
         )
     updated_repo = get_repo(owner, repo_name)
     sync_repo_git_config(updated_repo)
-    return render(
-        "repo_settings.tpl",
-        repo=updated_repo,
-        contributors=list_repo_contributors(updated_repo["id"]),
-        contributor_username="",
-        notice="Repository settings updated.",
-        **repo_page_context(updated_repo, path),
-    )
+    return render_repo_settings_page(updated_repo, path, notice="Repository settings updated.")
 
 
 @app.route("/<owner>/<repo_name>/src")
diff --git a/requirements.txt b/requirements.txt
index b522736..082f439 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 bottle>=0.13,<0.14
 bleach>=6.1,<7
 Markdown>=3.8,<4
+dnspython>=2.6,<3
 gunicorn
diff --git a/static/styles.css b/static/styles.css
index e0c7a33..04838c7 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -120,7 +120,7 @@ button, .button {
   gap: .2rem;
   position: absolute;
   right: 0;
-  top: calc(100% + .9rem);
+  top: calc(100% + .4rem);
   z-index: 25;
   width: 20rem;
   max-width: calc(100vw - 2rem);
diff --git a/templates/repo_settings.tpl b/templates/repo_settings.tpl
index 35f38a3..39c595b 100644
--- a/templates/repo_settings.tpl
+++ b/templates/repo_settings.tpl
@@ -23,6 +23,50 @@
   </form>
 </section>
 
+<section class="panel">
+  <h2>Pages</h2>
+  <p class="muted">Pages URL: <a href="{{pages_settings['url']}}">{{pages_settings["url"]}}</a></p>
+  % if pages_settings["docs_publishable"]:
+    <form method="post">
+      {{!csrf_field()}}
+      <input type="hidden" name="action" value="update_pages">
+      <label>
+        <input type="checkbox" name="pages_docs_enabled" value="1"{{" checked" if pages_settings["docs_enabled"] else ""}}>
+        Publish this repository's docs/ directory at {{pages_settings["url"]}}
+      </label>
+      <button class="button" type="submit">Save Pages settings</button>
+    </form>
+  % else:
+    <p>Push static site files to this repository root to publish the user Pages site.</p>
+  % end
+
+  % if pages_settings["is_user_site_repo"]:
+    <h3>Custom domain</h3>
+    % if pages_settings["cname_error"]:
+      <p class="alert">{{pages_settings["cname_error"]}}</p>
+    % elif pages_settings["cname_domain"]:
+      <p>Root CNAME: <strong>{{pages_settings["cname_domain"]}}</strong></p>
+      <p class="muted">Create this DNS TXT record before verifying:</p>
+      <p><code>{{pages_settings["txt_name"]}}</code></p>
+      <p><code>{{pages_settings["txt_value"]}}</code></p>
+      % custom_domain = pages_settings["custom_domain"]
+      % if custom_domain and custom_domain["verified_at"]:
+        <p class="notice">Verified {{custom_domain["verified_at"]}}.</p>
+      % end
+      % if custom_domain and custom_domain["status"]:
+        <p class="muted">{{custom_domain["status"]}}</p>
+      % end
+      <form method="post">
+        {{!csrf_field()}}
+        <input type="hidden" name="action" value="verify_custom_domain">
+        <button class="button" type="submit">Verify DNS</button>
+      </form>
+    % else:
+      <p class="muted">Add a root CNAME file to this repository to configure a custom domain.</p>
+    % end
+  % end
+</section>
+
 <section class="panel">
   <h2>Contributors</h2>
   % if contributors:
diff --git a/tests/test_app.py b/tests/test_app.py
index e1420f4..d8743ce 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -552,9 +552,9 @@ def test_init_db_creates_expected_tables_and_is_idempotent(isolated_app):
         repo_columns = {row["name"] for row in conn.execute("PRAGMA table_info(repositories)")}
         pr_columns = {row["name"] for row in conn.execute("PRAGMA table_info(pull_requests)")}
 
-    assert {"users", "repositories", "issues", "pull_requests", "repo_stars"}.issubset(tables)
+    assert {"users", "repositories", "issues", "pull_requests", "repo_stars", "custom_domains"}.issubset(tables)
     assert {"display_name", "bio", "website"}.issubset(user_columns)
-    assert {"forked_from_repo_id", "forked_at", "forked_from_node"}.issubset(repo_columns)
+    assert {"forked_from_repo_id", "forked_at", "forked_from_node", "pages_docs_enabled"}.issubset(repo_columns)
     assert {"target_ref_type", "target_ref_name", "source_ref_type", "source_ref_name"}.issubset(pr_columns)
 
 
@@ -761,6 +761,119 @@ def test_git_read_helpers_return_files_readme_commits_and_default_ref(isolated_a
     assert isolated_app.is_ancestor(path, isolated_app.NULL_REV, node)
 
 
+def test_pages_host_serves_user_site_and_enabled_project_docs(isolated_app):
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "alice.gitman.io", "")
+    site_path = isolated_app.repo_path("alice", "alice.gitman.io")
+    commit_file(site_path, "index.html", "<h1>Alice Site</h1>\n", message="site index", user="alice")
+    commit_file(site_path, "about.html", "<p>About Alice</p>\n", message="about page", user="alice")
+    commit_file(site_path, "404.html", "<h1>Missing page</h1>\n", message="not found page", user="alice")
+
+    isolated_app.create_repository(owner, "project", "")
+    project_path = isolated_app.repo_path("alice", "project")
+    commit_file(project_path, "docs/index.html", "<h1>Project Docs</h1>\n", message="docs index", user="alice")
+    commit_file(project_path, "docs/guide.html", "<h1>Guide</h1>\n", message="docs guide", user="alice")
+
+    client = WsgiClient(isolated_app.app)
+    response = client.get("/", headers={"Host": "alice.gitman.io"})
+    assert response.status_code == 200
+    assert "<h1>Alice Site</h1>" in response.text
+    assert response.header("Content-Type").startswith("text/html")
+    assert response.header("Content-Security-Policy") is None
+
+    response = client.get("/about", headers={"Host": "alice.gitman.io"})
+    assert response.status_code == 200
+    assert "About Alice" in response.text
+
+    response = client.get("/project/", headers={"Host": "alice.gitman.io"})
+    assert response.status_code == 404
+    assert "Missing page" in response.text
+
+    login_client(client, "alice")
+    settings_response = client.get("/alice/project/settings")
+    assert settings_response.status_code == 200
+    assert "Publish this repository" in settings_response.text
+
+    response = client.post("/alice/project/settings", {"action": "update_pages", "pages_docs_enabled": "1"})
+    assert response.status_code == 200
+    assert "Pages settings updated." in response.text
+
+    response = client.get("/project/", headers={"Host": "alice.gitman.io"})
+    assert response.status_code == 200
+    assert "Project Docs" in response.text
+
+    response = client.get("/project/guide", headers={"Host": "alice.gitman.io"})
+    assert response.status_code == 200
+    assert "Guide" in response.text
+
+    response = client.get("/.git/config", headers={"Host": "alice.gitman.io"})
+    assert response.status_code == 404
+
+
+def test_custom_pages_domain_requires_dns_txt_verification_and_current_cname(isolated_app, monkeypatch):
+    owner = create_user("alice")
+    attacker = create_user("bob")
+    isolated_app.create_repository(owner, "alice.gitman.io", "")
+    site_path = isolated_app.repo_path("alice", "alice.gitman.io")
+    commit_file(site_path, "index.html", "<h1>Alice Custom Site</h1>\n", message="site index", user="alice")
+    commit_file(site_path, "404.html", "<h1>Alice Missing</h1>\n", message="site 404", user="alice")
+    commit_file(site_path, "CNAME", "www.example.com\n", message="custom domain", user="alice")
+
+    alice_client = WsgiClient(isolated_app.app)
+    login_client(alice_client, "alice")
+    settings_response = alice_client.get("/alice/alice.gitman.io/settings")
+    assert settings_response.status_code == 200
+    assert "_gitman-pages.www.example.com" in settings_response.text
+
+    custom_domain = isolated_app.get_custom_domain_for_user(owner["id"], "www.example.com")
+    expected_txt = isolated_app.custom_domain_txt_value(custom_domain["verification_token"])
+    assert expected_txt in settings_response.text
+
+    response = alice_client.get("/", headers={"Host": "www.example.com"})
+    assert response.status_code == 404
+
+    monkeypatch.setattr(isolated_app, "resolve_dns_txt", lambda record_name: [])
+    response = alice_client.post("/alice/alice.gitman.io/settings", {"action": "verify_custom_domain"})
+    assert response.status_code == 200
+    assert "TXT verification record was not found" in response.text
+    assert isolated_app.get_custom_domain_for_user(owner["id"], "www.example.com")["verified_at"] is None
+
+    monkeypatch.setattr(isolated_app, "resolve_dns_txt", lambda record_name: [expected_txt])
+    response = alice_client.post("/alice/alice.gitman.io/settings", {"action": "verify_custom_domain"})
+    assert response.status_code == 200
+    assert "Custom domain verified." in response.text
+    assert isolated_app.get_custom_domain_for_user(owner["id"], "www.example.com")["verified_at"]
+
+    response = alice_client.get("/", headers={"Host": "www.example.com"})
+    assert response.status_code == 200
+    assert "Alice Custom Site" in response.text
+
+    isolated_app.create_repository(attacker, "bob.gitman.io", "")
+    attacker_path = isolated_app.repo_path("bob", "bob.gitman.io")
+    commit_file(attacker_path, "index.html", "<h1>Bob Site</h1>\n", message="site index", user="bob")
+    commit_file(attacker_path, "CNAME", "www.example.com\n", message="custom domain", user="bob")
+
+    bob_client = WsgiClient(isolated_app.app)
+    login_client(bob_client, "bob")
+    bob_settings = bob_client.get("/bob/bob.gitman.io/settings")
+    assert bob_settings.status_code == 200
+    assert "_gitman-pages.www.example.com" in bob_settings.text
+
+    response = bob_client.post("/bob/bob.gitman.io/settings", {"action": "verify_custom_domain"})
+    assert response.status_code == 200
+    assert "TXT verification record was not found" in response.text
+
+    response = bob_client.get("/", headers={"Host": "www.example.com"})
+    assert response.status_code == 200
+    assert "Alice Custom Site" in response.text
+    assert "Bob Site" not in response.text
+
+    commit_file(site_path, "CNAME", "other.example.com\n", message="change custom domain", user="alice")
+    response = alice_client.get("/", headers={"Host": "www.example.com"})
+    assert response.status_code == 404
+    assert "Alice Custom Site" not in response.text
+
+
 def test_first_pushed_master_branch_becomes_default_and_stale_head_falls_back(isolated_app):
     owner = create_user("alice")
     isolated_app.create_repository(owner, "demo", "")