[WIP] A (somewhat barebones) atproto app for creating custom sites without hosting!
at 7e11e6502dd3cf81549f085bcf1a1368823ffdca 165 lines 5.1 kB view raw
1import { LibSQLDatabase } from "drizzle-orm/libsql"; 2import { Client } from "@libsql/client"; 3import * as schema from "./db/schema.ts"; 4import { 5 CompositeDidDocumentResolver, 6 PlcDidDocumentResolver, 7 WebDidDocumentResolver, 8} from "@atcute/identity-resolver"; 9import { FetchHandler, SimpleFetchHandlerOptions } from "@atcute/client"; 10import { safeFetchWrap } from "@atproto-labs/fetch-node"; 11 12export type db = LibSQLDatabase<typeof schema> & { 13 $client: Client; 14}; 15 16export const ROOT_DOMAIN = Deno.env.get("HOSTNAME") || "localhost"; 17export const PORT = Number(Deno.env.get("PORT")) || 80; 18export const MAX_SITE_SIZE = Number(Deno.env.get("MAX_SITE_SIZE")) || 250000000; 19 20export const SUBDOMAIN_REGEX = new RegExp(`.+(?=\\.${ROOT_DOMAIN}$)`, "gm"); 21 22export function clearCookies(req: Request): Headers { 23 const cookie_header = req.headers.get("Cookie"); 24 // cookies are unset so return empty headers 25 if (!cookie_header) return new Headers(); 26 // get each kv pair and extract the key 27 const cookies = cookie_header.split("; ").map((x) => x.split("=")[0]); 28 const head = new Headers(); 29 for (const key of cookies) { 30 // max-age <= 0 means instant expiry .: deleted instantly 31 head.append("Set-Cookie", `${key}=; Max-Age=-1`); 32 } 33 return head; 34} 35 36const docResolver = new CompositeDidDocumentResolver({ 37 methods: { 38 plc: new PlcDidDocumentResolver(), 39 web: new WebDidDocumentResolver(), 40 }, 41}); 42 43export const fetchHandler: ({ 44 service, 45}: SimpleFetchHandlerOptions) => FetchHandler = ({ 46 service, 47}: SimpleFetchHandlerOptions): FetchHandler => { 48 const safeFetch = safeFetchWrap(); 49 return async (pathname, init) => { 50 const url = new URL(pathname, service); 51 return await safeFetch(url, init); 52 }; 53}; 54 55export async function getPds( 56 did: `did:${"plc" | "web"}:${string}` 57): Promise<string | undefined> { 58 try { 59 const doc = await docResolver.resolve(did); 60 const pds = doc.service?.filter( 61 (x) => 62 x.id.endsWith("#atproto_pds") && x.type === "AtprotoPersonalDataServer" 63 )[0].serviceEndpoint; 64 return typeof pds === "string" ? pds : undefined; 65 } catch { 66 return undefined; 67 } 68} 69 70export function isDid(did: unknown): did is `did:${"plc" | "web"}:${string}` { 71 return ( 72 typeof did === "string" && 73 (did.startsWith("did:web:") || did.startsWith("did:plc:")) 74 ); 75} 76 77/** 78 * given a valid url path string containing 79 * - `/` for seperating characters 80 * - a-zA-Z0-9 `-._~` as unreserved 81 * - `!$&'()*+,;=` as reserved but valid in paths 82 * - `:@` as neither reserved or unreserved but valid in paths 83 * - %XX where X are hex digits for percent encoding 84 * 85 * we need to consistently and bidirectionally convert it into a string containing the characters A-Z, a-z, 0-9, `.-_:~` for an atproto rkey 86 * A-Z a-z 0-9 are covered easily 87 * we can also take -._~ as they are also unreserved 88 * leaving : as a valid rkey character which looks nice for encoding 89 * the uppercase versions MUST be used to prevent ambiguity 90 * a colon which isnt followed by a valid character is an invalid rkey and should be ignored 91 * - `/` `::` 92 * - `%` `:~` 93 * - `!` `:21` 94 * - `$` `:24` 95 * - `&` `:26` 96 * - `'` `:27` 97 * - `(` `:28` 98 * - `)` `:29` 99 * - `*` `:2A` 100 * - `+` `:2B` 101 * - `,` `:2C` 102 * - `:` `:3A` 103 * - `;` `:3B` 104 * - `=` `:3D` 105 * - `@` `:40` 106 * @returns {string | undefined} undefined when input is invalid 107 */ 108export function urlToRkey(url: string): string | undefined { 109 // contains 0-9A-Za-z + special valid chars and / seperator. also can contain %XX with XX being hex 110 if ( 111 !url.match(/^(?:[a-zA-Z0-9/\-._~!$&'()*+,;=:@]|(?:%[0-9a-fA-F]{2}))*$/gm) 112 ) { 113 return; 114 } 115 return ( 116 url 117 // : replace is hoisted so it doesnt replace colons from elsewhere 118 .replaceAll(":", ":3A") 119 .replaceAll("/", "::") 120 .replaceAll("%", ":~") 121 .replaceAll("!", ":21") 122 .replaceAll("$", ":24") 123 .replaceAll("&", ":26") 124 .replaceAll("'", ":27") 125 .replaceAll("(", ":28") 126 .replaceAll(")", ":29") 127 .replaceAll("*", ":2A") 128 .replaceAll("+", ":2B") 129 .replaceAll(",", ":2C") 130 .replaceAll(";", ":3B") 131 .replaceAll("=", ":3D") 132 .replaceAll("@", ":40") 133 ); 134} 135 136/** 137 * @see {@link urlToRkey} for rkey <=> url conversion syntax 138 * @returns {string | undefined} undefined when input is invalid 139 */ 140export function rkeyToUrl(rkey: string): string | undefined { 141 // contains 0-9A-Za-z .-_~ 142 // or any valid colon escape sequence 143 if ( 144 !rkey.match( 145 /^(?:[A-Za-z0-9.\-_~]|(?:::)|(?::~)|(?::21)|(?::24)|(?::26)|(?::27)|(?::28)|(?::29)|(?::2A)|(?::2B)|(?::2C)|(?::3A)|(?::3B)|(?::3D)|(?::40))*$/gm 146 ) 147 ) 148 return; 149 return rkey 150 .replaceAll("::", "/") 151 .replaceAll(":~", "%") 152 .replaceAll(":21", "!") 153 .replaceAll(":24", "$") 154 .replaceAll(":26", "&") 155 .replaceAll(":27", "'") 156 .replaceAll(":28", "(") 157 .replaceAll(":29", ")") 158 .replaceAll(":2A", "*") 159 .replaceAll(":2B", "+") 160 .replaceAll(":2C", ",") 161 .replaceAll(":3A", ":") 162 .replaceAll(":3B", ";") 163 .replaceAll(":3D", "=") 164 .replaceAll(":40", "@"); 165}