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
186 changes: 165 additions & 21 deletions src/app/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ import { Button } from "@/components/ui/Button";
import { DataSourceNotice } from "@/components/ui/DataSourceNotice";
import { formatInteger } from "@/lib/utils";
import { messages, t } from "@/lib/messages";
import type { Bounty } from "@/types";
import {
applyBountyQuery,
buildIssuesHref,
isFilterActive,
parseBountyQuery,
SORT_LABELS,
SORT_VALUES,
STATUS_VALUES,
DIFFICULTY_VALUES,
ASSET_VALUES,
type RawSearchParams,
} from "@/lib/bounty-query";

const issuesDescription =
"Browse paid, escrow-backed GitHub issues funded through MergeFi and ready for contributors.";
Expand Down Expand Up @@ -37,32 +48,34 @@ const STATUS_FILTERS = [
{ value: "all", label: "All" },
{ value: "open", label: "Open" },
{ value: "in_review", label: "In review" },
] as const;
] as const satisfies readonly { value: (typeof STATUS_VALUES)[number]; label: string }[];

type StatusFilter = (typeof STATUS_FILTERS)[number]["value"];

function coerceStatusFilter(raw: string | string[] | undefined): StatusFilter {
const value = Array.isArray(raw) ? raw[0] : raw;
return STATUS_FILTERS.some((f) => f.value === value) ? (value as StatusFilter) : "all";
}
const DIFFICULTY_LABELS: Record<(typeof DIFFICULTY_VALUES)[number], string> = {
all: "Any difficulty",
beginner: "Beginner",
intermediate: "Intermediate",
advanced: "Advanced",
expert: "Expert",
};

function applyStatusFilter(bounties: Bounty[], filter: StatusFilter): Bounty[] {
if (filter === "all") return bounties;
return bounties.filter((b) => b.status === filter);
}
const ASSET_LABELS: Record<(typeof ASSET_VALUES)[number], string> = {
all: "Any asset",
USDC: "USDC",
XLM: "XLM",
};

