+1
-1
server/deno.json
+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
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
+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
-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
-
}
+38
server/src/routes/root.ts
+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
+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
-7
server/src/types.ts
-342
server/src/user.ts
-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
+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
+
}