about summary refs log tree commit diff
path: root/bskylink/src/routes/create.ts
diff options
context:
space:
mode:
Diffstat (limited to 'bskylink/src/routes/create.ts')
-rw-r--r--bskylink/src/routes/create.ts111
1 files changed, 111 insertions, 0 deletions
diff --git a/bskylink/src/routes/create.ts b/bskylink/src/routes/create.ts
new file mode 100644
index 000000000..db7c3f809
--- /dev/null
+++ b/bskylink/src/routes/create.ts
@@ -0,0 +1,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)
+}