GET /xrpc/app.bsky.actor.searchActorsTypeahead
typeahead.waow.tech
1import { CORS_HEADERS } from "./types";
2import { shouldHide } from "./moderation";
3
4export function clientIP(request: Request): string {
5 return request.headers.get("CF-Connecting-IP") || "unknown";
6}
7
8export function escHtml(s: string): string {
9 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
10}
11
12export function json(data: unknown, status = 200): Response {
13 return Response.json(data, { status, headers: CORS_HEADERS });
14}
15
16export function avatarUrl(did: string, cidOrUrl: string): string {
17 if (cidOrUrl.startsWith("https://")) return cidOrUrl;
18 return `https://cdn.bsky.app/img/avatar/plain/${did}/${cidOrUrl}`;
19}
20
21export function extractAvatarCid(url: string): string {
22 const match = url.match(/\/([^/]+?)(?:@[a-z]+)?$/);
23 return match?.[1] ?? '';
24}
25
26/** fetch profile directly from an actor's PDS — fallback for banned/suspended accounts */
27export async function fetchProfileFromPds(did: string, pds: string): Promise<any | null> {
28 try {
29 const res = await fetch(
30 `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`
31 );
32 if (!res.ok) return null;
33 const data: any = await res.json();
34 const val = data?.value;
35 if (!val) return null;
36
37 let avatar = '';
38 const cid = val.avatar?.ref?.$link || val.avatar?.ref?.['$link'];
39 if (cid) {
40 avatar = `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
41 }
42
43 return {
44 did,
45 handle: '',
46 displayName: val.displayName || '',
47 avatar,
48 labels: [],
49 createdAt: val.createdAt || '',
50 associated: {},
51 };
52 } catch {
53 return null;
54 }
55}
56
57/** strip zero/false fields from associated object to match bsky's typeahead shape */
58export function cleanAssociated(assoc: any): Record<string, unknown> {
59 if (!assoc || typeof assoc !== 'object') return {};
60 const clean: Record<string, unknown> = {};
61 for (const [k, v] of Object.entries(assoc)) {
62 if (v === 0 || v === false || v === null || v === undefined) continue;
63 clean[k] = v;
64 }
65 return clean;
66}
67
68/** extract the fields we store from a bsky profile response (getProfiles/getProfile/typeahead) */
69export interface ProfileFields {
70 handle: string;
71 displayName: string;
72 avatarCid: string;
73 labels: string;
74 hidden: number;
75 createdAt: string;
76 associated: string;
77}
78
79export function extractProfileFields(profile: any, override?: 'show' | 'hide' | null): ProfileFields {
80 let hidden = shouldHide(profile.labels) ? 1 : 0;
81 if (override === 'show') hidden = 0;
82 if (override === 'hide') hidden = 1;
83 const raw = profile.avatar || '';
84 // PDS blob URLs are already full URLs — store as-is; CDN URLs get CID extracted
85 const avatarCid = raw.startsWith('https://') && !raw.includes('cdn.bsky.app')
86 ? raw
87 : extractAvatarCid(raw);
88 return {
89 handle: profile.handle || '',
90 displayName: profile.displayName || '',
91 avatarCid,
92 labels: JSON.stringify(profile.labels || []),
93 hidden,
94 createdAt: profile.createdAt || '',
95 associated: JSON.stringify(cleanAssociated(profile.associated)),
96 };
97}
98
99/** strip anything that could break FTS5 syntax, preserving unicode letters/digits */
100export function sanitize(q: string): string {
101 return q.replace(/[^\p{L}\p{N}\s.-]/gu, "").trim();
102}
103
104export function html(body: string, extra?: Record<string, string> | number, status = 200): Response {
105 if (typeof extra === "number") { status = extra; extra = undefined; }
106 return new Response(body, {
107 status,
108 headers: { "Content-Type": "text/html; charset=utf-8", ...CORS_HEADERS, ...extra },
109 });
110}