Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 87 additions & 40 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,67 +7,114 @@
<style>
body{font-family:sans-serif;max-width:800px;margin:auto;padding:24px}
input{width:100%;padding:12px;box-sizing:border-box}
button{margin-top:10px;padding:12px 18px}
button,.buy{display:inline-block;margin-top:10px;padding:12px 18px}
.buy{background:#111;color:#fff;text-decoration:none;border-radius:4px}
.finding{border:1px solid #ccc;border-radius:8px;padding:12px;margin:12px 0}
.meta{font-size:.9em;opacity:.7}
pre{white-space:pre-wrap}
.meta{font-size:.9em;opacity:.7;overflow-wrap:anywhere}
#status{margin:16px 0}
</style>
</head>
<body>
<h1>Repository Audit</h1>

<input id="repo" placeholder="https://github.com/owner/repo.git">
<button onclick="runAudit()">Audit</button>
<button id="audit">Audit</button>

<div id="status"></div>
<div id="out"></div>

<script>
async function runAudit() {
const out = document.getElementById("out");
out.innerHTML = "<p>Running audit...</p>";
const repo = document.getElementById("repo");
const status = document.getElementById("status");
const out = document.getElementById("out");

const response = await fetch("/audit", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
repo_url: document.getElementById("repo").value
})
});
function add(tag, text, parent = out, className = "") {
const node = document.createElement(tag);
node.textContent = text;
if (className) node.className = className;
parent.appendChild(node);
return node;
}

const r = await response.json();
function clear() {
status.textContent = "";
out.replaceChildren();
}

if (!response.ok) {
out.innerHTML = `<pre>${r.error || "Audit failed"}</pre>`;
return;
}
function renderFinding(f) {
const card = document.createElement("div");
card.className = "finding";
add("strong", `${f.id} — ${f.surface}`, card);
add("p", f.claim, card);
add("div", `Evidence: ${f.evidence}`, card, "meta");
add("div", `Class: ${f.class}`, card, "meta");
out.appendChild(card);
}

