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 ( / ^ \/ (?: @ | u s e r s \/ ) ( [ ^ / ] + ) / )
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 * r e l = " n e x t " / )
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+ }
0 commit comments