forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import assert from 'node:assert'
2
3import bodyParser from 'body-parser'
4import {type Express, type Request} from 'express'
5
6import {type AppContext} from '../context.js'
7import {LinkType} from '../db/schema.js'
8import {randomId} from '../util.js'
9import {handler} from './util.js'
10
11export default function (ctx: AppContext, app: Express) {
12 return app.post(
13 '/link',
14 bodyParser.json(),
15 handler(async (req, res) => {
16 let path: string
17 if (typeof req.body?.path === 'string') {
18 path = req.body.path
19 } else {
20 return res.status(400).json({
21 error: 'InvalidPath',
22 message: '"path" parameter is missing or not a string',
23 })
24 }
25 if (!path.startsWith('/')) {
26 return res.status(400).json({
27 error: 'InvalidPath',
28 message:
29 '"path" parameter must be formatted as a path, starting with a "/"',
30 })
31 }
32 const parts = getPathParts(path)
33 if (parts.length === 3 && parts[0] === 'start') {
34 // link pattern: /start/{did}/{rkey}
35 if (!parts[1].startsWith('did:')) {
36 // enforce strong links
37 return res.status(400).json({
38 error: 'InvalidPath',
39 message:
40 '"path" parameter for starter pack must contain the actor\'s DID',
41 })
42 }
43 const id = await ensureLink(ctx, LinkType.StarterPack, parts)
44 return res.json({url: getUrl(ctx, req, id)})
45 }
46 return res.status(400).json({
47 error: 'InvalidPath',
48 message: '"path" parameter does not have a known format',
49 })
50 }),
51 )
52}
53
54const ensureLink = async (ctx: AppContext, type: LinkType, parts: string[]) => {
55 const normalizedPath = normalizedPathFromParts(parts)
56 const created = await ctx.db.db
57 .insertInto('link')
58 .values({
59 id: randomId(),
60 type,
61 path: normalizedPath,
62 })
63 .onConflict(oc => oc.column('path').doNothing())
64 .returningAll()
65 .executeTakeFirst()
66 if (created) {
67 return created.id
68 }
69 const found = await ctx.db.db
70 .selectFrom('link')
71 .selectAll()
72 .where('path', '=', normalizedPath)
73 .executeTakeFirstOrThrow()
74 return found.id
75}
76
77const getUrl = (ctx: AppContext, req: Request, id: string) => {
78 if (!ctx.cfg.service.hostnames.length) {
79 assert(req.headers.host, 'request must be made with host header')
80 const baseUrl =
81 req.protocol === 'http' && req.headers.host.startsWith('localhost:')
82 ? `http://${req.headers.host}`
83 : `https://${req.headers.host}`
84 return `${baseUrl}/${id}`
85 }
86 const host = req.headers.host ?? ''
87 const baseUrl = ctx.cfg.service.hostnamesSet.has(host)
88 ? `https://${host}`
89 : `https://${ctx.cfg.service.hostnames[0]}`
90 return `${baseUrl}/${id}`
91}
92
93const normalizedPathFromParts = (parts: string[]): string => {
94 // When given ['path1', 'path2', 'te:fg'], output should be
95 // /path1/path2/te:fg
96 return (
97 '/' +
98 parts
99 .map(encodeURIComponent)
100 .map(part => part.replace(/%3A/g, ':')) // preserve colons
101 .join('/')
102 )
103}
104
105const getPathParts = (path: string): string[] => {
106 if (path === '/') return []
107 if (path.endsWith('/')) {
108 path = path.slice(0, -1) // ignore trailing slash
109 }
110 return path
111 .slice(1) // remove leading slash
112 .split('/')
113 .map(decodeURIComponent)
114}