From 61a14043e51475b64c5c505dd10d81a0165bb3f2 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 24 Feb 2025 13:59:57 -0800 Subject: add to blink (#7788) --- bskylink/src/routes/create.ts | 111 --------------------------------- bskylink/src/routes/createShortLink.ts | 111 +++++++++++++++++++++++++++++++++ bskylink/src/routes/index.ts | 8 ++- bskylink/src/routes/redirect.ts | 55 +++++++--------- bskylink/src/routes/shortLink.ts | 54 ++++++++++++++++ 5 files changed, 194 insertions(+), 145 deletions(-) delete mode 100644 bskylink/src/routes/create.ts create mode 100644 bskylink/src/routes/createShortLink.ts create mode 100644 bskylink/src/routes/shortLink.ts diff --git a/bskylink/src/routes/create.ts b/bskylink/src/routes/create.ts deleted file mode 100644 index db7c3f809..000000000 --- a/bskylink/src/routes/create.ts +++ /dev/null @@ -1,111 +0,0 @@ -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/createShortLink.ts b/bskylink/src/routes/createShortLink.ts new file mode 100644 index 000000000..db7c3f809 --- /dev/null +++ b/bskylink/src/routes/createShortLink.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/index.ts b/bskylink/src/routes/index.ts index f60b99bcb..cfdaf3eaa 100644 --- a/bskylink/src/routes/index.ts +++ b/bskylink/src/routes/index.ts @@ -1,9 +1,10 @@ import {Express} from 'express' import {AppContext} from '../context.js' -import {default as create} from './create.js' +import {default as createShortLink} from './createShortLink.js' import {default as health} from './health.js' import {default as redirect} from './redirect.js' +import {default as shortLink} from './shortLink.js' import {default as siteAssociation} from './siteAssociation.js' export * from './util.js' @@ -11,7 +12,8 @@ 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) + app = redirect(ctx, app) // GET /redirect + app = createShortLink(ctx, app) // POST /link + app = shortLink(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 index 276aae1ca..4e7052af7 100644 --- a/bskylink/src/routes/redirect.ts +++ b/bskylink/src/routes/redirect.ts @@ -6,47 +6,40 @@ import {Express} from 'express' import {AppContext} from '../context.js' import {handler} from './util.js' +const INTERNAL_IP_REGEX = new RegExp( + '(^127.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^10.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.1[6-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.2[0-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.3[0-1]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^192.168.[0-9]{1,3}.[0-9]{1,3}$)|^localhost', + 'i', +) + export default function (ctx: AppContext, app: Express) { return app.get( - '/:linkId', + '/redirect', handler(async (req, res) => { - const linkId = req.params.linkId - const contentType = req.accepts(['html', 'json']) + let link = req.query.u assert( - typeof linkId === 'string', - 'express guarantees id parameter is a string', + typeof link === 'string', + 'express guarantees link query parameter is a string', ) - const found = await ctx.db.db - .selectFrom('link') - .selectAll() - .where('id', '=', linkId) - .executeTakeFirst() - if (!found) { - // potentially broken or mistyped link + link = decodeURIComponent(link) + + let url: URL | undefined + try { + url = new URL(link) + } catch {} + + if ( + !url || + (url.protocol !== 'http:' && url.protocol !== 'https:') || // is a http(s) url + (ctx.cfg.service.hostnames.includes(url.hostname.toLowerCase()) && + url.pathname === '/redirect') || // is a redirect loop + INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location + ) { res.setHeader('Cache-Control', 'no-store') - if (contentType === 'json') { - return res - .status(404) - .json({ - error: 'NotFound', - message: 'Link not found', - }) - .end() - } - // send the user to the app res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`) 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('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) - if (contentType === 'json') { - return res.json({url: url.href}).end() - } res.setHeader('Location', url.href) return res.status(301).end() }), diff --git a/bskylink/src/routes/shortLink.ts b/bskylink/src/routes/shortLink.ts new file mode 100644 index 000000000..276aae1ca --- /dev/null +++ b/bskylink/src/routes/shortLink.ts @@ -0,0 +1,54 @@ +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 + const contentType = req.accepts(['html', 'json']) + 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 + res.setHeader('Cache-Control', 'no-store') + if (contentType === 'json') { + return res + .status(404) + .json({ + error: 'NotFound', + message: 'Link not found', + }) + .end() + } + // send the user to the app + res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`) + 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('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) + if (contentType === 'json') { + return res.json({url: url.href}).end() + } + res.setHeader('Location', url.href) + return res.status(301).end() + }), + ) +} -- cgit 1.4.1