* bskylink: scaffold service w/ initial config and schema * bskylink: implement link creation and redirects * bskylink: tidy * bskylink: tests * bskylink: tidy, add error handler * bskylink: add dockerfile * bskylink: add build * bskylink: fix some express plumbing * bskyweb: proxy fallthrough routes to link service redirects * bskyweb: build w/ link proxy * Add AASA to bskylink (#4588) --------- Co-authored-by: Hailey <me@haileyok.com>
111 lines
3.2 KiB
TypeScript
111 lines
3.2 KiB
TypeScript
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)
|
|
}
|