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
13 changes: 13 additions & 0 deletions app/src/app/api/challenges/[id]/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
// Use the path from webhookUrl but resolve it against the local origin,
// so we always hit our own API regardless of what host is stored in Sanity.
const { pathname, search } = new URL(challenge.webhookUrl)
Comment thread
BugliL marked this conversation as resolved.
if (!pathname.startsWith("/api/webhook/")) {
return NextResponse.json({ message: "Invalid webhook path" }, { status: 400 })
}

const { origin } = new URL(request.url)
const localWebhookUrl = `${origin}${pathname}${search}`

Expand All @@ -54,6 +58,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
}),
})

const contentType = webhookResponse.headers.get("content-type") ?? ""
if (!contentType.includes("application/json")) {
const text = await webhookResponse.text()
console.error("Webhook returned non-JSON response:", text)
return NextResponse.json(
{ message: "Webhook returned an unexpected response" },
{ status: 502 },
)
}
const data = await webhookResponse.json()
return NextResponse.json(data, { status: webhookResponse.status })
} catch (error) {
Expand Down
244 changes: 244 additions & 0 deletions app/src/app/api/webhook/mastodon/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { NextResponse } from "next/server"
import { completeChallenge, findPlayerAndChallenge, findChallenge } from "@/lib/sanity.queries"

// Maximum number of following-list pages to walk before giving up.
// Each page returns up to 80 accounts, so 12 pages β‰ˆ 960 checked entries.
const MAX_FOLLOWING_PAGES = 12

interface MastodonAccount {
id: string
acct: string
url: string
}

/**
* Resolve a Mastodon username to an account object via the lookup API.
*
* Uses GET /api/v1/accounts/lookup which works without authentication and
* directly returns the account for a given acct handle on the instance.
*
* @param username The bare username (no @ prefix, no domain) entered by the player.
* @param instance The Mastodon instance derived from the challenge's callToAction URL.
*/
async function resolveAccount(username: string, instance: string): Promise<MastodonAccount | null> {
const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${encodeURIComponent(username)}`

const response = await fetch(lookupUrl, { headers: { Accept: "application/json" } })

if (response.status === 404) return null

if (!response.ok) {
throw new Error(
`Mastodon account lookup failed on ${instance}: ${response.status} ${response.statusText}`,
)
}

return (await response.json()) as MastodonAccount
}

/**
* Parse the Mastodon instance and username from a profile URL.
*
* Supports:
* - https://mastodon.social/@nephila β†’ { instance: "mastodon.social", username: "nephila" }
* - https://fosstodon.org/users/fosstodon β†’ { instance: "fosstodon.org", username: "fosstodon" }
*/
function parseProfileUrl(url: string): { instance: string; username: string } | null {
try {
const parsed = new URL(url)
const instance = parsed.hostname
// Handles both "/@username" and "/users/username" forms
const match = parsed.pathname.match(/^\/(?:@|users\/)([^/]+)/)
if (!match) return null
return { instance, username: match[1] }
} catch {
return null
}
}

/**
* Check whether `followerAccount` (identified by its Mastodon ID on `instance`)
* is following `targetAcct` (the full qualified acct, e.g. "nephila@mastodon.social"
* or just "nephila" if on the same instance).
*
* Paginates through the follower's following list up to MAX_FOLLOWING_PAGES pages.
*/
async function checkIsFollowing(
instance: string,
followerId: string,
targetAcct: string,
): Promise<boolean> {
// Normalise targetAcct for comparison β€” strip leading @
const normTarget = targetAcct.replace(/^@/, "").toLowerCase()

let url: string | null = `https://${instance}/api/v1/accounts/${followerId}/following?limit=80`
let pages = 0

while (url !== null && pages < MAX_FOLLOWING_PAGES) {
const currentUrl: string = url
url = null

const response: Response = await fetch(currentUrl, { headers: { Accept: "application/json" } })

if (!response.ok) {
throw new Error(
`Mastodon following-list fetch failed: ${response.status} ${response.statusText}`,
)
}

const followingPage = (await response.json()) as MastodonAccount[]

for (const account of followingPage) {
const acct = account.acct.toLowerCase()
// Match "nephila" or "nephila@mastodon.social"
if (acct === normTarget || acct.split("@")[0] === normTarget.split("@")[0]) {
// Confirm the instance also matches when both sides have a domain
const acctDomain = acct.split("@")[1] ?? instance
const targetDomain = normTarget.split("@")[1] ?? instance
if (acctDomain === targetDomain) return true
}
}

// Mastodon uses Link headers for cursor-based pagination
const linkHeader: string | null = response.headers.get("link")
if (linkHeader) {
const nextMatch: RegExpMatchArray | null = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
if (nextMatch) url = nextMatch[1]
}

pages++
}

return false
}

export async function POST(request: Request) {
try {
const body = await request.json()
const {
challengeId,
playerEmail,
verificationData,
}: {
challengeId?: string
playerEmail?: string
verificationData?: Record<string, string>
} = body

if (!challengeId || !playerEmail || !verificationData) {
return NextResponse.json(
{ message: "Missing required fields", success: false },
{ status: 400 },
)
}

const mastodonUsername = verificationData.username?.trim().replace(/^@/, "").split("@")[0]
if (!mastodonUsername) {
return NextResponse.json(
{ message: "Mastodon username is required", success: false },
{ status: 400 },
)
}

// Load the challenge so we can extract the target account from callToAction.url
const challenge = await findChallenge(challengeId)
if (!challenge) {
return NextResponse.json({ message: "Challenge not found", success: false }, { status: 404 })
}

const callToActionUrl: string | undefined = challenge.callToAction?.url
if (!callToActionUrl) {
return NextResponse.json(
{ message: "Challenge has no callToAction URL configured", success: false },
{ status: 500 },
)
}

const targetParsed = parseProfileUrl(callToActionUrl)
if (!targetParsed) {
return NextResponse.json(
{ message: "Could not parse target Mastodon profile URL", success: false },
{ status: 500 },
)
}

const { instance: targetInstance, username: targetUsername } = targetParsed
// Full qualified acct used for comparison
const targetAcct = `${targetUsername}@${targetInstance}`

// Verify the player hasn't already completed this challenge
const player = await findPlayerAndChallenge(playerEmail, challengeId)
if (!player) {
return NextResponse.json(
{ message: "Player not found or challenge already completed", success: false },
{ status: 404 },
)
}

// Resolve the submitting user's Mastodon account.
// We search on the target instance (mastodon.social) which can WebFinger-resolve
// remote accounts β€” so cross-instance users are handled correctly.
let followerAccount: MastodonAccount | null
try {
followerAccount = await resolveAccount(mastodonUsername, targetInstance)
} catch (error) {
console.error("Mastodon account resolution error:", error)
return NextResponse.json(
{
message: `Could not resolve Mastodon account "${mastodonUsername}". Make sure you entered the correct username`,
success: false,
},
{ status: 422 },
)
}

if (!followerAccount) {
return NextResponse.json(
{
message: `Mastodon account "${mastodonUsername}" not found.`,
success: false,
},
{ status: 404 },
)
}

// Check following list
let isFollowing: boolean
try {
isFollowing = await checkIsFollowing(targetInstance, followerAccount.id, targetAcct)
} catch (error) {
console.error("Mastodon following-check error:", error)
return NextResponse.json(
{
message: "Failed to verify Mastodon follow status. Please try again later.",
success: false,
},
{ status: 500 },
)
}

if (!isFollowing) {
return NextResponse.json(
{
message: `@${followerAccount.acct} does not appear to be following @${targetAcct}. Make sure you follow the account and try again.`,
success: false,
},
{ status: 422 },
)
}

// Follow confirmed β€” mark the challenge as completed
await completeChallenge(player._id, challengeId, {
...verificationData,
mastodon_username: followerAccount.acct,
})

return NextResponse.json({
message: `Follow verified! @${followerAccount.acct} is following @${targetAcct}.`,
success: true,
})
} catch (error) {
console.error("Mastodon webhook error:", error)
return NextResponse.json({ message: "Internal server error", success: false }, { status: 500 })
}
}
6 changes: 3 additions & 3 deletions app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ export default function HomePage() {
</div>

<p className="text-center mb-8 filter text-lg font-bold">
{eventCode
? `Join the ${capitalize(eventCode)} challenge and collect points through exciting activities! Unlock exclusive rewards with your earned points!`
: `Join the ${eventCode ? capitalize(eventCode) : ""} challenge and collect points through exciting activities! Unlock exclusive rewards with your earned points!`}
Join the {eventCode ? capitalize(eventCode) : ""}
challenge and collect points through exciting activities! Unlock exclusive rewards with
your earned points!
</p>

<div
Expand Down
37 changes: 21 additions & 16 deletions app/src/components/ChallengeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,15 @@ export function ChallengeDetail({ challenge }: Props) {
})

if (!response.ok) {
const errorData = await response.json()
let errorMessage = "Unknown error"
try {
const errorData = await response.json()
errorMessage = errorData.message ?? errorMessage
} catch {
errorMessage = await response.text().catch(() => errorMessage)
}
throw new Error(
`Failed to verify the challenge: ${errorData.message}. If the problem persists, please contact the staff.`,
`Failed to verify the challenge: ${errorMessage}. If the problem persists, please contact the staff.`,
)
}
} catch (error) {
Expand Down Expand Up @@ -243,20 +249,19 @@ export function ChallengeDetail({ challenge }: Props) {
</div>
)}
<form>
{challenge.verificationConfigJSON &&
challenge.verificationConfigJSON.fields.map((field) => (
<div key={field.name}>
{field.type === "hidden" ? null : (
<h3 className="font-semibold mb-2">{field.title}:</h3>
)}
{field.type === "hidden" ? null : (
<label htmlFor={field.name} className="text-sm text-neutral-600 mb-2">
{field.description}
</label>
)}
<Input type={field.type} name={field.name} defaultValue={field?.value || ""} />
</div>
))}
{challenge.verificationConfigJSON?.fields?.map((field) => (
<div key={field.name}>
{field.type === "hidden" ? null : (
<h3 className="font-semibold mb-2">{field.title}:</h3>
)}
{field.type === "hidden" ? null : (
<label htmlFor={field.name} className="text-sm text-neutral-600 mb-2">
{field.description}
</label>
)}
<Input type={field.type} name={field.name} defaultValue={field?.value || ""} />
</div>
))}
</form>

{!challenge.isOnline && (
Expand Down
16 changes: 9 additions & 7 deletions app/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ export interface Challenge {
}
verificationConfigJSON?: {
type: string
fields: {
type: string
title: string
name: string
description: string
value?: string
}[]
fields:
| {
type: string
title: string
name: string
description: string
value?: string
}[]
| null
}
event: {
_type: "reference"
Expand Down
Loading