export default async function IssuesPage({
searchParams,
}: {
// Next 16 App Router: searchParams is a promise on server components.
searchParams: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<RawSearchParams>;
}) {
const params = await searchParams;
const status = coerceStatusFilter(params.status);
const filterActive = status !== "all";
const query = parseBountyQuery(params);
const filterActive = isFilterActive(query);

const { data: bounties, source } = await fetchBounties(mockBounties);
const visible = applyStatusFilter(bounties, status);
const { data: bounties, source } = await fetchBounties(mockBounties, query);
const { items: visible, page, totalPages, filteredCount } = applyBountyQuery(bounties, query);
const total = bounties.length;

return (
Expand All @@ -87,14 +100,14 @@ export default async function IssuesPage({
the other. */}
{source === "mock" && <DataSourceNotice className="mb-6" />}

<div className="mb-6 flex flex-wrap items-center gap-2">
<div className="mb-4 flex flex-wrap items-center gap-2">
<span className="text-sm text-slate-500 dark:text-slate-400">Status:</span>
{STATUS_FILTERS.map((f) => {
const active = f.value === status;
const active = f.value === query.status;
return (
<Link
key={f.value}
href={f.value === "all" ? "/issues" : `/issues?status=${f.value}`}
href={buildIssuesHref(params, { status: f.value === "all" ? undefined : f.value })}
aria-current={active ? "true" : undefined}
className={
active
Expand All @@ -107,10 +120,106 @@ export default async function IssuesPage({
);
})}
<span className="ms-auto text-sm text-slate-500 dark:text-slate-400">
{formatInteger(visible.length)} of {formatInteger(total)}
{formatInteger(filteredCount)} of {formatInteger(total)}
</span>
</div>

{/* Difficulty/asset/reward-range/sort are exposed as a plain GET form
rather than client-side state — every combination stays a shareable,
bookmarkable URL and the page works with JavaScript disabled. The
hidden `status` field carries the pill selection above through the
same submit, since HTML forms only send their own named fields. */}
<form
method="get"
action="/issues"
className="mb-6 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-900/40"
>
{query.status !== "all" && <input type="hidden" name="status" value={query.status} />}

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Difficulty
<select
name="difficulty"
defaultValue={query.difficulty}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{DIFFICULTY_VALUES.map((value) => (
<option key={value} value={value}>
{DIFFICULTY_LABELS[value]}
</option>
))}
</select>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Asset
<select
name="asset"
defaultValue={query.asset}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{ASSET_VALUES.map((value) => (
<option key={value} value={value}>
{ASSET_LABELS[value]}
</option>
))}
</select>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Min reward
<input
type="number"
inputMode="decimal"
min={0}
name="minReward"
defaultValue={query.minReward ?? ""}
placeholder="0"
className="w-24 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
/>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Max reward
<input
type="number"
inputMode="decimal"
min={0}
name="maxReward"
defaultValue={query.maxReward ?? ""}
placeholder="Any"
className="w-24 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
/>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Sort by
<select
name="sort"
defaultValue={query.sort}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{SORT_VALUES.map((value) => (
<option key={value} value={value}>
{SORT_LABELS[value]}
</option>
))}
</select>
</label>

<Button type="submit" size="sm">
Apply filters
</Button>
{filterActive && (
<Link
href="/issues"
className="text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400"
>
Clear all
</Link>
)}
</form>

{visible.length === 0 ? (
filterActive ? (
/* Filtered-to-zero. Distinguished from a genuinely empty platform
Expand Down Expand Up @@ -154,6 +263,41 @@ export default async function IssuesPage({
<BountyCard key={bounty.id} bounty={bounty} />
))}
</div>

{totalPages > 1 && (
<nav
aria-label="Bounty board pages"
className="mt-8 flex items-center justify-center gap-4"
>
<Link
href={buildIssuesHref(params, { page: String(Math.max(1, page - 1)) })}
aria-disabled={page <= 1}
className={
page <= 1
? "pointer-events-none rounded-md px-3 py-1.5 text-sm text-slate-300 dark:text-slate-700"
: "rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
}
>
Previous
</Link>
<span className="text-sm text-slate-500 dark:text-slate-400">
Page {formatInteger(page)} of {formatInteger(totalPages)}
</span>
<Link
href={buildIssuesHref(params, {
page: String(Math.min(totalPages, page + 1)),
})}
aria-disabled={page >= totalPages}
className={
page >= totalPages
? "pointer-events-none rounded-md px-3 py-1.5 text-sm text-slate-300 dark:text-slate-700"
: "rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
}
>
Next
</Link>
</nav>
)}
</>
)}
</div>
Expand Down
52 changes: 42 additions & 10 deletions src/components/ui/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
/**
* Avatar.test.tsx
*
* Covers Avatar render logic (src vs fallback URL, unoptimized flag) and
* AvatarStack overflow counting (under-max, at-max, over-max) — #278.
* Covers Avatar render logic (src vs fallback URL, unoptimized flag),
* malicious/unallowlisted `src` rejection and load-failure fallback (#20),
* and AvatarStack overflow counting (under-max, at-max, over-max) — #278.
*/

import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { Avatar, AvatarStack } from "./Avatar";

const GITHUB_AVATAR = "https://avatars.githubusercontent.com/u/1?v=4";

describe("Avatar", () => {
it("renders with the given seed as alt text", () => {
render(<Avatar seed="alice" />);
const img = screen.getByRole("img", { name: "alice" });
expect(img).toBeInTheDocument();
});

it("uses the src prop when provided", () => {
render(<Avatar seed="alice" src="https://example.com/alice.png" />);
it("uses the src prop when it's on the allowlisted GitHub avatar host", () => {
render(<Avatar seed="alice" src={GITHUB_AVATAR} />);
const img = screen.getByRole("img", { name: "alice" });
// Next.js Image rewrites the src through its optimization pipeline
expect(img.getAttribute("src")).toContain("example.com%2Falice.png");
expect(img.getAttribute("src")).toContain("avatars.githubusercontent.com");
});

it("falls back to a dicebear URL when no src is provided", () => {
Expand All @@ -36,11 +39,10 @@ describe("Avatar", () => {
expect(img.getAttribute("src")).toContain("dicebear.com");
});

it("does not set unoptimized for custom src URLs", () => {
render(<Avatar seed="dave" src="https://example.com/dave.png" />);
it("does not set unoptimized for allowlisted custom src URLs", () => {
render(<Avatar seed="dave" src={GITHUB_AVATAR} />);
const img = screen.getByRole("img", { name: "dave" });
// Next.js Image rewrites src through optimization; just verify it's present
expect(img.getAttribute("src")).toContain("example.com");
expect(img.getAttribute("src")).toContain("avatars.githubusercontent.com");
});

it("applies custom size dimensions", () => {
Expand All @@ -55,6 +57,36 @@ describe("Avatar", () => {
const img = screen.getByRole("img", { name: "frank" });
expect(img.className).toMatch(/test-extra/);
});

it("falls back to the dicebear identicon for a src on a non-allowlisted host", () => {
render(<Avatar seed="gina" src="https://evil.example.com/gina.png" />);
const img = screen.getByRole("img", { name: "gina" });
expect(img.getAttribute("src")).toContain("dicebear.com");
});

it("falls back to the dicebear identicon for a javascript: scheme src", () => {
render(<Avatar seed="hank" src="javascript:alert(1)" />);
const img = screen.getByRole("img", { name: "hank" });
expect(img.getAttribute("src")).toContain("dicebear.com");
});

it("falls back to the dicebear identicon for a data: scheme src", () => {
render(<Avatar seed="ivy" src="data:text/html,<script>alert(1)</script>" />);
const img = screen.getByRole("img", { name: "ivy" });
expect(img.getAttribute("src")).toContain("dicebear.com");
});

it("falls back to the dicebear identicon when the allowlisted src fails to load", () => {
render(<Avatar seed="jack" src={GITHUB_AVATAR} />);
const img = screen.getByRole("img", { name: "jack" });
expect(img.getAttribute("src")).toContain("avatars.githubusercontent.com");

fireEvent.error(img);

expect(screen.getByRole("img", { name: "jack" }).getAttribute("src")).toContain(
"dicebear.com",
);
});
});

describe("AvatarStack", () => {
Expand Down
39 changes: 37 additions & 2 deletions src/components/ui/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
"use client";

import Image from "next/image";
import { useState } from "react";
import { cn } from "@/lib/utils";

function seedToUrl(seed: string) {
return `https://api.dicebear.com/9.x/identicon/svg?seed=${encodeURIComponent(seed)}&backgroundType=gradientLinear`;
}

/**
* Must match next.config.ts's `images.remotePatterns` — the only hosts an
* avatar `src` is ever allowed to resolve to (#20). Backend `avatarUrl` is
* typed only as `string | null`, not validated as a safe image URL, so a
* `javascript:`/`data:` scheme or an unexpected third-party host must be
* rejected here rather than trusted through to `<Image>`.
*/
const ALLOWED_AVATAR_HOSTS = new Set(["avatars.githubusercontent.com", "api.dicebear.com"]);

function isSafeAvatarUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "https:" && ALLOWED_AVATAR_HOSTS.has(url.hostname);
} catch {
return false;
}
}

export function Avatar({
seed,
src,
Expand All @@ -16,13 +37,27 @@ export function Avatar({
size?: number;
className?: string;
}) {
const fallback = seedToUrl(seed);
const requested = src && isSafeAvatarUrl(src) ? src : fallback;

// Falls back to the dicebear identicon on a real load failure (404,
// unreachable host, ...) in addition to the safety check above. Tracking
// the specific src that errored — not a plain boolean — means a later
// render with a *different* `src` prop (e.g. the user updates their GitHub
// avatar) retries instead of staying stuck on the old fallback, while a
// repeat failure of the same src stays on the fallback instead of
// retrying it every render.
const [erroredSrc, setErroredSrc] = useState<string | null>(null);
const resolved = erroredSrc === requested ? fallback : requested;

return (
<Image
src={src ?? seedToUrl(seed)}
src={resolved}
alt={seed}
width={size}
height={size}
unoptimized={!src || src.startsWith("https://api.dicebear.com")}
unoptimized={resolved.startsWith("https://api.dicebear.com")}
onError={() => setErroredSrc(requested)}
className={cn(
"rounded-full border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800",
className,
Expand Down
Loading