function renderResult(r, paid = false) {
clear();
add("h2", r.target?.remote || "Repository");
add("p", `Commit: ${r.target?.commit || "unknown"}`, out, "meta");

let html = `
<h2>${r.target?.remote || "Repository"}</h2>
<p class="meta">Commit: ${r.target?.commit || "unknown"}</p>
<h3>${r.findings.length} findings</h3>
`;
const count = paid ? (r.findings?.length || 0) : (r.total_findings ?? r.findings?.length ?? 0);
add("h3", `${count} findings${paid ? "" : " total"}`);

for (const f of r.findings) {
html += `
<div class="finding">
<strong>${f.id} — ${f.surface}</strong>
<p>${f.claim}</p>
<div class="meta">
Evidence: ${f.evidence}<br>
Class: ${f.class}
</div>
</div>
`;
for (const finding of r.findings || []) renderFinding(finding);

if (paid && r.hmmm?.length) {
add("h3", "hmmm");
const list = document.createElement("ul");
for (const item of r.hmmm) add("li", item, list);
out.appendChild(list);
}

if (r.hmmm?.length) {
html += `<h3>hmmm</h3><ul>` +
r.hmmm.map(x => `<li>${x}</li>`).join("") +
`</ul>`;
if (!paid && r.locked) {
add("p", `Preview shows the first ${r.findings.length}. Full audit also includes remaining findings and ${r.hmmm_count || 0} unresolved constraint(s).`);
const buy = document.createElement("a");
buy.href = r.payment_link;
buy.className = "buy";
buy.textContent = "Unlock full audit — $19";
out.appendChild(buy);
}
}

out.innerHTML = html;
async function runAudit() {
clear();
status.textContent = "Running audit...";
try {
const response = await fetch("/audit", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({repo_url: repo.value})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Audit failed");
renderResult(result, false);
} catch (error) {
clear();
status.textContent = error.message;
}
}

async function loadPaid(sessionId) {
clear();
status.textContent = "Verifying payment and running full audit...";
try {
const response = await fetch(`/paid?session_id=${encodeURIComponent(sessionId)}`);
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Paid audit failed");
renderResult(result, true);
history.replaceState({}, "", "/");
} catch (error) {
clear();
status.textContent = error.message;
}
}

document.getElementById("audit").addEventListener("click", runAudit);

const sessionId = new URLSearchParams(location.search).get("session_id");
if (sessionId) loadPaid(sessionId);
</script>
</body>
</html>
125 changes: 107 additions & 18 deletions service.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,147 @@
import base64
import json
import os
import re
import subprocess
import tempfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, quote, urlsplit
from urllib.request import Request, urlopen

from pubskill_lib.audit import audit_path

REPO = re.compile(r"^https://github\.com/[^/]+/[^/]+(?:\.git)?$")
PAYMENT_LINK_ID = "plink_1UFLuMAyiOEDWiRnLUiYEx3Y"
PAYMENT_LINK_URL = "https://buy.stripe.com/14A6oG5Sk9MWg8z3UO5EY01"
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the hosted payment service from this repository

The Stripe Payment Link and paid server endpoint turn this distribution repository into a hosted SaaS, while the applicable work order explicitly lists “Do not host a SaaS” in HANDOFF.md:26-30. This expands the repository beyond its clone/CLI scope and should be removed unless the repository contract is separately updated by an authorized change.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.

PRICE_CENTS = 1900
FREE_FINDINGS = 3
Comment on lines +15 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the hosted payment service from this repository

The Stripe Payment Link and paid server endpoint turn this distribution repository into a hosted SaaS, while the applicable work order explicitly lists “Do not host a SaaS” in HANDOFF.md:26-30. This expands the repository beyond its clone/CLI scope and should be removed unless the repository contract is separately updated by an authorized change.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.



def run_audit(repo_url):
if not REPO.fullmatch(repo_url):
raise ValueError("public github.com repository required")

with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, "repo")
subprocess.run(
["git", "clone", "--depth=1", "--single-branch", repo_url, target],
check=True,
timeout=120,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return audit_path(target)


def stripe_session(session_id):
key = os.environ.get("STRIPE_SECRET_KEY")
if not key:
raise RuntimeError("payment verification is not configured")
Comment on lines +38 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable checkout when Stripe verification is unconfigured

When STRIPE_SECRET_KEY is absent, /audit still returns the live Payment Link and the UI allows a customer to pay, but the redirect reaches this exception and /paid returns 500 instead of delivering the audit. The checked Dockerfile, .env.example, and repository-wide configuration references do not supply or document this required variable, so a default deployment can accept real payments while being unable to verify any of them; validate the key at startup or suppress checkout until verification is configured.

Useful? React with 👍 / 👎.


auth = base64.b64encode(f"{key}:".encode()).decode()
request = Request(
f"https://api.stripe.com/v1/checkout/sessions/{quote(session_id, safe='')}",
headers={"Authorization": f"Basic {auth}"},
)
with urlopen(request, timeout=15) as response:
return json.load(response)


def paid_repo(session):
if session.get("payment_status") != "paid":
raise PermissionError("payment is not complete")
if session.get("payment_link") != PAYMENT_LINK_ID:
raise PermissionError("payment does not belong to this product")
if session.get("currency") != "usd" or session.get("amount_total") != PRICE_CENTS:
raise PermissionError("payment amount does not match this product")

for field in session.get("custom_fields") or []:
if field.get("key") == "githubrepo" and field.get("type") == "text":
repo_url = (field.get("text") or {}).get("value", "").strip()
if REPO.fullmatch(repo_url):
return repo_url
Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate and bind the repository before charging

If a buyer mistypes the Checkout custom field, uses a common but rejected form such as a trailing slash, or supplies a private/unavailable repository, Stripe can complete the $19 payment before this validation or the subsequent clone fails. The paid endpoint then returns only an error, with no correction or refund path, so a successfully charged customer receives no audit; create the Checkout Session from the already validated preview URL or provide a secure recovery flow.

Useful? React with 👍 / 👎.

Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate and bind the repository before charging

If a buyer makes a typo in the Payment Link's free-text githubrepo field, uses a commonly formatted URL this regex rejects, or supplies an unavailable/private repository, Stripe has already completed the charge before this validation or the subsequent clone fails. The paid endpoint then returns only an error and provides no correction or refund path, so bind the previously validated preview URL to a server-created Checkout Session or otherwise validate it before accepting payment.

Useful? React with 👍 / 👎.


raise ValueError("paid checkout is missing a valid GitHub repository URL")


class Handler(BaseHTTPRequestHandler):
def reply(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)

def do_GET(self):
def page(self):
data = Path("index.html").read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)

def do_GET(self):
parsed = urlsplit(self.path)
if parsed.path == "/":
return self.page()

if parsed.path == "/paid":
session_id = (parse_qs(parsed.query).get("session_id") or [""])[0]
if not session_id:
return self.reply(400, {"error": "missing checkout session"})
try:
session = stripe_session(session_id)
repo_url = paid_repo(session)
result = run_audit(repo_url)
result["paid"] = True
Comment on lines +97 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Consume the checkout session or persist its purchased result

Every request containing the same paid Checkout Session ID reaches run_audit again, and neither the session nor a result is recorded as consumed. Consequently, one $19 session can be shared or replayed indefinitely to obtain fresh full audits whenever the bound repository changes; it can also return a different commit than the preview on which the purchase was based. Persist the purchased snapshot/result or enforce one-time session redemption.

Useful? React with 👍 / 👎.

return self.reply(200, result)
except PermissionError as exc:
return self.reply(402, {"error": str(exc)})
except ValueError as exc:
return self.reply(400, {"error": str(exc)})
except Exception as exc:
print(f"paid audit failure: {exc!r}", flush=True)
return self.reply(500, {"error": "paid audit could not be verified or completed"})

return self.reply(404, {"error": "not found"})

def do_POST(self):
if self.path != "/audit":
if urlsplit(self.path).path != "/audit":
return self.reply(404, {"error": "not found"})

try:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(min(length, 8192)))
repo_url = body["repo_url"]

if not REPO.fullmatch(repo_url):
return self.reply(400, {"error": "public github.com repository required"})

with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, "repo")
subprocess.run(
["git", "clone", "--depth=1", repo_url, target],
check=True,
timeout=120,
)
result = audit_path(target)
if length <= 0 or length > 8192:
return self.reply(400, {"error": "invalid request"})
body = json.loads(self.rfile.read(length))
repo_url = str(body["repo_url"]).strip()
result = run_audit(repo_url)

self.reply(200, result)
findings = result.get("findings") or []
preview = {
"target": result.get("target"),
"surfaces": result.get("surfaces"),
"findings": findings[:FREE_FINDINGS],
"total_findings": len(findings),
"hmmm_count": len(result.get("hmmm") or []),
"locked": len(findings) > FREE_FINDINGS or bool(result.get("hmmm")),
"payment_link": PAYMENT_LINK_URL,
}
return self.reply(200, preview)

except (ValueError, KeyError, json.JSONDecodeError) as exc:
return self.reply(400, {"error": str(exc) or "invalid request"})
except subprocess.TimeoutExpired:
return self.reply(504, {"error": "repository audit timed out"})
except subprocess.CalledProcessError:
return self.reply(400, {"error": "repository could not be cloned"})
except Exception as exc:
self.reply(500, {"error": str(exc)})
print(f"audit failure: {exc!r}", flush=True)
return self.reply(500, {"error": "audit failed"})


ThreadingHTTPServer(
Expand Down