[WIP] A (somewhat barebones) atproto app for creating custom sites without hosting!

server: major refactor

- subdomain specific code (index handler and user
site handler) are now in /routes
- constants from index.ts are now defined in utils.ts
so they can be used by other files
- rkey <=> url functions are now in utils.ts
- new and old error/404 pages which were plaintext now
look nicer and have the atcities ascii logo
- designed the atcities logo. added in ascii
TODO: design and add a proper svg
- remove logic related to resolving pdses and fetching blobs
this will return in backfilling

+1 -1
server/deno.json
··· 1 1 { 2 2 "tasks": { 3 - "dev": "deno run --watch --allow-net --allow-env --allow-sys --allow-read=/usr/bin/ldd,./blobs --allow-write=./blobs --allow-ffi --env-file src/index.ts", 3 + "dev": "deno run --watch --allow-net --allow-env --allow-sys --allow-read=/usr/bin/ldd,./blobs,./src --allow-write=./blobs --allow-ffi --env-file src/index.ts", 4 4 "lexgen": "deno run --allow-env --allow-sys --allow-read=.. --allow-write=./src/lexicons --no-prompt @atcute/lex-cli generate -c ./lex.config.js && cat ./src/lexicons/index.ts | sed \"s/.js/.ts/\" > ./src/lexicons/index.ts", 5 5 "dk": "deno run -A --node-modules-dir npm:drizzle-kit" 6 6 },
+1 -1
server/src/backfill.ts
··· 1 1 import { drizzle } from "drizzle-orm/libsql"; 2 2 import { routes } from "./db/schema.ts"; 3 3 import * as schema from "./db/schema.ts"; 4 - import { db as db_type } from "./types.ts"; 4 + import { db as db_type } from "./utils.ts"; 5 5 6 6 const db = drizzle<typeof schema>(Deno.env.get("DB_FILE_NAME")!); 7 7
+19 -46
server/src/index.ts
··· 1 - import root from "./root.ts"; 2 - import user from "./user.ts"; 1 + import root from "./routes/root.ts"; 2 + import user from "./routes/user.ts"; 3 3 import backfill from "./backfill.ts"; 4 - import { routes } from "./db/schema.ts"; 5 - 6 - const ROOT_DOMAIN = Deno.env.get("HOSTNAME") || "localhost"; 7 - const PORT = Number(Deno.env.get("PORT")) || 80; 8 - 9 - const SUBDOMAIN_REGEX = new RegExp(`.+(?=\\.${ROOT_DOMAIN}$)`, "gm"); 10 - 11 - function clearCookies(req: Request): Headers { 12 - const cookie_header = req.headers.get("Cookie"); 13 - // cookies are unset so return empty headers 14 - if (!cookie_header) return new Headers(); 15 - // get each kv pair and extract the key 16 - const cookies = cookie_header.split("; ").map((x) => x.split("=")[0]); 17 - const head = new Headers(); 18 - for (const key of cookies) { 19 - // max-age <= 0 means instant expiry .: deleted instantly 20 - head.append("Set-Cookie", `${key}=; Max-Age=-1`); 21 - } 22 - return head; 23 - } 4 + import { PORT, ROOT_DOMAIN, SUBDOMAIN_REGEX, clearCookies } from "./utils.ts"; 24 5 25 6 const db = await backfill(); 26 7 ··· 45 26 // did:plc example: `sjkdgfjk.did-plc.ROOT_DOMAIN` 46 27 // did:web example: `vielle.dev.did-web.ROOT_DOMAIN 47 28 // last segment must be did-plc or did-web 48 - if (subdomain.at(-1)?.match(/^did-(web|plc)+$/gm)) { 49 - const res = await user(db, req, { 50 - did: `did:${subdomain.at(-1) === "did-plc" ? "plc" : "web"}:${subdomain.slice(0, -1).join(".")}`, 51 - }); 52 - return new Response(res.body, { 53 - ...res, 54 - headers: { 55 - ...Array.from(res.headers.entries()).reduce( 56 - (acc, [k, v]) => ({ ...acc, [k]: v }), 57 - {} 58 - ), 59 - ...clearCookies(req), 60 - }, 61 - }); 62 - } 63 - 29 + const isDidSubdomain = !!subdomain.at(-1)?.match(/^did-(web|plc)+$/gm); 64 30 // ex: vielle.dev.ROOT_DOMAIN 65 31 // cannot contain hyphen in top level domain 66 - if (!subdomain.at(-1)?.startsWith("did-") && subdomain.length > 1) { 67 - const res = await user(db, req, { 68 - handle: subdomain.join(".") as `${string}.${string}`, 69 - }); 32 + const isHandleSubdomain = !isDidSubdomain && subdomain.length > 1; 33 + 34 + if (isDidSubdomain || isHandleSubdomain) { 35 + const res: Response = await user( 36 + db, 37 + req, 38 + isDidSubdomain 39 + ? { 40 + did: `did:${subdomain.at(-1) === "did-plc" ? "plc" : "web"}:${subdomain.slice(0, -1).join(".")}`, 41 + } 42 + : { 43 + handle: subdomain.join(".") as `${string}.${string}`, 44 + } 45 + ); 70 46 return new Response(res.body, { 71 47 ...res, 72 48 headers: { 73 - ...Array.from(res.headers.entries()).reduce( 74 - (acc, [k, v]) => ({ ...acc, [k]: v }), 75 - {} 76 - ), 49 + ...res.headers, 77 50 ...clearCookies(req), 78 51 }, 79 52 });
-13
server/src/root.ts
··· 1 - import index from "./www/index.html" with { type: "text" }; 2 - 3 - export default function (req: Request) { 4 - if (new URL(req.url).pathname === "/") 5 - return new Response(index, { 6 - headers: { 7 - "Content-Type": "text/html; charset=utf8", 8 - }, 9 - }); 10 - return new Response("404", { 11 - status: 404, 12 - }); 13 - }
+4
server/src/routes/ascii.txt
··· 1 + , | 2 + |\ -+-- 3 + |_\ | 4 + \\ \_/ ://cities
+38
server/src/routes/root.ts
··· 1 + import { ROOT_DOMAIN } from "../utils.ts"; 2 + import index from "../www/index.html#denoRawImport=text.ts" with { type: "text" }; 3 + import ascii from "./ascii.txt" with { type: "text" }; 4 + 5 + function route( 6 + path: string, 7 + callback: (req: Request) => Response 8 + ): { 9 + test: (path: string) => boolean; 10 + fn: (req: Request) => Response; 11 + } { 12 + const pattern = new URLPattern(path, `https://www.${ROOT_DOMAIN}`); 13 + return { 14 + test: (path) => pattern.test(path, `https://www.${ROOT_DOMAIN}`), 15 + fn: callback, 16 + }; 17 + } 18 + 19 + const routes = [ 20 + route( 21 + "/", 22 + () => 23 + new Response(index, { 24 + headers: { 25 + "Content-Type": "text/html; charset=utf8", 26 + }, 27 + }) 28 + ), 29 + route("/ascii.txt", () => new Response(ascii)), 30 + ]; 31 + 32 + export default function (req: Request) { 33 + const path = new URL(req.url).pathname; 34 + for (const r of routes) if (r.test(path)) return r.fn(req); 35 + return new Response("404", { 36 + status: 404, 37 + }); 38 + }
+98
server/src/routes/user.ts
··· 1 + /// <reference types="@atcute/atproto" /> 2 + import { 3 + CompositeHandleResolver, 4 + DohJsonHandleResolver, 5 + WellKnownHandleResolver, 6 + } from "@atcute/identity-resolver"; 7 + import { and, eq } from "drizzle-orm"; 8 + import { routes } from "../db/schema.ts"; 9 + import { ROOT_DOMAIN, type db } from "../utils.ts"; 10 + import ascii from "./ascii.txt" with { type: "text" }; 11 + 12 + const handleResolver = new CompositeHandleResolver({ 13 + strategy: "race", 14 + methods: { 15 + dns: new DohJsonHandleResolver({ 16 + dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 17 + }), 18 + http: new WellKnownHandleResolver(), 19 + }, 20 + }); 21 + 22 + export default async function ( 23 + db: db, 24 + req: Request, 25 + user: 26 + | { handle: `${string}.${string}` } 27 + | { did: `did:plc:${string}` | `did:web:${string}` } 28 + ): Promise<Response> { 29 + // if handle: resolve did 30 + let did: `did:${"plc" | "web"}:${string}`; 31 + if ("handle" in user) { 32 + try { 33 + // cast bc i know it will be `string.string` 34 + did = await handleResolver.resolve(user.handle); 35 + } catch { 36 + return new Response( 37 + `${ascii} 38 + 39 + This handle 40 + `, 41 + { 42 + status: 500, 43 + statusText: "Internal Server Error", 44 + } 45 + ); 46 + } 47 + } else did = user.did; 48 + 49 + // look up in db 50 + const db_res = 51 + ( 52 + await db 53 + .select() 54 + .from(routes) 55 + .where( 56 + and( 57 + eq(routes.did, did), 58 + eq(routes.url_route, new URL(req.url).pathname) 59 + ) 60 + ) 61 + ).at(0) ?? 62 + ( 63 + await db 64 + .select() 65 + .from(routes) 66 + .where(and(eq(routes.did, did), eq(routes.url_route, "404"))) 67 + ).at(0); 68 + 69 + if (!db_res) { 70 + return new Response(`${ascii} 71 + 72 + 404: The user has no atcities site or is missing a 404 page. 73 + 74 + If you're the owner of this account, head to https://atcities.dev/ for more information. 75 + The index of this account is at https://${"handle" in user ? user.handle : user.did.split(":").at(-1) + ".did-" + user.did.split(":").at(1)}.${ROOT_DOMAIN}/ 76 + `); 77 + } 78 + try { 79 + const file = await Deno.readFile( 80 + `./blobs/${db_res.did}/${db_res.blob_cid}` 81 + ); 82 + return new Response(file, { 83 + headers: { 84 + "Content-Type": db_res.mime, 85 + }, 86 + }); 87 + } catch { 88 + return new Response(`${ascii} 89 + 90 + This page isn't stored in the CDN. 91 + TODO: 92 + Fetch the content from the pds, 93 + check its hash, 94 + serve it if not known as illegal, 95 + store in cdn if doesnt take account over fs limit 96 + `); 97 + } 98 + }
-7
server/src/types.ts
··· 1 - import { LibSQLDatabase } from "drizzle-orm/libsql"; 2 - import { Client } from "@libsql/client"; 3 - import * as schema from "./db/schema.ts"; 4 - 5 - export type db = LibSQLDatabase<typeof schema> & { 6 - $client: Client; 7 - };
-342
server/src/user.ts
··· 1 - /// <reference types="@atcute/atproto" /> 2 - import { Client, simpleFetchHandler } from "@atcute/client"; 3 - import { is } from "@atcute/lexicons"; 4 - import { 5 - CompositeHandleResolver, 6 - DohJsonHandleResolver, 7 - WellKnownHandleResolver, 8 - CompositeDidDocumentResolver, 9 - PlcDidDocumentResolver, 10 - WebDidDocumentResolver, 11 - } from "@atcute/identity-resolver"; 12 - import { DevAtcitiesRoute } from "./lexicons/index.ts"; 13 - import { db } from "./types.ts"; 14 - import { and, eq } from "drizzle-orm"; 15 - import { routes } from "./db/schema.ts"; 16 - 17 - const handleResolver = new CompositeHandleResolver({ 18 - strategy: "race", 19 - methods: { 20 - dns: new DohJsonHandleResolver({ 21 - dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", 22 - }), 23 - http: new WellKnownHandleResolver(), 24 - }, 25 - }); 26 - 27 - const docResolver = new CompositeDidDocumentResolver({ 28 - methods: { 29 - plc: new PlcDidDocumentResolver(), 30 - web: new WebDidDocumentResolver(), 31 - }, 32 - }); 33 - 34 - /** 35 - * given a valid url path string containing 36 - * - `/` for seperating characters 37 - * - a-zA-Z0-9 `-._~` as unreserved 38 - * - `!$&'()*+,;=` as reserved but valid in paths 39 - * - `:@` as neither reserved or unreserved but valid in paths 40 - * - %XX where X are hex digits for percent encoding 41 - * 42 - * we need to consistently and bidirectionally convert it into a string containing the characters A-Z, a-z, 0-9, `.-_:~` for an atproto rkey 43 - * A-Z a-z 0-9 are covered easily 44 - * we can also take -._~ as they are also unreserved 45 - * leaving : as a valid rkey character which looks nice for encoding 46 - * the uppercase versions MUST be used to prevent ambiguity 47 - * a colon which isnt followed by a valid character is an invalid rkey and should be ignored 48 - * - `/` `::` 49 - * - `%` `:~` 50 - * - `!` `:21` 51 - * - `$` `:24` 52 - * - `&` `:26` 53 - * - `'` `:27` 54 - * - `(` `:28` 55 - * - `)` `:29` 56 - * - `*` `:2A` 57 - * - `+` `:2B` 58 - * - `,` `:2C` 59 - * - `:` `:3A` 60 - * - `;` `:3B` 61 - * - `=` `:3D` 62 - * - `@` `:40` 63 - * @returns {string | undefined} undefined when input is invalid 64 - */ 65 - function urlToRkey(url: string): string | undefined { 66 - // contains 0-9A-Za-z + special valid chars and / seperator. also can contain %XX with XX being hex 67 - if (!url.match(/^([a-zA-Z0-9/\-._~!$&'()*+,;=:@]|(%[0-9a-fA-F]{2}))*$/gm)) 68 - return; 69 - return ( 70 - url 71 - // : replace is hoisted so it doesnt replace colons from elsewhere 72 - .replaceAll(":", ":3A") 73 - .replaceAll("/", "::") 74 - .replaceAll("%", ":~") 75 - .replaceAll("!", ":21") 76 - .replaceAll("$", ":24") 77 - .replaceAll("&", ":26") 78 - .replaceAll("'", ":27") 79 - .replaceAll("(", ":28") 80 - .replaceAll(")", ":29") 81 - .replaceAll("*", ":2A") 82 - .replaceAll("+", ":2B") 83 - .replaceAll(",", ":2C") 84 - .replaceAll(";", ":3B") 85 - .replaceAll("=", ":3D") 86 - .replaceAll("@", ":40") 87 - ); 88 - } 89 - 90 - /** 91 - * @see {@link urlToRkey} for rkey <=> url conversion syntax 92 - * @returns {string | undefined} undefined when input is invalid 93 - */ 94 - function rkeyToUrl(rkey: string): string | undefined { 95 - // contains 0-9A-Za-z .-_:~ 96 - if (!rkey.match(/^[A-Za-z0-9.\-_:~]*$/gm)) return; 97 - return rkey 98 - .replaceAll("::", "/") 99 - .replaceAll(":~", "%") 100 - .replaceAll(":21", "!") 101 - .replaceAll(":24", "$") 102 - .replaceAll(":26", "&") 103 - .replaceAll(":27", "'") 104 - .replaceAll(":28", "(") 105 - .replaceAll(":29", ")") 106 - .replaceAll(":2A", "*") 107 - .replaceAll(":2B", "+") 108 - .replaceAll(":2C", ",") 109 - .replaceAll(":3A", ":") 110 - .replaceAll(":3B", ";") 111 - .replaceAll(":3D", "=") 112 - .replaceAll(":40", "@"); 113 - } 114 - 115 - async function getRoute( 116 - did: `did:${"plc" | "web"}:${string}`, 117 - pds: string, 118 - route: string 119 - ): Promise<Response> { 120 - // get client to pds 121 - const client = new Client({ 122 - handler: simpleFetchHandler({ service: pds }), 123 - }); 124 - 125 - try { 126 - // note: / urls are reserved for special routes (404) 127 - // any path should be prefixed with a / 128 - // this is not enforced here so that this function can be used for special routes 129 - const targetRkey = urlToRkey(route); 130 - console.log("trying:", targetRkey, "for", route); 131 - 132 - if (!targetRkey) throw "invalid url"; 133 - 134 - const { ok: record_ok, data: record_data } = await client.get( 135 - "com.atproto.repo.getRecord", 136 - { 137 - params: { 138 - collection: "dev.atcities.route", 139 - repo: did, 140 - rkey: targetRkey, 141 - }, 142 - } 143 - ); 144 - 145 - if (!record_ok) { 146 - switch (record_data.error) { 147 - case "RecordNotFound": { 148 - // 404 error so try load 404 page 149 - if (route !== "404") { 150 - // try remove trailing / to see if that works 151 - // if that fails it'll get a 404 anyway 152 - if (route !== "/" && route.endsWith("/")) 153 - return new Response(undefined, { 154 - status: 308, 155 - statusText: "Permanent Redirect", 156 - headers: { 157 - Location: route.slice(0, -1), 158 - }, 159 - }); 160 - 161 - const r404 = await getRoute(did, pds, "404"); 162 - return new Response(r404.body, { 163 - status: 404, 164 - statusText: "Not Found", 165 - headers: r404.headers, 166 - }); 167 - } 168 - return new Response("Could not find page.", { 169 - status: 404, 170 - statusText: "Not Found", 171 - }); 172 - } 173 - // unless its a 404, internal error 174 - default: 175 - throw ( 176 - "Internal Error: got error fetching record: " + record_data.error 177 - ); 178 - } 179 - } 180 - 181 - console.log(record_data.value); 182 - if (is(DevAtcitiesRoute.mainSchema, record_data.value)) { 183 - switch (record_data.value.page.$type) { 184 - case "dev.atcities.route#blob": { 185 - const { ok: blob_ok, data: blob_data } = await client.get( 186 - "com.atproto.sync.getBlob", 187 - { 188 - params: { 189 - did, 190 - cid: 191 - "ref" in record_data.value.page.blob 192 - ? record_data.value.page.blob.ref.$link 193 - : record_data.value.page.blob.cid, 194 - }, 195 - as: "stream", 196 - } 197 - ); 198 - 199 - // possible errors include: 200 - // - request issue 201 - // - 404 not found 202 - // - account takedown 203 - // in all cases thats not recoverable 204 - // so throw out and take the happy path 205 - if (!blob_ok) { 206 - if (blob_data.error === "BlobNotFound") { 207 - // 404 error so try load 404 page 208 - if (route !== "404") { 209 - const r404 = await getRoute(did, pds, "404"); 210 - return new Response(r404.body, { 211 - status: 404, 212 - statusText: "Not Found", 213 - headers: r404.headers, 214 - }); 215 - } 216 - return new Response("Could not load page.", { 217 - status: 404, 218 - statusText: "Not Found", 219 - }); 220 - } else 221 - throw "Internal Error: Error fetching blob: " + blob_data.error; 222 - } 223 - 224 - return new Response(blob_data, { 225 - headers: { 226 - "Content-Type": record_data.value.page.blob.mimeType, 227 - }, 228 - }); 229 - } 230 - } 231 - } 232 - // isnt valid data so throw exception 233 - return new Response( 234 - "Malformed record for at://" + did + "/dev.atcities.route/" + targetRkey 235 - ); 236 - } catch (e) { 237 - console.error(e); 238 - return new Response("Something went wrong loading this route", { 239 - status: 500, 240 - statusText: "Internal Server Error", 241 - }); 242 - } 243 - } 244 - 245 - export default async function ( 246 - db: db, 247 - req: Request, 248 - user: 249 - | { handle: `${string}.${string}` } 250 - | { did: `did:plc:${string}` | `did:web:${string}` } 251 - ): Promise<Response> { 252 - // if handle: resolve did 253 - let did: `did:${"plc" | "web"}:${string}`; 254 - if ("handle" in user) { 255 - try { 256 - // cast bc i know it will be `string.string` 257 - did = await handleResolver.resolve(user.handle); 258 - } catch { 259 - return new Response("Failed to resolve handle", { 260 - status: 500, 261 - statusText: "Internal Server Error", 262 - }); 263 - } 264 - } else did = user.did; 265 - 266 - // look up in db 267 - const db_res = 268 - ( 269 - await db 270 - .select() 271 - .from(routes) 272 - .where( 273 - and( 274 - eq(routes.did, did), 275 - eq(routes.url_route, new URL(req.url).pathname) 276 - ) 277 - ) 278 - ).at(0) ?? 279 - ( 280 - await db 281 - .select() 282 - .from(routes) 283 - .where(and(eq(routes.did, did), eq(routes.url_route, "404"))) 284 - ).at(0); 285 - 286 - if (db_res) { 287 - try { 288 - const file = await Deno.readFile( 289 - `./blobs/${db_res.did}/${db_res.blob_cid}` 290 - ); 291 - return new Response(file, { 292 - headers: { 293 - "Content-Type": db_res.mime, 294 - }, 295 - }); 296 - } catch { 297 - return new Response( 298 - "Could not find in CDN; TODO: fallback to fetch from pds && add to cdn if missing\nNB: should this be default? index routes in db but only cache used pages" 299 - ); 300 - } 301 - } 302 - 303 - // resolve did doc 304 - let doc; 305 - try { 306 - doc = await docResolver.resolve(did); 307 - } catch { 308 - return new Response("Could not resolve " + did + ".\n", { 309 - status: 500, 310 - statusText: "Internal Server Error", 311 - }); 312 - } 313 - 314 - // handle must be in did document 315 - if ("handle" in user && !doc.alsoKnownAs?.includes(`at://${user.handle}`)) { 316 - return new Response( 317 - `Provided handle (at://${user.handle}) was not found in the did document for ${did}. Found handles:\n${doc.alsoKnownAs?.map((x) => "- " + x).join("\n") ?? "None"}`, 318 - { 319 - status: 500, 320 - statusText: "Internal Server Error", 321 - } 322 - ); 323 - } 324 - 325 - const pds = doc.service?.filter( 326 - (x) => 327 - x.id.endsWith("#atproto_pds") && 328 - x.type === "AtprotoPersonalDataServer" && 329 - typeof x.serviceEndpoint === "string" 330 - // cast as we type checked it above but it didnt acc apply? 331 - )[0].serviceEndpoint as string | undefined; 332 - if (!pds) 333 - return new Response( 334 - `Could not find a valid pds for ${"handle" in user ? user.handle : user.did}`, 335 - { 336 - status: 500, 337 - statusText: "Internal Server Error", 338 - } 339 - ); 340 - 341 - return await getRoute(did, pds, new URL(req.url).pathname); 342 - }
+107
server/src/utils.ts
··· 1 + import { LibSQLDatabase } from "drizzle-orm/libsql"; 2 + import { Client } from "@libsql/client"; 3 + import * as schema from "./db/schema.ts"; 4 + 5 + export type db = LibSQLDatabase<typeof schema> & { 6 + $client: Client; 7 + }; 8 + 9 + export const ROOT_DOMAIN = Deno.env.get("HOSTNAME") || "localhost"; 10 + export const PORT = Number(Deno.env.get("PORT")) || 80; 11 + 12 + export const SUBDOMAIN_REGEX = new RegExp(`.+(?=\\.${ROOT_DOMAIN}$)`, "gm"); 13 + 14 + export function clearCookies(req: Request): Headers { 15 + const cookie_header = req.headers.get("Cookie"); 16 + // cookies are unset so return empty headers 17 + if (!cookie_header) return new Headers(); 18 + // get each kv pair and extract the key 19 + const cookies = cookie_header.split("; ").map((x) => x.split("=")[0]); 20 + const head = new Headers(); 21 + for (const key of cookies) { 22 + // max-age <= 0 means instant expiry .: deleted instantly 23 + head.append("Set-Cookie", `${key}=; Max-Age=-1`); 24 + } 25 + return head; 26 + } 27 + 28 + /** 29 + * given a valid url path string containing 30 + * - `/` for seperating characters 31 + * - a-zA-Z0-9 `-._~` as unreserved 32 + * - `!$&'()*+,;=` as reserved but valid in paths 33 + * - `:@` as neither reserved or unreserved but valid in paths 34 + * - %XX where X are hex digits for percent encoding 35 + * 36 + * we need to consistently and bidirectionally convert it into a string containing the characters A-Z, a-z, 0-9, `.-_:~` for an atproto rkey 37 + * A-Z a-z 0-9 are covered easily 38 + * we can also take -._~ as they are also unreserved 39 + * leaving : as a valid rkey character which looks nice for encoding 40 + * the uppercase versions MUST be used to prevent ambiguity 41 + * a colon which isnt followed by a valid character is an invalid rkey and should be ignored 42 + * - `/` `::` 43 + * - `%` `:~` 44 + * - `!` `:21` 45 + * - `$` `:24` 46 + * - `&` `:26` 47 + * - `'` `:27` 48 + * - `(` `:28` 49 + * - `)` `:29` 50 + * - `*` `:2A` 51 + * - `+` `:2B` 52 + * - `,` `:2C` 53 + * - `:` `:3A` 54 + * - `;` `:3B` 55 + * - `=` `:3D` 56 + * - `@` `:40` 57 + * @returns {string | undefined} undefined when input is invalid 58 + */ 59 + export function urlToRkey(url: string): string | undefined { 60 + // contains 0-9A-Za-z + special valid chars and / seperator. also can contain %XX with XX being hex 61 + if (!url.match(/^([a-zA-Z0-9/\-._~!$&'()*+,;=:@]|(%[0-9a-fA-F]{2}))*$/gm)) 62 + return; 63 + return ( 64 + url 65 + // : replace is hoisted so it doesnt replace colons from elsewhere 66 + .replaceAll(":", ":3A") 67 + .replaceAll("/", "::") 68 + .replaceAll("%", ":~") 69 + .replaceAll("!", ":21") 70 + .replaceAll("$", ":24") 71 + .replaceAll("&", ":26") 72 + .replaceAll("'", ":27") 73 + .replaceAll("(", ":28") 74 + .replaceAll(")", ":29") 75 + .replaceAll("*", ":2A") 76 + .replaceAll("+", ":2B") 77 + .replaceAll(",", ":2C") 78 + .replaceAll(";", ":3B") 79 + .replaceAll("=", ":3D") 80 + .replaceAll("@", ":40") 81 + ); 82 + } 83 + 84 + /** 85 + * @see {@link urlToRkey} for rkey <=> url conversion syntax 86 + * @returns {string | undefined} undefined when input is invalid 87 + */ 88 + export function rkeyToUrl(rkey: string): string | undefined { 89 + // contains 0-9A-Za-z .-_:~ 90 + if (!rkey.match(/^[A-Za-z0-9.\-_:~]*$/gm)) return; 91 + return rkey 92 + .replaceAll("::", "/") 93 + .replaceAll(":~", "%") 94 + .replaceAll(":21", "!") 95 + .replaceAll(":24", "$") 96 + .replaceAll(":26", "&") 97 + .replaceAll(":27", "'") 98 + .replaceAll(":28", "(") 99 + .replaceAll(":29", ")") 100 + .replaceAll(":2A", "*") 101 + .replaceAll(":2B", "+") 102 + .replaceAll(":2C", ",") 103 + .replaceAll(":3A", ":") 104 + .replaceAll(":3B", ";") 105 + .replaceAll(":3D", "=") 106 + .replaceAll(":40", "@"); 107 + }