Skip to content

Commit d1c3ff5

Browse files
authored
Merge pull request #214 from dodok8/remote-follow
feat(web-next): implement remote follow UIRemote follow
2 parents bff75d0 + 460bb6d commit d1c3ff5

File tree

13 files changed

+1295
-73
lines changed

13 files changed

+1295
-73
lines changed

graphql/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import "./reactable.ts";
1717
import "./search.ts";
1818
import "./signup.ts";
1919
import "./timeline.ts";
20+
import "./webfinger.ts";
2021
export type { UserContext as Context } from "./builder.ts";
2122
export { createYogaServer } from "./server.ts";
2223

graphql/schema.graphql

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,19 @@ type Query {
958958

959959
"""Returns all accounts as a flat array for building the invitation tree."""
960960
invitationTree: [InvitationTreeNode!]!
961+
962+
"""
963+
Look up a remote Fediverse user by their handle, fetching their ActivityPub profile and constructing a remote follow URL for the given actor.
964+
"""
965+
lookupRemoteFollower(
966+
"""The Relay global ID of the Hackers' Pub actor to be followed."""
967+
actorId: ID!
968+
969+
"""
970+
The Fediverse handle of the remote user who wants to follow (e.g., @user@mastodon.social).
971+
"""
972+
followerHandle: String!
973+
): WebFingerResult
961974
markdownGuide(
962975
"""The locale for the Markdown guide."""
963976
locale: Locale!
@@ -1367,4 +1380,20 @@ type UploadMediaPayload {
13671380
width: Int!
13681381
}
13691382

1370-
union UploadMediaResult = InvalidInputError | NotAuthenticatedError | UploadMediaPayload
1383+
union UploadMediaResult = InvalidInputError | NotAuthenticatedError | UploadMediaPayload
1384+
1385+
"""
1386+
Result of looking up a remote follower via WebFinger, including their ActivityPub profile and remote follow URL.
1387+
"""
1388+
type WebFingerResult {
1389+
domain: String
1390+
emojis: JSON
1391+
handle: String
1392+
iconUrl: URL
1393+
name: String
1394+
preferredUsername: String
1395+
remoteFollowUrl: URL
1396+
software: String
1397+
summary: String
1398+
url: URL
1399+
}

graphql/webfinger.ts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { getNodeInfo } from "@fedify/fedify";
2+
import * as vocab from "@fedify/vocab";
3+
import { isActor } from "@fedify/vocab";
4+
import type { Actor as FedifyActor } from "@fedify/vocab";
5+
import { validateUuid } from "@hackerspub/models/uuid";
6+
import { getLogger } from "@logtape/logtape";
7+
import { Actor } from "./actor.ts";
8+
import { builder, type UserContext } from "./builder.ts";
9+
10+
const logger = getLogger(["hackerspub", "graphql", "webfinger"]);
11+
12+
const FEDIVERSE_ID_REGEX =
13+
/^@?([a-zA-Z0-9_.-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/;
14+
15+
interface WebfingerLink {
16+
rel?: string;
17+
type?: string;
18+
href?: string;
19+
template?: string;
20+
}
21+
22+
interface WebFingerResultData {
23+
preferredUsername: string | null;
24+
name: string | null;
25+
summary: string | null;
26+
url: URL | null;
27+
iconUrl: URL | null;
28+
handle: string | null;
29+
domain: string | null;
30+
software: string | null;
31+
emojis: Record<string, string> | null;
32+
remoteFollowUrl: URL | null;
33+
}
34+
35+
const WebFingerResult = builder.simpleObject("WebFingerResult", {
36+
description: "Result of looking up a remote follower via WebFinger, " +
37+
"including their ActivityPub profile and remote follow URL.",
38+
fields: (t) => ({
39+
preferredUsername: t.string({ nullable: true }),
40+
name: t.string({ nullable: true }),
41+
summary: t.string({ nullable: true }),
42+
url: t.field({ type: "URL", nullable: true }),
43+
iconUrl: t.field({ type: "URL", nullable: true }),
44+
handle: t.string({ nullable: true }),
45+
domain: t.string({ nullable: true }),
46+
software: t.string({ nullable: true }),
47+
emojis: t.field({ type: "JSON", nullable: true }),
48+
remoteFollowUrl: t.field({ type: "URL", nullable: true }),
49+
}),
50+
});
51+
52+
async function buildWebFingerResult(
53+
actorObject: FedifyActor,
54+
normalizedId: string,
55+
domain: string,
56+
remoteFollowUrl?: URL,
57+
): Promise<WebFingerResultData> {
58+
let software = "unknown";
59+
try {
60+
const nodeInfo = await getNodeInfo(`https://${domain}`);
61+
if (nodeInfo?.software?.name) {
62+
software = nodeInfo.software.name.toLowerCase();
63+
}
64+
} catch (error) {
65+
logger.warn("Failed to get nodeinfo for {domain}: {error}", {
66+
domain,
67+
error: error instanceof Error ? error.message : String(error),
68+
});
69+
}
70+
71+
let iconUrl: URL | null = null;
72+
const icon = await actorObject.getIcon();
73+
if (icon) {
74+
const raw = icon.url instanceof URL
75+
? icon.url.href
76+
: icon.url?.href?.href ?? null;
77+
if (raw) iconUrl = new URL(raw);
78+
}
79+
if (!iconUrl && actorObject.iconId) {
80+
iconUrl = new URL(actorObject.iconId.href);
81+
}
82+
83+
const emojis: Record<string, string> = {};
84+
try {
85+
for await (const tag of actorObject.getTags()) {
86+
if (!(tag instanceof vocab.Emoji)) continue;
87+
try {
88+
if (tag.name == null) continue;
89+
const emojiIcon = await tag.getIcon();
90+
if (
91+
emojiIcon?.url == null ||
92+
emojiIcon.url instanceof vocab.Link && emojiIcon.url.href == null
93+
) {
94+
continue;
95+
}
96+
const emojiName = tag.name.toString();
97+
const raw = emojiIcon.url instanceof vocab.Link
98+
? emojiIcon.url.href!.href
99+
: emojiIcon.url.href;
100+
const u = new URL(raw);
101+
if (
102+
(u.protocol === "http:" || u.protocol === "https:") &&
103+
!/[\'\"]/.test(raw)
104+
) {
105+
emojis[emojiName] = u.href;
106+
}
107+
} catch (error) {
108+
logger.warn("Failed to extract emoji {name}: {error}", {
109+
name: tag.name?.toString() ?? "unknown",
110+
error: error instanceof Error ? error.message : String(error),
111+
});
112+
}
113+
}
114+
} catch (error) {
115+
logger.warn("Failed to iterate tags: {error}", {
116+
error: error instanceof Error ? error.message : String(error),
117+
});
118+
}
119+
120+
return {
121+
preferredUsername: actorObject.preferredUsername?.toString() ?? null,
122+
name: actorObject.name?.toString() ?? null,
123+
summary: actorObject.summary?.toString() ?? null,
124+
url: actorObject.url instanceof URL
125+
? actorObject.url
126+
: actorObject.url?.toString()
127+
? new URL(actorObject.url.toString())
128+
: null,
129+
iconUrl,
130+
handle: normalizedId,
131+
domain,
132+
software,
133+
emojis: Object.keys(emojis).length > 0 ? emojis : null,
134+
remoteFollowUrl: remoteFollowUrl ?? null,
135+
};
136+
}
137+
138+
async function lookupRemoteFollowerImpl(
139+
ctx: UserContext,
140+
followerHandle: string,
141+
actorHandle: string,
142+
): Promise<WebFingerResultData | null> {
143+
const match = followerHandle.trim().match(FEDIVERSE_ID_REGEX);
144+
if (!match) return null;
145+
146+
const [, username, domain] = match;
147+
const normalizedId = `${username}@${domain}`;
148+
149+
logger.info("Looking up remote follower {followerHandle}", {
150+
followerHandle: normalizedId,
151+
});
152+
153+
const webfingerResult = await ctx.fedCtx.lookupWebFinger(
154+
`acct:${normalizedId}`,
155+
);
156+
if (webfingerResult == null) return null;
157+
158+
const activityPubLink = webfingerResult.links?.find((link) =>
159+
link.rel === "self" &&
160+
(link.type === "application/activity+json" ||
161+
link.type?.startsWith(
162+
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
163+
))
164+
) as WebfingerLink | undefined;
165+
166+
if (!activityPubLink?.href) return null;
167+
168+
const remoteFollowLink = webfingerResult.links?.find((link) =>
169+
link.rel === "http://ostatus.org/schema/1.0/subscribe"
170+
) as WebfingerLink | undefined;
171+
172+
let remoteFollowUrl: URL | undefined;
173+
if (remoteFollowLink?.template?.includes("{uri}")) {
174+
const candidate = remoteFollowLink.template.replace(
175+
"{uri}",
176+
encodeURIComponent(actorHandle),
177+
);
178+
try {
179+
const u = new URL(candidate);
180+
if (u.protocol === "http:" || u.protocol === "https:") {
181+
remoteFollowUrl = u;
182+
}
183+
} catch {
184+
// invalid URL template, ignore
185+
}
186+
}
187+
188+
try {
189+
const documentLoader = ctx.account == null
190+
? ctx.fedCtx.documentLoader
191+
: await ctx.fedCtx.getDocumentLoader({ identifier: ctx.account.id });
192+
193+
const actorObject = await ctx.fedCtx.lookupObject(activityPubLink.href, {
194+
documentLoader,
195+
});
196+
197+
if (!isActor(actorObject)) {
198+
throw new Error("Object is not an actor");
199+
}
200+
201+
return await buildWebFingerResult(
202+
actorObject,
203+
normalizedId,
204+
domain,
205+
remoteFollowUrl,
206+
);
207+
} catch (error) {
208+
logger.warn(
209+
"ActivityPub lookup failed, using fallback: {error}",
210+
{
211+
error: error instanceof Error ? error.message : String(error),
212+
},
213+
);
214+
215+
return {
216+
preferredUsername: username,
217+
name: username,
218+
summary: null,
219+
url: new URL(activityPubLink.href),
220+
iconUrl: null,
221+
handle: normalizedId,
222+
domain,
223+
software: "unknown",
224+
emojis: null,
225+
remoteFollowUrl: remoteFollowUrl ?? null,
226+
};
227+
}
228+
}
229+
230+
builder.queryFields((t) => ({
231+
lookupRemoteFollower: t.field({
232+
description: "Look up a remote Fediverse user by their handle, " +
233+
"fetching their ActivityPub profile and constructing " +
234+
"a remote follow URL for the given actor.",
235+
type: WebFingerResult,
236+
nullable: true,
237+
args: {
238+
followerHandle: t.arg.string({
239+
required: true,
240+
description:
241+
"The Fediverse handle of the remote user who wants to follow " +
242+
"(e.g., @user@mastodon.social).",
243+
}),
244+
actorId: t.arg.globalID({
245+
required: true,
246+
for: [Actor],
247+
description:
248+
"The Relay global ID of the Hackers' Pub actor to be followed.",
249+
}),
250+
},
251+
async resolve(_, args, ctx) {
252+
try {
253+
if (
254+
args.actorId.typename !== "Actor" ||
255+
!validateUuid(args.actorId.id)
256+
) {
257+
return null;
258+
}
259+
260+
const actor = await ctx.db.query.actorTable.findFirst({
261+
where: { id: args.actorId.id },
262+
columns: { handle: true },
263+
});
264+
265+
if (!actor) return null;
266+
267+
return await lookupRemoteFollowerImpl(
268+
ctx,
269+
args.followerHandle,
270+
actor.handle,
271+
);
272+
} catch (error) {
273+
logger.error("Remote follower lookup error: {error}", {
274+
error: error instanceof Error ? error.message : String(error),
275+
});
276+
return null;
277+
}
278+
},
279+
}),
280+
}));

0 commit comments

Comments
 (0)