social bookmarking for atproto

Merge branch 'main' into lexicons-extension

hexmani.ac b7a485ce 44269b40

verified
Changed files
+126 -16
.idea
dictionaries
backend
src
network
routes
static
+1
.idea/clippr.iml
··· 10 10 <excludeFolder url="file://$MODULE_DIR$/backend/build" /> 11 11 <excludeFolder url="file://$MODULE_DIR$/.idea/dataSources" /> 12 12 <excludeFolder url="file://$MODULE_DIR$/lexicons/dist" /> 13 + <excludeFolder url="file://$MODULE_DIR$/backend/logs" /> 13 14 </content> 14 15 <orderEntry type="inheritedJdk" /> 15 16 <orderEntry type="sourceFolder" forTests="false" />
+1
.idea/dictionaries/project.xml
··· 5 5 <w>appview</w> 6 6 <w>atcute</w> 7 7 <w>atproto</w> 8 + <w>bluesky</w> 8 9 <w>bsky</w> 9 10 <w>clippr</w> 10 11 <w>clipprjs</w>
+47 -1
backend/src/network/converters.ts
··· 14 14 UnsupportedDidMethodError, 15 15 WebDidDocumentResolver, 16 16 } from "@atcute/identity-resolver"; 17 + import { Client, simpleFetchHandler } from "@atcute/client"; 17 18 18 19 /// Converts an ``At.DID`` type to a proper string, for type reasons. 19 20 export function convertDidToString(did: `did:${string}`): string { ··· 30 31 } 31 32 } 32 33 33 - // TODO: Stop leeching off Bluesky's CDN and get the blob directly from the user's PDS 34 + // TODO: Stop leeching off the Bluesky CDN and get the blob directly from the user's PDS 35 + // Get a CDN URI from a blob's CID 34 36 export async function getUriFromBlobCid( 35 37 did: string, 36 38 cid: string, ··· 38 40 return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 39 41 } 40 42 43 + // Get a user's handle from their DID. DID method agnostic. 41 44 export async function getHandleFromDid(did: string): Promise<string> { 42 45 const docResolver = new CompositeDidDocumentResolver({ 43 46 methods: { ··· 79 82 doc?.alsoKnownAs[0].lastIndexOf("/" + 1), 80 83 ); 81 84 } 85 + 86 + // Get a user's DID from their handle. 87 + export async function getDidFromHandle(handle: string): Promise<string> { 88 + const handler = simpleFetchHandler({ 89 + service: "https://public.api.bsky.app", 90 + }); 91 + const rpc = new Client({ handler }); 92 + 93 + const { ok, data } = await rpc.get("com.atproto.identity.resolveHandle", { 94 + params: { 95 + handle: handle as `${string}.${string}`, 96 + }, 97 + }); 98 + 99 + if (!ok) { 100 + switch (data.error) { 101 + case "InvalidRequest": { 102 + throw new Error("InvalidRequest", { cause: data.message }); 103 + } 104 + case "AccountTakedown": { 105 + throw new Error("AccountTakedown", { cause: data.message }); 106 + } 107 + case "AccountDeactivated": { 108 + throw new Error("AccountDeactivated", { cause: data.message }); 109 + } 110 + default: { 111 + throw new Error(data.error, { cause: data.message }); 112 + } 113 + } 114 + } 115 + 116 + let actorDid; 117 + 118 + if (ok) { 119 + actorDid = data.did as string; 120 + } 121 + 122 + if (actorDid === undefined) { 123 + throw new Error("InvalidRequest"); 124 + } 125 + 126 + return actorDid; 127 + }
+73 -11
backend/src/routes/xrpc.ts
··· 8 8 import { Database } from "../db/database.js"; 9 9 import { usersTable } from "../db/schema.js"; 10 10 import { eq } from "drizzle-orm"; 11 - import { getHandleFromDid, getUriFromBlobCid } from "../network/converters.js"; 11 + import { 12 + getDidFromHandle, 13 + getHandleFromDid, 14 + getUriFromBlobCid, 15 + } from "../network/converters.js"; 12 16 13 17 const app = new Hono(); 14 18 const db = Database.getInstance().getDb(); 15 19 16 20 app.get("/social.clippr.actor.getProfile", async (c) => { 17 - const did = c.req.query("did"); 18 - if (did === undefined || did.length === 0) { 21 + const actor = c.req.query("actor"); 22 + if (actor === undefined || actor.trim().length === 0) { 19 23 return c.json( 20 24 { 21 25 error: "InvalidRequest", 22 - message: "Error: Params must have the did property included", 26 + message: "Error: Parameters must have the actor property included", 23 27 }, 24 28 400, 25 29 ); 26 30 } 27 31 32 + let actorDid = actor; 33 + 34 + if (!actor.startsWith("did:")) { 35 + try { 36 + actorDid = await getDidFromHandle(actor); 37 + } catch (e: unknown) { 38 + if (e instanceof Error) { 39 + return c.json( 40 + { 41 + error: e.message, 42 + message: e.cause, 43 + }, 44 + 400, 45 + ); 46 + } else { 47 + return c.json( 48 + { 49 + error: "InvalidRequest" as string, 50 + message: "Unknown error while resolving DID from handle" as string, 51 + }, 52 + 400, 53 + ); 54 + } 55 + } 56 + } 57 + 28 58 const profileSearch = await db 29 59 .selectDistinct() 30 60 .from(usersTable) 31 - .where(eq(usersTable.did, did)); 61 + .where(eq(usersTable.did, actorDid)); 32 62 33 63 if (profileSearch.length === 0) { 34 64 return c.json( ··· 40 70 ); 41 71 } 42 72 43 - const handle = await getHandleFromDid(did); 73 + let actorHandle; 74 + 75 + if (actor.startsWith("did:")) { 76 + try { 77 + actorHandle = await getHandleFromDid(actor); 78 + } catch (e: unknown) { 79 + if (e instanceof Error) { 80 + return c.json( 81 + { 82 + error: "InvalidRequest", 83 + message: `${e.message}`, 84 + }, 85 + 400, 86 + ); 87 + } else { 88 + return c.json( 89 + { 90 + error: "InvalidRequest" as string, 91 + message: "Unknown error while resolving handle from DID" as string, 92 + }, 93 + 400, 94 + ); 95 + } 96 + } 97 + 98 + if (actorHandle === undefined) { 99 + actorHandle = "invalid.handle"; 100 + } 101 + } else actorHandle = actor; 102 + 44 103 // TODO: Add placeholder avatar 45 104 const avatarCid: string = 46 105 profileSearch[0]?.avatar || "https://missing.avatar"; 47 - const avatar = await getUriFromBlobCid(did, avatarCid); 106 + let actorAvatar; 107 + if (avatarCid !== "https://missing.avatar") { 108 + actorAvatar = await getUriFromBlobCid(actorDid, avatarCid); 109 + } else actorAvatar = avatarCid; 48 110 49 111 // Right now we don't do de-duplication in the database, so we just take the 50 112 // first result and use that for our return call. 51 113 return c.json({ 52 - did: did, 53 - handle: handle, 54 - displayName: profileSearch[0]?.displayName || null, 55 - avatar: avatar, 114 + did: actorDid, 115 + handle: actorHandle, 116 + displayName: profileSearch[0]?.displayName, 117 + avatar: actorAvatar, 56 118 description: profileSearch[0]?.description || null, 57 119 createdAt: profileSearch[0]?.createdAt, 58 120 });
+4 -4
backend/static/api.json
··· 29 29 "/xrpc/social.clippr.actor.getProfile": { 30 30 "get": { 31 31 "summary": "Get a profile", 32 - "description": "Get an user's profile based on their DID.", 32 + "description": "Get an user's profile based on their DID or handle.", 33 33 "parameters": [ 34 34 { 35 - "name": "did", 35 + "name": "actor", 36 36 "in": "query", 37 - "description": "The DID of the account to get the profile record of.", 37 + "description": "The DID or handle of the account to get the profile record of.", 38 38 "required": true, 39 39 "content": { 40 40 "schema": { ··· 104 104 "message": { 105 105 "type": "string", 106 106 "description": "A detailed description of the error.", 107 - "example": "Error: Params must have the did property included" 107 + "example": "Error: Parameters must have the actor property included" 108 108 } 109 109 } 110 110 }