···1717import { isBlob } from "@atcute/lexicons/interfaces";
1818import { validateClip, validateProfile, validateTag } from "./validator.js";
1919import xxhash from "xxhash-wasm";
2020+import { convertDidToString } from "./converters.js";
20212122const db = Database.getInstance().getDb();
2222-2323-/// Converts an ``At.DID`` type to a proper string, for type reasons.
2424-function convertDidToString(did: `did:${string}`): string {
2525- return did.toString();
2626-}
27232824/// Converts a microsecond Unix date to a Date object, for type reasons.
2925function convertMicroToDate(micro: number): Date {
+81
backend/src/network/converters.ts
···11+/*
22+ * clippr: a social bookmarking service for the AT Protocol
33+ * Copyright (c) 2025 clippr contributors.
44+ * SPDX-License-Identifier: AGPL-3.0-only
55+ */
66+77+import {
88+ CompositeDidDocumentResolver,
99+ DocumentNotFoundError,
1010+ FailedDocumentResolutionError,
1111+ HandleResolutionError,
1212+ ImproperDidError,
1313+ PlcDidDocumentResolver,
1414+ UnsupportedDidMethodError,
1515+ WebDidDocumentResolver,
1616+} from "@atcute/identity-resolver";
1717+1818+/// Converts an ``At.DID`` type to a proper string, for type reasons.
1919+export function convertDidToString(did: `did:${string}`): string {
2020+ return did.toString();
2121+}
2222+2323+export function convertStringToTypedDid(did: string) {
2424+ if (did.startsWith("did:plc:")) {
2525+ return did as `did:plc:${string}`;
2626+ } else if (did.startsWith("did:web:")) {
2727+ return did as `did:web:${string}`;
2828+ } else {
2929+ return did as `did:plc:${string}`;
3030+ }
3131+}
3232+3333+// TODO: Stop leeching off Bluesky's CDN and get the blob directly from the user's PDS
3434+export async function getUriFromBlobCid(
3535+ did: string,
3636+ cid: string,
3737+): Promise<string> {
3838+ return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`;
3939+}
4040+4141+export async function getHandleFromDid(did: string): Promise<string> {
4242+ const docResolver = new CompositeDidDocumentResolver({
4343+ methods: {
4444+ plc: new PlcDidDocumentResolver(),
4545+ web: new WebDidDocumentResolver(),
4646+ },
4747+ });
4848+4949+ let doc;
5050+ try {
5151+ doc = await docResolver.resolve(convertStringToTypedDid(did));
5252+ } catch (err) {
5353+ if (err instanceof DocumentNotFoundError) {
5454+ throw new Error("Document not found");
5555+ }
5656+ if (err instanceof UnsupportedDidMethodError) {
5757+ throw new Error("Unsupported did method");
5858+ }
5959+ if (err instanceof ImproperDidError) {
6060+ throw new Error("Improper did");
6161+ }
6262+ if (err instanceof FailedDocumentResolutionError) {
6363+ throw new Error("Failed document resolution");
6464+ }
6565+ if (err instanceof HandleResolutionError) {
6666+ throw new Error("Generic handle resolution error");
6767+ }
6868+ }
6969+7070+ if (!doc?.alsoKnownAs) {
7171+ throw new Error("No handles found");
7272+ }
7373+7474+ if (doc?.alsoKnownAs[0] === undefined) {
7575+ throw new Error("No handles found");
7676+ }
7777+7878+ return doc?.alsoKnownAs[0].substring(
7979+ doc?.alsoKnownAs[0].lastIndexOf("/" + 1),
8080+ );
8181+}
+44
backend/src/routes/openapi.ts
···11+/*
22+ * clippr: a social bookmarking service for the AT Protocol
33+ * Copyright (c) 2025 clippr contributors.
44+ * SPDX-License-Identifier: AGPL-3.0-only
55+ */
66+77+import { Hono } from "hono";
88+import { Scalar } from "@scalar/hono-api-reference";
99+import { createMarkdownFromOpenApi } from "@scalar/openapi-to-markdown";
1010+import { serveStatic } from "@hono/node-server/serve-static";
1111+import { readFileSync } from "fs";
1212+1313+const app = new Hono();
1414+1515+app.get(
1616+ "/api.json",
1717+ serveStatic({
1818+ path: "./static/api.json",
1919+ }),
2020+);
2121+2222+app.on(
2323+ "GET",
2424+ ["/scalar", "/docs"],
2525+ Scalar({
2626+ url: "/api.json",
2727+ theme: "bluePlanet",
2828+ pageTitle: "Clippr AppView API documentation",
2929+ layout: "modern",
3030+ hideClientButton: true,
3131+ forceDarkModeState: "dark",
3232+ }),
3333+);
3434+3535+/**
3636+ * Create a Markdown document for LLMs to read
3737+ * @see https://llmstxt.org/
3838+ */
3939+const markdown = await createMarkdownFromOpenApi(
4040+ readFileSync("./static/api.json", "utf-8"),
4141+);
4242+app.get("/llms.txt", (c) => c.text(markdown));
4343+4444+export default app;
+67
backend/src/routes/xrpc.ts
···11+/*
22+ * clippr: a social bookmarking service for the AT Protocol
33+ * Copyright (c) 2025 clippr contributors.
44+ * SPDX-License-Identifier: AGPL-3.0-only
55+ */
66+77+import { Hono } from "hono";
88+import { Database } from "../db/database.js";
99+import { usersTable } from "../db/schema.js";
1010+import { eq } from "drizzle-orm";
1111+import { getHandleFromDid, getUriFromBlobCid } from "../network/converters.js";
1212+1313+const app = new Hono();
1414+const db = Database.getInstance().getDb();
1515+1616+app.get("/social.clippr.actor.getProfile", async (c) => {
1717+ const did = c.req.query("did");
1818+ if (did === undefined || did.length === 0) {
1919+ return c.json(
2020+ {
2121+ error: "InvalidRequest",
2222+ message: "Error: Params must have the did property included",
2323+ },
2424+ 400,
2525+ );
2626+ }
2727+2828+ const profileSearch = await db
2929+ .selectDistinct()
3030+ .from(usersTable)
3131+ .where(eq(usersTable.did, did));
3232+3333+ if (profileSearch.length === 0) {
3434+ return c.json(
3535+ {
3636+ error: "InvalidRequest",
3737+ message: "Profile not found",
3838+ },
3939+ 400,
4040+ );
4141+ }
4242+4343+ const handle = await getHandleFromDid(did);
4444+ // TODO: Add placeholder avatar
4545+ const avatarCid: string =
4646+ profileSearch[0]?.avatar || "https://missing.avatar";
4747+ const avatar = await getUriFromBlobCid(did, avatarCid);
4848+4949+ // Right now we don't do de-duplication in the database, so we just take the
5050+ // first result and use that for our return call.
5151+ return c.json({
5252+ did: did,
5353+ handle: handle,
5454+ displayName: profileSearch[0]?.displayName || null,
5555+ avatar: avatar,
5656+ description: profileSearch[0]?.description || null,
5757+ createdAt: profileSearch[0]?.createdAt,
5858+ });
5959+});
6060+6161+app.get("/_health", async (c) => {
6262+ return c.json({
6363+ version: process.env.npm_package_version,
6464+ });
6565+});
6666+6767+export default app;
+4
backend/src/server.ts
···6677import { Hono } from "hono";
88import misc from "./routes/misc.js";
99+import xrpc from "./routes/xrpc.js";
910import Logger from "./logger.js";
1011import { logger } from "hono/logger";
1212+import openapi from "./routes/openapi.js";
11131214export function winstonLogger(message: string, ...rest: unknown[]) {
1315 Logger.http(message, ...rest);
···18201921// Link all routes up
2022app.route("/", misc);
2323+app.route("/", openapi);
2424+app.route("/xrpc", xrpc);
21252226export default app;
+145
backend/static/api.json
···11+{
22+ "openapi": "3.1.1",
33+ "servers": [
44+ {
55+ "url": "http://localhost:9090",
66+ "description": "Development server"
77+ },
88+ {
99+ "url": "https://clippr.social",
1010+ "description": "Production server"
1111+ }
1212+ ],
1313+ "info": {
1414+ "title": "Clippr AppView API",
1515+ "version": "0.1.0",
1616+ "description": "Official API reference documentation for Clippr's backend."
1717+ },
1818+ "tags": [
1919+ {
2020+ "name": "Profile",
2121+ "description": "API paths that relate to user profiles."
2222+ },
2323+ {
2424+ "name": "Misc",
2525+ "description": "API paths that don't fit into any other category."
2626+ }
2727+ ],
2828+ "paths": {
2929+ "/xrpc/social.clippr.actor.getProfile": {
3030+ "get": {
3131+ "summary": "Get a profile",
3232+ "description": "Get an user's profile based on their DID.",
3333+ "parameters": [
3434+ {
3535+ "name": "did",
3636+ "in": "query",
3737+ "description": "The DID of the account to get the profile record of.",
3838+ "required": true,
3939+ "content": {
4040+ "schema": {
4141+ "type": "string"
4242+ }
4343+ },
4444+ "deprecated": false,
4545+ "allowEmptyValue": false
4646+ }
4747+ ],
4848+ "responses": {
4949+ "200": {
5050+ "description": "OK",
5151+ "content": {
5252+ "application/json": {
5353+ "schema": {
5454+ "type": "object",
5555+ "properties": {
5656+ "did": {
5757+ "type": "string",
5858+ "description": "The decentralized identifier associated to the profile.",
5959+ "example": "did:plc:z72i7hdynmk6r22z27h6tvur"
6060+ },
6161+ "handle": {
6262+ "type": "string",
6363+ "description": "The handle associated to the profile.",
6464+ "example": "alice.bsky.social"
6565+ },
6666+ "displayName": {
6767+ "type": "string",
6868+ "description": "The display name associated to the profile.",
6969+ "example": "Alice"
7070+ },
7171+ "avatar": {
7272+ "type": "string",
7373+ "format": "uri",
7474+ "description": "A URI linking to an JPEG or PNG file.",
7575+ "example": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:b6bhzquz665p6bgjuaqz6xjp/bafkreicoqygyiqhhmjod4hvezo3besjyza24neldcxkz55keos3dg5mmj4@jpeg"
7676+ },
7777+ "description": {
7878+ "type": "string",
7979+ "description": "A biography associated to the profile.",
8080+ "example": "This is an example bio."
8181+ },
8282+ "createdAt": {
8383+ "type": "string",
8484+ "format": "date-time",
8585+ "description": "The date and time of the creation of the profile record."
8686+ }
8787+ }
8888+ }
8989+ }
9090+ }
9191+ },
9292+ "400": {
9393+ "description": "Bad Request",
9494+ "content": {
9595+ "application/json": {
9696+ "schema": {
9797+ "type": "object",
9898+ "properties": {
9999+ "error": {
100100+ "type": "string",
101101+ "description": "A general error code.",
102102+ "example": "InvalidRequest"
103103+ },
104104+ "message": {
105105+ "type": "string",
106106+ "description": "A detailed description of the error.",
107107+ "example": "Error: Params must have the did property included"
108108+ }
109109+ }
110110+ }
111111+ }
112112+ }
113113+ }
114114+ },
115115+ "tags": ["Profile"]
116116+ }
117117+ },
118118+ "/xrpc/_health": {
119119+ "get": {
120120+ "summary": "Health check",
121121+ "description": "Check the health of the server. If it is functioning properly, you will receive the server's version number.",
122122+ "responses": {
123123+ "200": {
124124+ "description": "OK",
125125+ "content": {
126126+ "application/json": {
127127+ "schema": {
128128+ "type": "object",
129129+ "properties": {
130130+ "version": {
131131+ "type": "string",
132132+ "description": "The version number of the AppView.",
133133+ "example": "0.1.0"
134134+ }
135135+ }
136136+ }
137137+ }
138138+ }
139139+ }
140140+ },
141141+ "tags": ["Misc"]
142142+ }
143143+ }
144144+ }
145145+}