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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@ LNBITS_URL=https://ln.example.com
LNBITS_WALLET_ID=
LNBITS_ADMIN_KEY=
LNBITS_INVOICE_KEY=

# GitHub App (optional) — lets bounties post a status comment on the GitHub
# issue they fund and mark it paid on payout. Register a GitHub App, install it
# on the target repos, and supply the App ID + private key (PEM). Newlines in
# the key may be escaped as \n on a single line. If unset, the comment feature
# is silently skipped.
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
41 changes: 40 additions & 1 deletion src/app/api/bounties/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { createClient } from "@/lib/supabase/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { createBountySchema } from "@/lib/bounties";
import { createBountySchema, formatBountyPayout } from "@/lib/bounties";
import { parseGitHubIssueUrl } from "@/lib/github-links";
import { postIssueComment } from "@/lib/github-app";

// Post the "bounty posted" status comment on the funded GitHub issue.
// Returns the comment id (to store for later editing) or null. Best-effort.
async function postBountyIssueComment(bounty: {
id: string;
title: string;
payout_usd: number | string;
payment_coin: string | null;
github_issue_url: string;
}): Promise<number | null> {
const coords = parseGitHubIssueUrl(bounty.github_issue_url);
if (!coords) return null;
const appUrl = (process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net").replace(/\/$/, "");
const bountyUrl = `${appUrl}/bounties/${bounty.id}`;
const body =
`💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` +
`**${bounty.title}**\n\n` +
Comment on lines +22 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unescaped bounty.title allows Markdown injection in GitHub comments

bounty.title is user-supplied text (validated only for length) and is embedded directly inside a Markdown bold block. A title like **foo** [claim](https://evil.com) will break out of the formatting and could produce a misleading or spoofed-looking comment on the funded issue. Wrapping the title in a code span or escaping Markdown special characters prevents this.

Suggested change
const body =
`💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` +
`**${bounty.title}**\n\n` +
const escapedTitle = bounty.title.replace(/[`*_[\]()\\]/g, "\\$&");
const body =
`💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` +
`**${escapedTitle}**\n\n` +

Fix in Codex Fix in Claude Code

`[Claim this bounty →](${bountyUrl})\n\n` +
`<sub>Posted automatically by ugig.net.</sub>`;
return postIssueComment(coords.owner, coords.repo, coords.number, body);
}

const BOUNTY_STATUSES = ["open", "paused", "closed"] as const;
type BountyStatus = (typeof BOUNTY_STATUSES)[number];
Expand Down Expand Up @@ -115,6 +138,7 @@ export async function POST(request: NextRequest) {
payment_coin: parsed.data.payment_coin || null,
max_submissions: parsed.data.max_submissions ?? null,
closes_at: parsed.data.closes_at || null,
github_issue_url: parsed.data.github_issue_url ?? null,
questions,
})
.select()
Expand All @@ -124,6 +148,21 @@ export async function POST(request: NextRequest) {
console.error("[POST /api/bounties] Supabase error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}

// Best-effort: if the bounty funds a GitHub issue and the ugig App is
// installed on that repo, post a status comment and remember its id so we
// can edit it to "paid" later. Never blocks bounty creation.
if (data?.github_issue_url) {
const commentId = await postBountyIssueComment(data);
if (commentId) {
await (supabase as any)
.from("bounties")
.update({ github_comment_id: commentId })
.eq("id", data.id);
data.github_comment_id = commentId;
}
}

return NextResponse.json({ data }, { status: 201 });
} catch (err) {
console.error("[POST /api/bounties] Unexpected error:", err);
Expand Down
18 changes: 17 additions & 1 deletion src/app/api/payments/coinpayportal/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createServiceClient } from "@/lib/supabase/service";
import { verifyWebhookSignature, type CoinPayWebhookPayload } from "@/lib/coinpayportal";
import { LIFETIME_THRESHOLD_USD } from "@/lib/funding";
import { getUserDid, onPaymentReceived, onPaymentSent } from "@/lib/reputation-hooks";
import { parseGitHubIssueUrl } from "@/lib/github-links";
import { updateIssueComment } from "@/lib/github-app";

// POST /api/payments/coinpayportal/webhook - Handle CoinPayPortal webhooks
export async function POST(request: NextRequest) {
Expand Down Expand Up @@ -438,10 +440,24 @@ async function handleBountyPaymentConfirmed(

const { data: bounty } = await (supabase as any)
.from("bounties")
.select("id, title, creator_id, payout_usd")
.select("id, title, creator_id, payout_usd, github_issue_url, github_comment_id")
.eq("id", submission.bounty_id)
.single();

// Best-effort: flip the GitHub issue status comment to "paid". Only runs on
// the first paid transition (the early-return above handles forwarded events),
// so the comment is edited once.
if (bounty?.github_issue_url && bounty.github_comment_id) {
const coords = parseGitHubIssueUrl(bounty.github_issue_url);
if (coords) {
const appUrl = (process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net").replace(/\/$/, "");
const body =
`✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${bounty.title}".\n\n` +
`<sub>Updated automatically by ugig.net.</sub>`;
Comment on lines +454 to +456

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Same unescaped title in the "paid" comment

bounty.title is interpolated directly into the Markdown body here as well. The same escaping applied to the "posted" comment should be applied here for consistency.

Suggested change
const body =
`✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${bounty.title}".\n\n` +
`<sub>Updated automatically by ugig.net.</sub>`;
const escapedTitle = bounty.title.replace(/[`*_[\]()\\]/g, "\\$&");
const body =
`✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${escapedTitle}".\n\n` +
`<sub>Updated automatically by ugig.net.</sub>`;

Fix in Codex Fix in Claude Code

await updateIssueComment(coords.owner, coords.repo, bounty.github_comment_id, body);
}
}

await (supabase.from("notifications") as any).insert(
[
{
Expand Down
3 changes: 2 additions & 1 deletion src/app/bounties/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default async function EditBountyPage({
const { data: bounty } = await (supabase as any)
.from("bounties")
.select(
"id, creator_id, title, description, payout_usd, payment_coin, max_submissions, questions, status"
"id, creator_id, title, description, payout_usd, payment_coin, max_submissions, github_issue_url, questions, status"
)
.eq("id", id)
.single();
Expand Down Expand Up @@ -67,6 +67,7 @@ export default async function EditBountyPage({
payout_usd: Number(bounty.payout_usd),
payment_coin: bounty.payment_coin,
max_submissions: bounty.max_submissions,
github_issue_url: bounty.github_issue_url,
questions: bounty.questions || [],
}}
/>
Expand Down
14 changes: 14 additions & 0 deletions src/app/bounties/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Clock,
Lock,
Pencil,
Github,
} from "lucide-react";
import { MarkdownContent } from "@/components/ui/MarkdownContent";
import { PriceBox, PriceBoxRow } from "@/components/ui/PriceBox";
Expand All @@ -26,6 +27,7 @@ interface BountyDetail {
payout_currency: string;
payment_coin: string | null;
max_submissions: number | null;
github_issue_url: string | null;
status: "open" | "paused" | "closed";
questions: {
id: string;
Expand Down Expand Up @@ -238,6 +240,18 @@ export default async function BountyDetailPage({
{(submissionCount ?? 0) === 1 ? "" : "s"}
{bounty.max_submissions && ` / ${bounty.max_submissions}`}
</PriceBoxRow>
{bounty.github_issue_url && (
<PriceBoxRow icon={<Github className="h-4 w-4" />}>
<a
href={bounty.github_issue_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline truncate"
>
{bounty.github_issue_url.replace(/^https:\/\/(www\.)?github\.com\//, "")}
</a>
</PriceBoxRow>
)}
{!isCreator && bounty.status === "open" && user && !mySubmission && (
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
Scroll down to submit your answers.
Expand Down
25 changes: 25 additions & 0 deletions src/app/bounties/new/BountyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface BountyInitialData {
payout_usd?: number;
payment_coin?: string | null;
max_submissions?: number | null;
github_issue_url?: string | null;
questions?: Array<{
id: string;
type: QuestionType;
Expand Down Expand Up @@ -58,6 +59,7 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) {
const [maxSubmissions, setMaxSubmissions] = useState(
initialData?.max_submissions != null ? String(initialData.max_submissions) : ""
);
const [githubIssueUrl, setGithubIssueUrl] = useState(initialData?.github_issue_url ?? "");
const [questions, setQuestions] = useState<DraftQuestion[]>(
initialData?.questions && initialData.questions.length > 0
? initialData.questions.map((q) => ({
Expand Down Expand Up @@ -103,6 +105,11 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) {
return setError("Max submissions must be a positive number or blank");
}

const issueUrl = githubIssueUrl.trim();
if (issueUrl && !/^https:\/\/(www\.)?github\.com\/[^/]+\/[^/]+\/issues\/\d+/.test(issueUrl)) {
return setError("GitHub issue URL must look like https://github.com/owner/repo/issues/123");
}

setSubmitting(true);
try {
const url = isEdit ? `/api/bounties/${bountyId}` : "/api/bounties";
Expand All @@ -116,6 +123,7 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) {
payout_usd: payoutNum,

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 Clearing github_issue_url on edit is silently ignored

issueUrl || undefined evaluates to undefined when the field is empty, and JSON.stringify omits undefined-valued keys entirely. The PATCH request body therefore never includes github_issue_url, so Supabase never clears it — a previously-set URL is permanently stuck. Send null instead to allow the field to be unset, and validate/reject null in the Zod schema only when the field is explicitly required.

Fix in Codex Fix in Claude Code

payment_coin: coin || null,
max_submissions: max,
github_issue_url: issueUrl || undefined,
questions: questions.map((q) => ({
id: q.id,
type: q.type,
Expand Down Expand Up @@ -191,6 +199,23 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) {
/>
</div>

<div className="space-y-2">
<label className="text-sm font-medium">
GitHub issue URL <span className="text-muted-foreground">(optional)</span>
</label>
<input
type="url"
value={githubIssueUrl}
onChange={(e) => setGithubIssueUrl(e.target.value)}
placeholder="https://github.com/owner/repo/issues/123"
className="w-full border rounded-md px-3 py-2 bg-background"
/>
<p className="text-xs text-muted-foreground">
Link the issue this bounty funds. If the ugig GitHub App is installed on the repo, we
post a status comment on the issue and mark it paid when the payout settles.
</p>
</div>

<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium">Submission form</label>
Expand Down
9 changes: 9 additions & 0 deletions src/lib/bounties.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";
import { formatCurrency } from "@/lib/utils";
import { isGitHubIssueLink, GITHUB_ISSUE_LINK_HINT } from "@/lib/github-links";

/**
* Human label for a bounty payout, matching the gig card style:
Expand Down Expand Up @@ -31,6 +32,14 @@ export const createBountySchema = z.object({
payment_coin: z.string().max(16).nullable().optional(),
max_submissions: z.number().int().positive().max(100000).nullable().optional(),
closes_at: z.string().datetime().optional(),
// Optional GitHub issue this bounty funds. When set (and the ugig GitHub App
// is installed on the repo), a status comment is posted on the issue.
github_issue_url: z
.string()
.trim()
.refine((v) => v === "" || isGitHubIssueLink(v), GITHUB_ISSUE_LINK_HINT)
.transform((v) => (v === "" ? undefined : v))
.optional(),
questions: z.array(questionSchema).min(1).max(20),
});

Expand Down
136 changes: 136 additions & 0 deletions src/lib/github-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Minimal GitHub App client for posting/editing a single status comment on the
// GitHub issue a bounty is funding. Zero external dependencies — the App JWT is
// signed with node:crypto (RS256), and all calls go through fetch against the
// REST API.
//
// Configure via env:
// GITHUB_APP_ID — the numeric App ID
// GITHUB_APP_PRIVATE_KEY — the App private key (PEM). Literal "\n" sequences
// are normalized so the key can live on one env line.
//
// The App must be installed on the target repo. We resolve the installation on
// demand (GET /repos/{owner}/{repo}/installation) so no installation_id needs to
// be stored — if the App isn't installed, the call returns null and callers skip
// the comment gracefully.
import crypto from "crypto";

const API = "https://api.github.com";
const API_HEADERS = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "ugig.net-bounties",
};

export function isGitHubAppConfigured(): boolean {
return Boolean(process.env.GITHUB_APP_ID && process.env.GITHUB_APP_PRIVATE_KEY);
}

function base64url(input: Buffer | string): string {
return Buffer.from(input)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

// A short-lived (10 min) App JWT, signed RS256 with the App private key.
function appJwt(): string {
const appId = process.env.GITHUB_APP_ID as string;
const privateKey = (process.env.GITHUB_APP_PRIVATE_KEY as string).replace(/\\n/g, "\n");
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
// iat back-dated 60s to tolerate clock skew; GitHub caps exp at 10 min.
const payload = base64url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId }));
const signature = base64url(
crypto.createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey)
);
return `${header}.${payload}.${signature}`;
}

async function installationToken(owner: string, repo: string): Promise<string | null> {
const jwt = appJwt();
const authHeaders = { ...API_HEADERS, Authorization: `Bearer ${jwt}` };

const instRes = await fetch(`${API}/repos/${owner}/${repo}/installation`, {
headers: authHeaders,
});
if (!instRes.ok) {
// 404 = App not installed on this repo; anything else is a real error.
console.warn(`[github-app] no installation for ${owner}/${repo} (${instRes.status})`);
return null;
}
const installation = (await instRes.json()) as { id: number };

const tokenRes = await fetch(`${API}/app/installations/${installation.id}/access_tokens`, {
method: "POST",
headers: authHeaders,
});
if (!tokenRes.ok) {
console.warn(`[github-app] token exchange failed (${tokenRes.status})`);
return null;
}
const { token } = (await tokenRes.json()) as { token: string };
return token;
}

/**
* Post a comment on an issue. Returns the new comment id, or null if the App is
* unconfigured / not installed / the call failed. Never throws — callers treat
* the comment as best-effort.
*/
export async function postIssueComment(
owner: string,
repo: string,
issueNumber: number,
body: string
): Promise<number | null> {
if (!isGitHubAppConfigured()) return null;
try {
const token = await installationToken(owner, repo);
if (!token) return null;
const res = await fetch(`${API}/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: "POST",
headers: { ...API_HEADERS, Authorization: `Bearer ${token}` },
body: JSON.stringify({ body }),
});
if (!res.ok) {
console.warn(`[github-app] post comment failed (${res.status})`);
return null;
}
const comment = (await res.json()) as { id: number };
return comment.id;
} catch (err) {
console.warn("[github-app] post comment error:", err);
return null;
}
}

/**
* Edit an existing issue comment in place. Best-effort: returns true on success,
* false otherwise. Never throws.
*/
export async function updateIssueComment(
owner: string,
repo: string,
commentId: number,
body: string
): Promise<boolean> {
if (!isGitHubAppConfigured()) return false;
try {
const token = await installationToken(owner, repo);
if (!token) return false;
const res = await fetch(`${API}/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: "PATCH",
headers: { ...API_HEADERS, Authorization: `Bearer ${token}` },
body: JSON.stringify({ body }),
});
if (!res.ok) {
console.warn(`[github-app] update comment failed (${res.status})`);
return false;
}
return true;
} catch (err) {
console.warn("[github-app] update comment error:", err);
return false;
}
}
Loading
Loading