social bookmarking for atproto

[backend] Add service proxying support and nodeinfo stats (cuz lol)

hexmani.ac dc8f2047 9513b287

verified
+261 -7
+14 -4
backend/config.example.toml
··· 1 1 ## This is a configuration file for Clippr. 2 2 ## Please copy to "config.example.toml" before starting the server, 3 3 ## otherwise it will not start. Modify as necessary. 4 + ## All keys that are listed are expected to be included in the config, even if not explicity declared as required. 4 5 6 + ## Where the server is broadcasted to. 5 7 hostname = "localhost" 6 8 port = 9090 9 + webDomain = "https://localhost" 7 10 8 11 ## For most deployments, you will want to keep the log level at "info". 9 - ## If you are debugging the software, move it down to "debug". 12 + ## If you are a developer, move it down to "debug" for more information. 10 13 ## 11 14 ## List of all recognized log levels, sorted by importance: 12 15 ## "error" - Critical errors, typically leading to a crash or a degraded state. ··· 16 19 ## "verbose" - Details more of the server's operations. 17 20 ## "debug" - Information for developers, meant for debugging. 18 21 ## "silly" - Anything goes. 19 - log-level = "info" 22 + logLevel = "info" 20 23 21 24 ## How the SQLite database is stored. 22 - ## For testing, you can store the database in memory with ":memory:" 25 + ## For experimenting, you can store the database in memory with ":memory:" 23 26 [database] 24 27 ## Paths can be used here. 25 28 name = "file:clippr.db" 26 29 27 30 ## How the server interacts with the ATproto network. 28 31 [network] 29 - firehose = "jetstream1.us-east.bsky.network" 32 + ## What Jetstream instance to use for receiving content from the network. 33 + firehose = "jetstream1.us-east.bsky.network" 34 + ## What DID to use for service proxying. This should be the domain that the API is accessible from. 35 + ## Default: "did:web:localhost%3A9090" 36 + serviceDid = "did:web:localhost%3A9090" 37 + ## A multibase public key to use for signing in the service proxy DID, formatted as "did:key:[key]". Required. 38 + ## Do not use the default key outside of testing. 39 + didSigningKey = "did:key:zDnaeuuRRQuYp4S76LwosLhHbpU1HJcg6S5oJAUHmdZLVdLM5"
+36
backend/src/api/stats.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import type { AppviewStatsQuery } from "./types.js"; 8 + import { Database } from "../db/database.js"; 9 + import { clipsTable, tagsTable, usersTable } from "../db/schema.js"; 10 + import { count } from "drizzle-orm"; 11 + 12 + const db = Database.getInstance().getDb(); 13 + 14 + export async function getStats(): Promise<AppviewStatsQuery> { 15 + const clipCount = await db.select({ count: count() }).from(clipsTable); 16 + const tagCount = await db.select({ count: count() }).from(tagsTable); 17 + const userCount = await db.select({ count: count() }).from(usersTable); 18 + 19 + if ( 20 + clipCount[0] === undefined || 21 + tagCount[0] === undefined || 22 + userCount[0] === undefined 23 + ) { 24 + return { 25 + knownClips: 0, 26 + knownTags: 0, 27 + knownUsers: 0, 28 + }; 29 + } 30 + 31 + return { 32 + knownClips: clipCount[0].count, 33 + knownTags: tagCount[0].count, 34 + knownUsers: userCount[0].count, 35 + }; 36 + }
+6
backend/src/api/types.ts
··· 36 36 collection: string; 37 37 recordKey: string; 38 38 } 39 + 40 + export interface AppviewStatsQuery { 41 + knownUsers: number; 42 + knownClips: number; 43 + knownTags: number; 44 + }
+2 -2
backend/src/logger.ts
··· 7 7 import { createLogger, format, transports } from "winston"; 8 8 9 9 // TODO: I can't seem to actually get the config setting for the log level yet. 10 - const loglevel = "debug"; 10 + const logLevel = "debug"; 11 11 12 12 const Logger = createLogger({ 13 - level: loglevel, 13 + level: logLevel, 14 14 transports: [ 15 15 new transports.Console({ 16 16 format: format.combine(
+200
backend/src/routes/well-known.ts
··· 1 + /* 2 + * clippr: a social bookmarking service for the AT Protocol 3 + * Copyright (c) 2025 clippr contributors. 4 + * SPDX-License-Identifier: AGPL-3.0-only 5 + */ 6 + 7 + import { Hono } from "hono"; 8 + import { Config } from "../config.js"; 9 + import { getStats } from "../api/stats.js"; 10 + 11 + const app = new Hono(); 12 + const config = Config.getInstance(); 13 + 14 + const serviceDid: string = 15 + config.get("network.serviceDid") || 16 + `did:web:${config.get("hostname")}%3A${config.get("port")}/`; 17 + const signingKey: string | unknown = config.get("network.didSigningKey"); 18 + let webDomain: string = 19 + config.get("network.webDomain") || 20 + `http://${config.get("hostname")}:${config.get("port")}`; 21 + 22 + if (!webDomain.startsWith("http://") || !webDomain.startsWith("https://")) { 23 + webDomain = `http://${webDomain.replace(/^https?:\/\//, "")}`; 24 + } 25 + 26 + app.get("/.well-known/nodeinfo", (c) => { 27 + return c.json({ 28 + links: [ 29 + { 30 + rel: "https://nodeinfo.diaspora.software/ns/schema/2.2", 31 + href: `${webDomain}/nodeinfo/2.2`, 32 + }, 33 + { 34 + rel: "https://nodeinfo.diaspora.software/ns/schema/2.1", 35 + href: `${webDomain}/nodeinfo/2.1`, 36 + }, 37 + { 38 + rel: "https://nodeinfo.diaspora.software/ns/schema/2.0", 39 + href: `${webDomain}/nodeinfo/2.0`, 40 + }, 41 + ], 42 + }); 43 + }); 44 + 45 + app.get("/nodeinfo/2.2", async (c) => { 46 + const appviewStats = await getStats(); 47 + 48 + return c.json({ 49 + version: "2.2", 50 + software: { 51 + name: "clippr", 52 + version: `${process.env.npm_package_version}`, 53 + repository: "https://tangled.sh/@hexmani.ac/clippr", 54 + homepage: "https://clippr.social", 55 + }, 56 + instance: { 57 + name: "clippr", 58 + description: "A social bookmarking service for the AT Protocol", 59 + }, 60 + openRegistrations: true, 61 + protocols: ["atprotocol"], 62 + services: { 63 + inbound: [], 64 + outbound: [], 65 + }, 66 + usage: { 67 + users: { 68 + total: appviewStats.knownUsers, 69 + }, 70 + }, 71 + metadata: { 72 + clips: appviewStats.knownClips, 73 + tags: appviewStats.knownTags, 74 + }, 75 + }); 76 + }); 77 + 78 + app.get("/nodeinfo/2.1", async (c) => { 79 + const appviewStats = await getStats(); 80 + 81 + return c.json({ 82 + version: "2.1", 83 + software: { 84 + name: "clippr", 85 + version: `${process.env.npm_package_version}`, 86 + repository: "https://tangled.sh/@hexmani.ac/clippr", 87 + homepage: "https://clippr.social", 88 + }, 89 + openRegistrations: true, 90 + protocols: ["atprotocol"], 91 + services: { 92 + inbound: [], 93 + outbound: [], 94 + }, 95 + usage: { 96 + users: { 97 + total: appviewStats.knownUsers, 98 + }, 99 + }, 100 + metadata: { 101 + clips: appviewStats.knownClips, 102 + tags: appviewStats.knownTags, 103 + }, 104 + }); 105 + }); 106 + 107 + app.get("/nodeinfo/2.0", async (c) => { 108 + const appviewStats = await getStats(); 109 + 110 + return c.json({ 111 + version: "2.0", 112 + software: { 113 + name: "clippr", 114 + version: `${process.env.npm_package_version}`, 115 + }, 116 + openRegistrations: true, 117 + protocols: ["atprotocol"], 118 + services: { 119 + inbound: [], 120 + outbound: [], 121 + }, 122 + usage: { 123 + users: { 124 + total: appviewStats.knownUsers, 125 + }, 126 + }, 127 + metadata: { 128 + clips: appviewStats.knownClips, 129 + tags: appviewStats.knownTags, 130 + }, 131 + }); 132 + }); 133 + 134 + app.get("/.well-known/did.json", (c) => { 135 + if (serviceDid === undefined) { 136 + return c.json( 137 + { 138 + error: "Internal Server Error", 139 + message: "Server is not properly configured", 140 + }, 141 + 500, 142 + ); 143 + } 144 + 145 + if (signingKey === undefined) { 146 + return c.json( 147 + { 148 + error: "Internal Server Error", 149 + message: "Server is not properly configured", 150 + }, 151 + 500, 152 + ); 153 + } 154 + 155 + if (typeof signingKey !== "string") { 156 + return c.json( 157 + { 158 + error: "Internal Server Error", 159 + message: "Server is not properly configured", 160 + }, 161 + 500, 162 + ); 163 + } 164 + 165 + if (!signingKey.replace("did:key:", "").startsWith("z")) { 166 + console.log(signingKey); 167 + return c.json( 168 + { 169 + error: "Internal Server Error", 170 + message: "Server is not properly configured", 171 + }, 172 + 500, 173 + ); 174 + } 175 + 176 + return c.json({ 177 + "@context": [ 178 + "https://www.w3.org/ns/did/v1", 179 + "https://w3id.org/security/multikey/v1", 180 + ], 181 + id: serviceDid, 182 + verificationMethod: [ 183 + { 184 + id: `${serviceDid}#atproto`, 185 + type: "Multikey", 186 + controller: serviceDid, 187 + publicKeyMultibase: `${signingKey.replace("did:key:", "")}`, 188 + }, 189 + ], 190 + services: [ 191 + { 192 + id: "#clippr_appview", 193 + type: "ClipprAppView", 194 + serviceEndpoint: `${webDomain}`, 195 + }, 196 + ], 197 + }); 198 + }); 199 + 200 + export default app;
+3 -1
backend/src/server.ts
··· 6 6 7 7 import { Hono } from "hono"; 8 8 import misc from "./routes/misc.js"; 9 + import openapi from "./routes/openapi.js"; 9 10 import xrpc from "./routes/xrpc.js"; 11 + import wellKnown from "./routes/well-known.js"; 10 12 import Logger from "./logger.js"; 11 13 import { logger } from "hono/logger"; 12 - import openapi from "./routes/openapi.js"; 13 14 import { cors } from "hono/cors"; 14 15 15 16 export function winstonLogger(message: string, ...rest: unknown[]) { ··· 23 24 // Link all routes up 24 25 app.route("/", misc); 25 26 app.route("/", openapi); 27 + app.route("/", wellKnown); 26 28 app.route("/xrpc", xrpc); 27 29 28 30 export default app;