Skip to content

Commit 8dfcbe9

Browse files
feat: mastodon follow challenge (#33)
* fix: no verification config * feat: implemented mastodon check * fix: include mastodon challenge data * chore(autofix): apply formatting * refactor: remove optional Mastodon API token and simplify header handling * refactor: fixed event code problem * chore(autofix): apply formatting * refactor: using complete challenge * refactor: added url checking * refactor: regex for mastodon --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent d488930 commit 8dfcbe9

5 files changed

Lines changed: 290 additions & 26 deletions

File tree

app/src/app/api/challenges/[id]/webhook/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
4141
// Use the path from webhookUrl but resolve it against the local origin,
4242
// so we always hit our own API regardless of what host is stored in Sanity.
4343
const { pathname, search } = new URL(challenge.webhookUrl)
44+
if (!pathname.startsWith("/api/webhook/")) {
45+
return NextResponse.json({ message: "Invalid webhook path" }, { status: 400 })
46+
}
47+
4448
const { origin } = new URL(request.url)
4549
const localWebhookUrl = `${origin}${pathname}${search}`
4650

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

61+
const contentType = webhookResponse.headers.get("content-type") ?? ""
62+
if (!contentType.includes("application/json")) {
63+
const text = await webhookResponse.text()
64+
console.error("Webhook returned non-JSON response:", text)
65+
return NextResponse.json(
66+
{ message: "Webhook returned an unexpected response" },
67+
{ status: 502 },
68+
)
69+
}
5770
const data = await webhookResponse.json()
5871
return NextResponse.json(data, { status: webhookResponse.status })
5972
} catch (error) {
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { NextResponse } from "next/server"
2+
import { completeChallenge, findPlayerAndChallenge, findChallenge } from "@/lib/sanity.queries"
3+
4+
// Maximum number of following-list pages to walk before giving up.
5+
// Each page returns up to 80 accounts, so 12 pages ≈ 960 checked entries.
6+
const MAX_FOLLOWING_PAGES = 12
7+
8+
interface MastodonAccount {
9+
id: string
10+
acct: string
11+
url: string
12+
}
13+
14+
/**
15+
* Resolve a Mastodon username to an account object via the lookup API.
16+
*
17+
* Uses GET /api/v1/accounts/lookup which works without authentication and
18+
* directly returns the account for a given acct handle on the instance.
19+
*
20+
* @param username The bare username (no @ prefix, no domain) entered by the player.
21+
* @param instance The Mastodon instance derived from the challenge's callToAction URL.
22+
*/
23+
async function resolveAccount(username: string, instance: string): Promise<MastodonAccount | null> {
24+
const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${encodeURIComponent(username)}`
25+
26+
const response = await fetch(lookupUrl, { headers: { Accept: "application/json" } })
27+
28+
if (response.status === 404) return null
29+
30+
if (!response.ok) {
31+
throw new Error(
32+
`Mastodon account lookup failed on ${instance}: ${response.status} ${response.statusText}`,
33+
)
34+
}
35+
36+
return (await response.json()) as MastodonAccount
37+
}
38+
39+
/**
40+
* Parse the Mastodon instance and username from a profile URL.
41+
*
42+
* Supports:
43+
* - https://mastodon.social/@nephila → { instance: "mastodon.social", username: "nephila" }
44+
* - https://fosstodon.org/users/fosstodon → { instance: "fosstodon.org", username: "fosstodon" }
45+
*/
46+
function parseProfileUrl(url: string): { instance: string; username: string } | null {
47+
try {
48+
const parsed = new URL(url)
49+
const instance = parsed.hostname
50+
// Handles both "/@username" and "/users/username" forms
51+
const match = parsed.pathname.match(/^\/(?:@|users\/)([^/]+)/)
52+
if (!match) return null
53+
return { instance, username: match[1] }
54+
} catch {
55+
return null
56+
}
57+
}
58+
59+
/**
60+
* Check whether `followerAccount` (identified by its Mastodon ID on `instance`)
61+
* is following `targetAcct` (the full qualified acct, e.g. "nephila@mastodon.social"
62+
* or just "nephila" if on the same instance).
63+
*
64+
* Paginates through the follower's following list up to MAX_FOLLOWING_PAGES pages.
65+
*/
66+
async function checkIsFollowing(
67+
instance: string,
68+
followerId: string,
69+
targetAcct: string,
70+
): Promise<boolean> {
71+
// Normalise targetAcct for comparison — strip leading @
72+
const normTarget = targetAcct.replace(/^@/, "").toLowerCase()
73+
74+
let url: string | null = `https://${instance}/api/v1/accounts/${followerId}/following?limit=80`
75+
let pages = 0
76+
77+
while (url !== null && pages < MAX_FOLLOWING_PAGES) {
78+
const currentUrl: string = url
79+
url = null
80+
81+
const response: Response = await fetch(currentUrl, { headers: { Accept: "application/json" } })
82+
83+
if (!response.ok) {
84+
throw new Error(
85+
`Mastodon following-list fetch failed: ${response.status} ${response.statusText}`,
86+
)
87+
}
88+
89+
const followingPage = (await response.json()) as MastodonAccount[]
90+
91+
for (const account of followingPage) {
92+
const acct = account.acct.toLowerCase()
93+
// Match "nephila" or "nephila@mastodon.social"
94+
if (acct === normTarget || acct.split("@")[0] === normTarget.split("@")[0]) {
95+
// Confirm the instance also matches when both sides have a domain
96+
const acctDomain = acct.split("@")[1] ?? instance
97+
const targetDomain = normTarget.split("@")[1] ?? instance
98+
if (acctDomain === targetDomain) return true
99+
}
100+
}
101+
102+
// Mastodon uses Link headers for cursor-based pagination
103+
const linkHeader: string | null = response.headers.get("link")
104+
if (linkHeader) {
105+
const nextMatch: RegExpMatchArray | null = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
106+
if (nextMatch) url = nextMatch[1]
107+
}
108+
109+
pages++
110+
}
111+
112+
return false
113+
}
114+
115+
export async function POST(request: Request) {
116+
try {
117+
const body = await request.json()
118+
const {
119+
challengeId,
120+
playerEmail,
121+
verificationData,
122+
}: {
123+
challengeId?: string
124+
playerEmail?: string
125+
verificationData?: Record<string, string>
126+
} = body
127+
128+
if (!challengeId || !playerEmail || !verificationData) {
129+
return NextResponse.json(
130+
{ message: "Missing required fields", success: false },
131+
{ status: 400 },
132+
)
133+
}
134+
135+
const mastodonUsername = verificationData.username?.trim().replace(/^@/, "").split("@")[0]
136+
if (!mastodonUsername) {
137+
return NextResponse.json(
138+
{ message: "Mastodon username is required", success: false },
139+
{ status: 400 },
140+
)
141+
}
142+
143+
// Load the challenge so we can extract the target account from callToAction.url
144+
const challenge = await findChallenge(challengeId)
145+
if (!challenge) {
146+
return NextResponse.json({ message: "Challenge not found", success: false }, { status: 404 })
147+
}
148+
149+
const callToActionUrl: string | undefined = challenge.callToAction?.url
150+
if (!callToActionUrl) {
151+
return NextResponse.json(
152+
{ message: "Challenge has no callToAction URL configured", success: false },
153+
{ status: 500 },
154+
)
155+
}
156+
157+
const targetParsed = parseProfileUrl(callToActionUrl)
158+
if (!targetParsed) {
159+
return NextResponse.json(
160+
{ message: "Could not parse target Mastodon profile URL", success: false },
161+
{ status: 500 },
162+
)
163+
}
164+
165+
const { instance: targetInstance, username: targetUsername } = targetParsed
166+
// Full qualified acct used for comparison
167+
const targetAcct = `${targetUsername}@${targetInstance}`
168+
169+
// Verify the player hasn't already completed this challenge
170+
const player = await findPlayerAndChallenge(playerEmail, challengeId)
171+
if (!player) {
172+
return NextResponse.json(
173+
{ message: "Player not found or challenge already completed", success: false },
174+
{ status: 404 },
175+
)
176+
}
177+
178+
// Resolve the submitting user's Mastodon account.
179+
// We search on the target instance (mastodon.social) which can WebFinger-resolve
180+
// remote accounts — so cross-instance users are handled correctly.
181+
let followerAccount: MastodonAccount | null
182+
try {
183+
followerAccount = await resolveAccount(mastodonUsername, targetInstance)
184+
} catch (error) {
185+
console.error("Mastodon account resolution error:", error)
186+
return NextResponse.json(
187+
{
188+
message: `Could not resolve Mastodon account "${mastodonUsername}". Make sure you entered the correct username`,
189+
success: false,
190+
},
191+
{ status: 422 },
192+
)
193+
}
194+
195+
if (!followerAccount) {
196+
return NextResponse.json(
197+
{
198+
message: `Mastodon account "${mastodonUsername}" not found.`,
199+
success: false,
200+
},
201+
{ status: 404 },
202+
)
203+
}
204+
205+
// Check following list
206+
let isFollowing: boolean
207+
try {
208+
isFollowing = await checkIsFollowing(targetInstance, followerAccount.id, targetAcct)
209+
} catch (error) {
210+
console.error("Mastodon following-check error:", error)
211+
return NextResponse.json(
212+
{
213+
message: "Failed to verify Mastodon follow status. Please try again later.",
214+
success: false,
215+
},
216+
{ status: 500 },
217+
)
218+
}
219+
220+
if (!isFollowing) {
221+
return NextResponse.json(
222+
{
223+
message: `@${followerAccount.acct} does not appear to be following @${targetAcct}. Make sure you follow the account and try again.`,
224+
success: false,
225+
},
226+
{ status: 422 },
227+
)
228+
}
229+
230+
// Follow confirmed — mark the challenge as completed
231+
await completeChallenge(player._id, challengeId, {
232+
...verificationData,
233+
mastodon_username: followerAccount.acct,
234+
})
235+
236+
return NextResponse.json({
237+
message: `Follow verified! @${followerAccount.acct} is following @${targetAcct}.`,
238+
success: true,
239+
})
240+
} catch (error) {
241+
console.error("Mastodon webhook error:", error)
242+
return NextResponse.json({ message: "Internal server error", success: false }, { status: 500 })
243+
}
244+
}

app/src/app/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ export default function HomePage() {
4646
</div>
4747

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

5454
<div

app/src/components/ChallengeDetail.tsx

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,15 @@ export function ChallengeDetail({ challenge }: Props) {
110110
})
111111

112112
if (!response.ok) {
113-
const errorData = await response.json()
113+
let errorMessage = "Unknown error"
114+
try {
115+
const errorData = await response.json()
116+
errorMessage = errorData.message ?? errorMessage
117+
} catch {
118+
errorMessage = await response.text().catch(() => errorMessage)
119+
}
114120
throw new Error(
115-
`Failed to verify the challenge: ${errorData.message}. If the problem persists, please contact the staff.`,
121+
`Failed to verify the challenge: ${errorMessage}. If the problem persists, please contact the staff.`,
116122
)
117123
}
118124
} catch (error) {
@@ -243,20 +249,19 @@ export function ChallengeDetail({ challenge }: Props) {
243249
</div>
244250
)}
245251
<form>
246-
{challenge.verificationConfigJSON &&
247-
challenge.verificationConfigJSON.fields.map((field) => (
248-
<div key={field.name}>
249-
{field.type === "hidden" ? null : (
250-
<h3 className="font-semibold mb-2">{field.title}:</h3>
251-
)}
252-
{field.type === "hidden" ? null : (
253-
<label htmlFor={field.name} className="text-sm text-neutral-600 mb-2">
254-
{field.description}
255-
</label>
256-
)}
257-
<Input type={field.type} name={field.name} defaultValue={field?.value || ""} />
258-
</div>
259-
))}
252+
{challenge.verificationConfigJSON?.fields?.map((field) => (
253+
<div key={field.name}>
254+
{field.type === "hidden" ? null : (
255+
<h3 className="font-semibold mb-2">{field.title}:</h3>
256+
)}
257+
{field.type === "hidden" ? null : (
258+
<label htmlFor={field.name} className="text-sm text-neutral-600 mb-2">
259+
{field.description}
260+
</label>
261+
)}
262+
<Input type={field.type} name={field.name} defaultValue={field?.value || ""} />
263+
</div>
264+
))}
260265
</form>
261266

262267
{!challenge.isOnline && (

app/src/types/index.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,15 @@ export interface Challenge {
5151
}
5252
verificationConfigJSON?: {
5353
type: string
54-
fields: {
55-
type: string
56-
title: string
57-
name: string
58-
description: string
59-
value?: string
60-
}[]
54+
fields:
55+
| {
56+
type: string
57+
title: string
58+
name: string
59+
description: string
60+
value?: string
61+
}[]
62+
| null
6163
}
6264
event: {
6365
_type: "reference"

0 commit comments

Comments
 (0)