patx/gitman
custom domains for all repos
Commit cdfc519 · patx · 2026-05-22T03:25:11-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index de2c92e..d1f4a99 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ The web UI reads directly from Git for source browsing, raw file downloads, comm
Issues, comments, stars, contributors, forks, and pull request records are stored in SQLite. Forking creates a bare clone of the source repository. Pull requests compare a source ref against a target branch, and maintainers can close or merge them from the browser.
-Pages-style static hosting is driven by the Git repository contents. A user site repository named `<username>.<GITMAN_PAGES_DOMAIN>` is served from its repository root at `https://<username>.<GITMAN_PAGES_DOMAIN>/`; project Pages can be enabled in repository settings and are served from that repository's `docs/` directory. User sites can also advertise a custom domain with a root `CNAME` file and a DNS TXT verification record.
+Pages-style static hosting is driven by the Git repository contents. A user site repository named `<username>.<GITMAN_PAGES_DOMAIN>` is served from its repository root at `https://<username>.<GITMAN_PAGES_DOMAIN>/`; project Pages can be enabled in repository settings and are served from that repository's `docs/` directory. Any published Pages repository can advertise a custom domain with a DNS TXT verification record: user sites use a root `CNAME` file, and project sites use `docs/CNAME`.
## Configuration
diff --git a/app.py b/app.py
index dd4a240..2249d84 100644
--- a/app.py
+++ b/app.py
@@ -450,14 +450,14 @@ def init_db():
CREATE TABLE IF NOT EXISTS custom_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ repo_id INTEGER REFERENCES repositories(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)
+ updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS repo_metadata (
@@ -507,6 +507,7 @@ def init_db():
ensure_user_profile_columns(conn)
ensure_repository_collaboration_columns(conn)
ensure_repository_pages_columns(conn)
+ ensure_custom_domains_repo_scope(conn)
ensure_pull_request_ref_columns(conn)
ensure_repo_metadata_table(conn)
ensure_repo_imports_table(conn)
@@ -547,6 +548,81 @@ def ensure_repository_pages_columns(conn):
conn.execute("ALTER TABLE repositories ADD COLUMN pages_docs_enabled INTEGER NOT NULL DEFAULT 0")
+def backfill_legacy_custom_domain_repos(conn):
+ if not PAGES_DOMAIN:
+ return
+ conn.execute(
+ """
+ UPDATE custom_domains
+ SET repo_id = (
+ SELECT repositories.id
+ FROM repositories
+ JOIN users ON users.id = repositories.owner_id
+ WHERE repositories.owner_id = custom_domains.user_id
+ AND repositories.name = users.username || ?
+ LIMIT 1
+ )
+ WHERE repo_id IS NULL
+ """,
+ (f".{PAGES_DOMAIN}",),
+ )
+
+
+def ensure_custom_domains_repo_scope(conn):
+ columns = {row["name"] for row in conn.execute("PRAGMA table_info(custom_domains)")}
+ if "repo_id" not in columns:
+ conn.execute("ALTER TABLE custom_domains ADD COLUMN repo_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE")
+ backfill_legacy_custom_domain_repos(conn)
+ columns.add("repo_id")
+
+ row = conn.execute(
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'custom_domains'"
+ ).fetchone()
+ table_sql = row["sql"] if row else ""
+ if table_sql and re.search(r"UNIQUE\s*\(\s*user_id\s*,\s*domain\s*\)", table_sql, re.IGNORECASE):
+ conn.execute("ALTER TABLE custom_domains RENAME TO custom_domains_old")
+ conn.execute(
+ """
+ CREATE TABLE custom_domains (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ repo_id INTEGER REFERENCES repositories(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
+ )
+ """
+ )
+ conn.execute(
+ """
+ INSERT INTO custom_domains (
+ id, user_id, repo_id, domain, verification_token, verified_at,
+ last_checked_at, status, created_at, updated_at
+ )
+ SELECT id, user_id, repo_id, domain, verification_token, verified_at,
+ last_checked_at, status, created_at, updated_at
+ FROM custom_domains_old
+ """
+ )
+ conn.execute("DROP TABLE custom_domains_old")
+
+ backfill_legacy_custom_domain_repos(conn)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_custom_domains_domain ON custom_domains(domain)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_custom_domains_user ON custom_domains(user_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_custom_domains_repo ON custom_domains(repo_id)")
+ conn.execute(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_domains_repo_domain
+ ON custom_domains(repo_id, domain)
+ WHERE repo_id IS NOT NULL
+ """
+ )
+
+
def ensure_pull_request_ref_columns(conn):
columns = {row["name"] for row in conn.execute("PRAGMA table_info(pull_requests)")}
ref_columns = {
@@ -2524,30 +2600,70 @@ def get_custom_domain_for_user(user_id, domain):
SELECT *
FROM custom_domains
WHERE user_id = ? AND domain = ?
+ ORDER BY verified_at DESC, id DESC
""",
(user_id, domain),
).fetchone()
-def ensure_custom_domain_for_user(user_id, domain):
+def get_custom_domain_for_repo(repo_id, domain):
+ with db_connect() as conn:
+ return conn.execute(
+ """
+ SELECT *
+ FROM custom_domains
+ WHERE repo_id = ? AND domain = ?
+ """,
+ (repo_id, domain),
+ ).fetchone()
+
+
+def get_legacy_custom_domain_for_user(user_id, domain):
+ with db_connect() as conn:
+ return conn.execute(
+ """
+ SELECT *
+ FROM custom_domains
+ WHERE user_id = ? AND repo_id IS NULL AND domain = ?
+ ORDER BY id DESC
+ """,
+ (user_id, domain),
+ ).fetchone()
+
+
+def get_custom_domain_by_id(custom_domain_id):
+ with db_connect() as conn:
+ return conn.execute("SELECT * FROM custom_domains WHERE id = ?", (custom_domain_id,)).fetchone()
+
+
+def ensure_custom_domain_for_repo(repo, domain):
domain = normalize_custom_domain(domain)
- existing = get_custom_domain_for_user(user_id, domain)
+ existing = get_custom_domain_for_repo(repo["id"], domain)
if existing:
return existing
+ legacy = get_legacy_custom_domain_for_user(repo["owner_id"], domain) if is_user_site_repo(repo) else None
+ if legacy:
+ with db_connect() as conn:
+ conn.execute(
+ "UPDATE custom_domains SET repo_id = ?, updated_at = ? WHERE id = ?",
+ (repo["id"], utcnow(), legacy["id"]),
+ )
+ return get_custom_domain_for_repo(repo["id"], domain)
+
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
+ user_id, repo_id, domain, verification_token, created_at, updated_at
)
- VALUES (?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?)
""",
- (user_id, domain, token, now, now),
+ (repo["owner_id"], repo["id"], domain, token, now, now),
)
- return get_custom_domain_for_user(user_id, domain)
+ return get_custom_domain_for_repo(repo["id"], domain)
def custom_domains_for_domain(domain):
@@ -2576,12 +2692,25 @@ def update_custom_domain_check(custom_domain, verified, status):
""",
(verified_at, now, status[:500], now, custom_domain["id"]),
)
- return get_custom_domain_for_user(custom_domain["user_id"], custom_domain["domain"])
+ return get_custom_domain_by_id(custom_domain["id"])
+
+
+def pages_base_path_for_repo(repo):
+ return "" if is_user_site_repo(repo) else "docs"
+
+
+def custom_domain_cname_path_for_repo(repo):
+ return "CNAME" if is_user_site_repo(repo) else "docs/CNAME"
+
+
+def pages_repo_is_published(repo):
+ return bool(repo and (is_user_site_repo(repo) or repo["pages_docs_enabled"]))
def read_cname_domain_for_repo(repo):
if not repo:
return "", ""
+ expected_cname_path = custom_domain_cname_path_for_repo(repo)
path = repo_path(repo["owner_username"], repo["name"])
try:
revision = ref_revision(default_code_ref(path))
@@ -2590,7 +2719,7 @@ def read_cname_domain_for_repo(repo):
return "", ""
by_lower = {file_path.lower(): file_path for file_path in files}
- cname_path = by_lower.get("cname")
+ cname_path = by_lower.get(expected_cname_path.lower())
if not cname_path:
return "", ""
@@ -2605,7 +2734,7 @@ def read_cname_domain_for_repo(repo):
raw_domain = line.strip()
break
if not raw_domain:
- return "", "CNAME is empty."
+ return "", f"{expected_cname_path} is empty."
try:
return normalize_custom_domain(raw_domain), ""
except ValueError as exc:
@@ -2613,15 +2742,15 @@ def read_cname_domain_for_repo(repo):
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.")
+ if not pages_repo_is_published(repo):
+ raise ValueError("Publish Pages before verifying a custom domain.")
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.")
+ raise ValueError(f"Add {custom_domain_cname_path_for_repo(repo)} before verifying a custom domain.")
- custom_domain = ensure_custom_domain_for_user(repo["owner_id"], domain)
+ custom_domain = ensure_custom_domain_for_repo(repo, domain)
txt_name = custom_domain_txt_name(domain)
expected = custom_domain_txt_value(custom_domain["verification_token"])
try:
@@ -2646,20 +2775,20 @@ def pages_settings_context(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_path": custom_domain_cname_path_for_repo(repo),
+ "custom_domain_ready": pages_repo_is_published(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)
+ custom_domain = ensure_custom_domain_for_repo(repo, 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"])
@@ -2755,23 +2884,27 @@ def pages_repo_for_request(owner_username, request_path):
and project_repo["pages_docs_enabled"]
and not is_user_site_repo(project_repo)
):
- return project_repo, "docs", rest if separator else ""
+ return project_repo, pages_base_path_for_repo(project_repo), 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 site_repo, pages_base_path_for_repo(site_repo), request_path
return None, "", request_path
+def pages_method_not_allowed_response():
+ 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",
+ )
+
+
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",
- )
+ return pages_method_not_allowed_response()
owner_username = (owner_username or "").lower()
if not SLUG_RE.match(owner_username) or not get_user_by_username(owner_username):
@@ -2787,6 +2920,23 @@ def pages_response_for_owner(owner_username):
return pages_response_for_repo(repo, base_path, repo_request_path, trailing_slash=trailing_slash)
+def pages_response_for_custom_domain_repo(repo):
+ if request.method not in {"GET", "HEAD"}:
+ return pages_method_not_allowed_response()
+ if not pages_repo_is_published(repo):
+ 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()
+ return pages_response_for_repo(
+ repo,
+ pages_base_path_for_repo(repo),
+ request_path,
+ trailing_slash=trailing_slash,
+ )
+
+
def pages_owner_from_host(host):
if not PAGES_DOMAIN or host == PAGES_DOMAIN:
return None, False
@@ -2796,6 +2946,14 @@ def pages_owner_from_host(host):
return host[: -len(suffix)], True
+def repo_for_custom_domain_row(row):
+ if row["repo_id"]:
+ return get_repo_by_id(row["repo_id"])
+ if row["owner_username"] and PAGES_DOMAIN:
+ return get_repo(row["owner_username"], user_site_repo_name(row["owner_username"]))
+ return None
+
+
def custom_domain_site_for_host(host):
try:
domain = normalize_custom_domain(host)
@@ -2809,10 +2967,12 @@ def custom_domain_site_for_host(host):
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)
+ repo = repo_for_custom_domain_row(row)
+ if not pages_repo_is_published(repo):
+ continue
+ cname_domain, cname_error = read_cname_domain_for_repo(repo)
if not cname_error and cname_domain == domain:
- return {"owner_username": row["owner_username"], "domain": domain}, True
+ return {"repo": repo, "domain": domain}, True
return None, True
@@ -2824,7 +2984,7 @@ def pages_response_for_request_host():
site, registered_domain = custom_domain_site_for_host(host)
if site:
- return pages_response_for_owner(site["owner_username"])
+ return pages_response_for_custom_domain_repo(site["repo"])
if registered_domain:
return pages_not_found_response()
return None
diff --git a/templates/repo_settings.tpl b/templates/repo_settings.tpl
index b2cd9d3..68a3f40 100644
--- a/templates/repo_settings.tpl
+++ b/templates/repo_settings.tpl
@@ -67,34 +67,36 @@
<p class="muted"><strong>Pages URL:</strong> <a href="{{pages_settings['url']}}">{{pages_settings["url"]}}</a> <small class="muted">(from source: `root`)</small></p>
% end
- % if pages_settings["is_user_site_repo"]:
- % if pages_settings["cname_error"]:
- <p class="alert">{{pages_settings["cname_error"]}}</p>
- % elif pages_settings["cname_domain"]:
- % custom_domain = pages_settings["custom_domain"]
- <form class="panel-heading" method="post">
- {{!csrf_field()}}
- <input type="hidden" name="action" value="verify_custom_domain">
- <div>
- <p>
- <strong class="muted">Custom Domain:</strong>
- {{pages_settings["cname_domain"]}}
- % if custom_domain and custom_domain["status"]:
- <small class="muted">{{custom_domain["status"]}}</small>
- % end
- % if custom_domain and custom_domain["verified_at"]:
- <small class="notice">@{{custom_domain["verified_at"]}}.</small>
- % end
- </p>
- </div>
- <button class="button" type="submit">{{"Reverify DNS" if custom_domain and custom_domain["verified_at"] else "Verify DNS"}}</button>
- </form>
- <p class="muted">Create this DNS TXT record before verifying:</p>
- <pre>{{pages_settings["txt_name"]}}
+ % if pages_settings["cname_error"]:
+ <p class="alert">{{pages_settings["cname_error"]}}</p>
+ % elif pages_settings["cname_domain"]:
+ % custom_domain = pages_settings["custom_domain"]
+ <form class="panel-heading" method="post">
+ {{!csrf_field()}}
+ <input type="hidden" name="action" value="verify_custom_domain">
+ <div>
+ <p>
+ <strong class="muted">Custom Domain:</strong>
+ {{pages_settings["cname_domain"]}}
+ <small class="muted">(from source: `{{pages_settings["cname_path"]}}`)</small>
+ % if custom_domain and custom_domain["status"]:
+ <small class="muted">{{custom_domain["status"]}}</small>
+ % end
+ % if custom_domain and custom_domain["verified_at"]:
+ <small class="notice">@{{custom_domain["verified_at"]}}.</small>
+ % end
+ </p>
+ % if not pages_settings["custom_domain_ready"]:
+ <p class="muted">Publish Pages before verifying this domain.</p>
+ % end
+ </div>
+ <button class="button" type="submit">{{"Reverify DNS" if custom_domain and custom_domain["verified_at"] else "Verify DNS"}}</button>
+ </form>
+ <p class="muted">Create this DNS TXT record before verifying:</p>
+ <pre>{{pages_settings["txt_name"]}}
{{pages_settings["txt_value"]}}</pre>
- % else:
- <p class="muted"><strong>Custom Domain:</strong> Add a root CNAME file to this repository to configure a custom domain.</p>
- % end
+ % else:
+ <p class="muted"><strong>Custom Domain:</strong> Add {{pages_settings["cname_path"]}} to this repository to configure a custom domain.</p>
% end
</section>
diff --git a/tests/test_app.py b/tests/test_app.py
index 2a8ab64..0790770 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -777,6 +777,7 @@ def test_init_db_creates_expected_tables_and_is_idempotent(isolated_app):
user_columns = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
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)")}
+ custom_domain_columns = {row["name"] for row in conn.execute("PRAGMA table_info(custom_domains)")}
assert {
"users",
@@ -791,6 +792,7 @@ def test_init_db_creates_expected_tables_and_is_idempotent(isolated_app):
assert {"display_name", "bio", "website"}.issubset(user_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)
+ assert {"repo_id"}.issubset(custom_domain_columns)
def test_auth_rate_limit_failures_are_shared_between_workers(isolated_app, monkeypatch):
@@ -1671,6 +1673,7 @@ def test_custom_pages_domain_requires_dns_txt_verification_and_current_cname(iso
assert "Reverify DNS" not in settings_response.text
custom_domain = isolated_app.get_custom_domain_for_user(owner["id"], "www.example.com")
+ assert custom_domain["repo_id"] == isolated_app.get_repo("alice", "alice.gitman.io")["id"]
expected_txt = isolated_app.custom_domain_txt_value(custom_domain["verification_token"])
assert expected_txt in settings_response.text
@@ -1721,6 +1724,117 @@ def test_custom_pages_domain_requires_dns_txt_verification_and_current_cname(iso
assert "Alice Custom Site" not in response.text
+def test_project_custom_pages_domain_requires_publish_and_serves_docs_root(isolated_app, monkeypatch):
+ owner = create_user("alice")
+ isolated_app.create_repository(owner, "project", "")
+ project = isolated_app.get_repo("alice", "project")
+ project_path = isolated_app.repo_path("alice", "project")
+ commit_file(project_path, "index.html", "<h1>Root Should Not Serve</h1>\n", message="root index", user="alice")
+ commit_file(project_path, "secret.html", "<h1>Root Secret</h1>\n", message="root secret", user="alice")
+ commit_file(project_path, "CNAME", "root.example.com\n", message="root cname", user="alice")
+ commit_file(project_path, "docs/index.html", "<h1>Project Custom Docs</h1>\n", message="docs index", user="alice")
+ commit_file(project_path, "docs/guide.html", "<h1>Project Guide</h1>\n", message="docs guide", user="alice")
+ commit_file(project_path, "docs/404.html", "<h1>Project Missing</h1>\n", message="docs 404", user="alice")
+ commit_file(project_path, "docs/CNAME", "docs.example.com\n", message="docs cname", user="alice")
+
+ client = WsgiClient(isolated_app.app)
+ login_client(client, "alice")
+
+ settings_response = client.get("/alice/project/settings")
+ assert settings_response.status_code == 200
+ assert "docs/CNAME" in settings_response.text
+ assert "docs.example.com" in settings_response.text
+ assert "root.example.com" not in settings_response.text
+ assert "Publish Pages before verifying this domain." in settings_response.text
+
+ custom_domain = isolated_app.get_custom_domain_for_repo(project["id"], "docs.example.com")
+ expected_txt = isolated_app.custom_domain_txt_value(custom_domain["verification_token"])
+ assert expected_txt in settings_response.text
+
+ response = client.get("/", headers={"Host": "docs.example.com"})
+ assert response.status_code == 404
+
+ monkeypatch.setattr(isolated_app, "resolve_dns_txt", lambda record_name: [expected_txt])
+ response = client.post("/alice/project/settings", {"action": "verify_custom_domain"})
+ assert response.status_code == 200
+ assert "Publish Pages before verifying a custom domain." in response.text
+ assert isolated_app.get_custom_domain_for_repo(project["id"], "docs.example.com")["verified_at"] is None
+
+ 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.post("/alice/project/settings", {"action": "verify_custom_domain"})
+ assert response.status_code == 200
+ assert "Custom domain verified." in response.text
+ assert isolated_app.get_custom_domain_for_repo(project["id"], "docs.example.com")["verified_at"]
+
+ response = client.get("/", headers={"Host": "docs.example.com"})
+ assert response.status_code == 200
+ assert "Project Custom Docs" in response.text
+ assert "Root Should Not Serve" not in response.text
+
+ response = client.get("/guide", headers={"Host": "docs.example.com"})
+ assert response.status_code == 200
+ assert "Project Guide" in response.text
+
+ response = client.get("/secret", headers={"Host": "docs.example.com"})
+ assert response.status_code == 404
+ assert "Project Missing" in response.text
+ assert "Root Secret" not in response.text
+
+
+def test_project_custom_pages_domain_requires_current_docs_cname_and_repo_verification(isolated_app, monkeypatch):
+ owner = create_user("alice")
+ attacker = create_user("bob")
+ isolated_app.create_repository(owner, "project", "")
+ project = isolated_app.get_repo("alice", "project")
+ project_path = isolated_app.repo_path("alice", "project")
+ commit_file(project_path, "docs/index.html", "<h1>Alice Project</h1>\n", message="docs index", user="alice")
+ commit_file(project_path, "docs/CNAME", "docs.example.com\n", message="docs cname", user="alice")
+
+ client = WsgiClient(isolated_app.app)
+ login_client(client, "alice")
+ client.get("/alice/project/settings")
+ client.post("/alice/project/settings", {"action": "update_pages", "pages_docs_enabled": "1"})
+ alice_domain = isolated_app.get_custom_domain_for_repo(project["id"], "docs.example.com")
+ alice_txt = isolated_app.custom_domain_txt_value(alice_domain["verification_token"])
+ monkeypatch.setattr(isolated_app, "resolve_dns_txt", lambda record_name: [alice_txt])
+ response = client.post("/alice/project/settings", {"action": "verify_custom_domain"})
+ assert response.status_code == 200
+ assert "Custom domain verified." in response.text
+
+ response = client.get("/", headers={"Host": "docs.example.com"})
+ assert response.status_code == 200
+ assert "Alice Project" in response.text
+
+ commit_file(project_path, "docs/CNAME", "other.example.com\n", message="change docs cname", user="alice")
+ response = client.get("/", headers={"Host": "docs.example.com"})
+ assert response.status_code == 404
+ assert "Alice Project" not in response.text
+
+ isolated_app.create_repository(attacker, "project", "")
+ attacker_project = isolated_app.get_repo("bob", "project")
+ attacker_path = isolated_app.repo_path("bob", "project")
+ commit_file(attacker_path, "docs/index.html", "<h1>Bob Project</h1>\n", message="docs index", user="bob")
+ commit_file(attacker_path, "docs/CNAME", "docs.example.com\n", message="docs cname", user="bob")
+
+ bob_client = WsgiClient(isolated_app.app)
+ login_client(bob_client, "bob")
+ bob_client.get("/bob/project/settings")
+ bob_client.post("/bob/project/settings", {"action": "update_pages", "pages_docs_enabled": "1"})
+ bob_domain = isolated_app.get_custom_domain_for_repo(attacker_project["id"], "docs.example.com")
+ assert bob_domain["verification_token"] != alice_domain["verification_token"]
+
+ response = bob_client.post("/bob/project/settings", {"action": "verify_custom_domain"})
+ assert response.status_code == 200
+ assert "TXT verification record was not found" in response.text
+
+ response = client.get("/", headers={"Host": "docs.example.com"})
+ assert response.status_code == 404
+ assert "Bob Project" 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", "")