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