[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";
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}