about summary refs log tree commit diff
path: root/bskylink/src/routes/create.ts
blob: db7c3f809049f4e225596ff68315298df576391e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import assert from 'node:assert'

import bodyParser from 'body-parser'
import {Express, Request} from 'express'

import {AppContext} from '../context.js'
import {LinkType} from '../db/schema.js'
import {randomId} from '../util.js'
import {handler} from './util.js'

export default function (ctx: AppContext, app: Express) {
  return app.post(
    '/link',
    bodyParser.json(),
    handler(async (req, res) => {
      let path: string
      if (typeof req.body?.path === 'string') {
        path = req.body.path
      } else {
        return res.status(400).json({
          error: 'InvalidPath',
          message: '"path" parameter is missing or not a string',
        })
      }
      if (!path.startsWith('/')) {
        return res.status(400).json({
          error: 'InvalidPath',
          message:
            '"path" parameter must be formatted as a path, starting with a "/"',
        })
      }
      const parts = getPathParts(path)
      if (parts.length === 3 && parts[0] === 'start') {
        // link pattern: /start/{did}/{rkey}
        if (!parts[1].startsWith('did:')) {
          // enforce strong links
          return res.status(400).json({
            error: 'InvalidPath',
            message:
              '"path" parameter for starter pack must contain the actor\'s DID',
          })
        }
        const id = await ensureLink(ctx, LinkType.StarterPack, parts)
        return res.json({url: getUrl(ctx, req, id)})
      }
      return res.status(400).json({
        error: 'InvalidPath',
        message: '"path" parameter does not have a known format',
      })
    }),
  )
}

const ensureLink = async (ctx: AppContext, type: LinkType, parts: string[]) => {
  const normalizedPath = normalizedPathFromParts(parts)
  const created = await ctx.db.db
    .insertInto('link')
    .values({
      id: randomId(),
      type,
      path: normalizedPath,
    })
    .onConflict(oc => oc.column('path').doNothing())
    .returningAll()
    .executeTakeFirst()
  if (created) {
    return created.id
  }
  const found = await ctx.db.db
    .selectFrom('link')
    .selectAll()
    .where('path', '=', normalizedPath)
    .executeTakeFirstOrThrow()
  return found.id
}

const getUrl = (ctx: AppContext, req: Request, id: string) => {
  if (!ctx.cfg.service.hostnames.length) {
    assert(req.headers.host, 'request must be made with host header')
    const baseUrl =
      req.protocol === 'http' && req.headers.host.startsWith('localhost:')
        ? `http://${req.headers.host}`
        : `https://${req.headers.host}`
    return `${baseUrl}/${id}`
  }
  const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host)
    ? `https://${req.headers.host}`
    : `https://${ctx.cfg.service.hostnames[0]}`
  return `${baseUrl}/${id}`
}

const normalizedPathFromParts = (parts: string[]): string => {
  return (
    '/' +
    parts
      .map(encodeURIComponent)
      .map(part => part.replaceAll('%3A', ':')) // preserve colons
      .join('/')
  )
}

const getPathParts = (path: string): string[] => {
  if (path === '/') return []
  if (path.endsWith('/')) {
    path = path.slice(0, -1) // ignore trailing slash
  }
  return path
    .slice(1) // remove leading slash
    .split('/')
    .map(decodeURIComponent)
}