about summary refs log tree commit diff
path: root/bskylink/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'bskylink/src/routes')
-rw-r--r--bskylink/src/routes/create.ts111
-rw-r--r--bskylink/src/routes/health.ts20
-rw-r--r--bskylink/src/routes/index.ts17
-rw-r--r--bskylink/src/routes/redirect.ts40
-rw-r--r--bskylink/src/routes/siteAssociation.ts13
-rw-r--r--bskylink/src/routes/util.ts23
6 files changed, 224 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)
+}
diff --git a/bskylink/src/routes/health.ts b/bskylink/src/routes/health.ts
new file mode 100644
index 000000000..c8a30c59e
--- /dev/null
+++ b/bskylink/src/routes/health.ts
@@ -0,0 +1,20 @@
+import {Express} from 'express'
+import {sql} from 'kysely'
+
+import {AppContext} from '../context.js'
+import {handler} from './util.js'
+
+export default function (ctx: AppContext, app: Express) {
+  return app.get(
+    '/_health',
+    handler(async (_req, res) => {
+      const {version} = ctx.cfg.service
+      try {
+        await sql`select 1`.execute(ctx.db.db)
+        return res.send({version})
+      } catch (err) {
+        return res.status(503).send({version, error: 'Service Unavailable'})
+      }
+    }),
+  )
+}
diff --git a/bskylink/src/routes/index.ts b/bskylink/src/routes/index.ts
new file mode 100644
index 000000000..f60b99bcb
--- /dev/null
+++ b/bskylink/src/routes/index.ts
@@ -0,0 +1,17 @@
+import {Express} from 'express'
+
+import {AppContext} from '../context.js'
+import {default as create} from './create.js'
+import {default as health} from './health.js'
+import {default as redirect} from './redirect.js'
+import {default as siteAssociation} from './siteAssociation.js'
+
+export * from './util.js'
+
+export default function (ctx: AppContext, app: Express) {
+  app = health(ctx, app) // GET /_health
+  app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
+  app = create(ctx, app) // POST /link
+  app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
+  return app
+}
diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts
new file mode 100644
index 000000000..7791ea815
--- /dev/null
+++ b/bskylink/src/routes/redirect.ts
@@ -0,0 +1,40 @@
+import assert from 'node:assert'
+
+import {DAY, SECOND} from '@atproto/common'
+import {Express} from 'express'
+
+import {AppContext} from '../context.js'
+import {handler} from './util.js'
+
+export default function (ctx: AppContext, app: Express) {
+  return app.get(
+    '/:linkId',
+    handler(async (req, res) => {
+      const linkId = req.params.linkId
+      assert(
+        typeof linkId === 'string',
+        'express guarantees id parameter is a string',
+      )
+      const found = await ctx.db.db
+        .selectFrom('link')
+        .selectAll()
+        .where('id', '=', linkId)
+        .executeTakeFirst()
+      if (!found) {
+        // potentially broken or mistyped link— send user to the app
+        res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
+        res.setHeader('Cache-Control', 'no-store')
+        return res.status(302).end()
+      }
+      // build url from original url in order to preserve query params
+      const url = new URL(
+        req.originalUrl,
+        `https://${ctx.cfg.service.appHostname}`,
+      )
+      url.pathname = found.path
+      res.setHeader('Location', url.href)
+      res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
+      return res.status(301).end()
+    }),
+  )
+}
diff --git a/bskylink/src/routes/siteAssociation.ts b/bskylink/src/routes/siteAssociation.ts
new file mode 100644
index 000000000..ae3b42e30
--- /dev/null
+++ b/bskylink/src/routes/siteAssociation.ts
@@ -0,0 +1,13 @@
+import {Express} from 'express'
+
+import {AppContext} from '../context.js'
+
+export default function (ctx: AppContext, app: Express) {
+  return app.get('/.well-known/apple-app-site-association', (req, res) => {
+    res.json({
+      appclips: {
+        apps: ['B3LX46C5HS.xyz.blueskyweb.app.AppClip'],
+      },
+    })
+  })
+}
diff --git a/bskylink/src/routes/util.ts b/bskylink/src/routes/util.ts
new file mode 100644
index 000000000..bcac64b01
--- /dev/null
+++ b/bskylink/src/routes/util.ts
@@ -0,0 +1,23 @@
+import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
+
+import {httpLogger} from '../logger.js'
+
+export type Handler = (req: Request, res: Response) => Awaited<void>
+
+export const handler = (runHandler: Handler): RequestHandler => {
+  return async (req, res, next) => {
+    try {
+      await runHandler(req, res)
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
+export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => {
+  httpLogger.error({err}, 'request error')
+  if (res.headersSent) {
+    return next(err)
+  }
+  return res.status(500).end('server error')
+}