···11import {Express} from 'express'
2233import {AppContext} from '../context.js'
44-import {default as create} from './create.js'
44+import {default as createShortLink} from './createShortLink.js'
55import {default as health} from './health.js'
66import {default as redirect} from './redirect.js'
77+import {default as shortLink} from './shortLink.js'
78import {default as siteAssociation} from './siteAssociation.js'
89910export * from './util.js'
···1112export default function (ctx: AppContext, app: Express) {
1213 app = health(ctx, app) // GET /_health
1314 app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
1414- app = create(ctx, app) // POST /link
1515- app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
1515+ app = redirect(ctx, app) // GET /redirect
1616+ app = createShortLink(ctx, app) // POST /link
1717+ app = shortLink(ctx, app) // GET /:linkId (should go last due to permissive matching)
1618 return app
1719}
+24-31
bskylink/src/routes/redirect.ts
···66import {AppContext} from '../context.js'
77import {handler} from './util.js'
8899+const INTERNAL_IP_REGEX = new RegExp(
1010+ '(^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',
1111+ 'i',
1212+)
1313+914export default function (ctx: AppContext, app: Express) {
1015 return app.get(
1111- '/:linkId',
1616+ '/redirect',
1217 handler(async (req, res) => {
1313- const linkId = req.params.linkId
1414- const contentType = req.accepts(['html', 'json'])
1818+ let link = req.query.u
1519 assert(
1616- typeof linkId === 'string',
1717- 'express guarantees id parameter is a string',
2020+ typeof link === 'string',
2121+ 'express guarantees link query parameter is a string',
1822 )
1919- const found = await ctx.db.db
2020- .selectFrom('link')
2121- .selectAll()
2222- .where('id', '=', linkId)
2323- .executeTakeFirst()
2424- if (!found) {
2525- // potentially broken or mistyped link
2323+ link = decodeURIComponent(link)
2424+2525+ let url: URL | undefined
2626+ try {
2727+ url = new URL(link)
2828+ } catch {}
2929+3030+ if (
3131+ !url ||
3232+ (url.protocol !== 'http:' && url.protocol !== 'https:') || // is a http(s) url
3333+ (ctx.cfg.service.hostnames.includes(url.hostname.toLowerCase()) &&
3434+ url.pathname === '/redirect') || // is a redirect loop
3535+ INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
3636+ ) {
2637 res.setHeader('Cache-Control', 'no-store')
2727- if (contentType === 'json') {
2828- return res
2929- .status(404)
3030- .json({
3131- error: 'NotFound',
3232- message: 'Link not found',
3333- })
3434- .end()
3535- }
3636- // send the user to the app
3738 res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
3839 return res.status(302).end()
3940 }
4040- // build url from original url in order to preserve query params
4141- const url = new URL(
4242- req.originalUrl,
4343- `https://${ctx.cfg.service.appHostname}`,
4444- )
4545- url.pathname = found.path
4141+4642 res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
4747- if (contentType === 'json') {
4848- return res.json({url: url.href}).end()
4949- }
5043 res.setHeader('Location', url.href)
5144 return res.status(301).end()
5245 }),
+54
bskylink/src/routes/shortLink.ts
···11+import assert from 'node:assert'
22+33+import {DAY, SECOND} from '@atproto/common'
44+import {Express} from 'express'
55+66+import {AppContext} from '../context.js'
77+import {handler} from './util.js'
88+99+export default function (ctx: AppContext, app: Express) {
1010+ return app.get(
1111+ '/:linkId',
1212+ handler(async (req, res) => {
1313+ const linkId = req.params.linkId
1414+ const contentType = req.accepts(['html', 'json'])
1515+ assert(
1616+ typeof linkId === 'string',
1717+ 'express guarantees id parameter is a string',
1818+ )
1919+ const found = await ctx.db.db
2020+ .selectFrom('link')
2121+ .selectAll()
2222+ .where('id', '=', linkId)
2323+ .executeTakeFirst()
2424+ if (!found) {
2525+ // potentially broken or mistyped link
2626+ res.setHeader('Cache-Control', 'no-store')
2727+ if (contentType === 'json') {
2828+ return res
2929+ .status(404)
3030+ .json({
3131+ error: 'NotFound',
3232+ message: 'Link not found',
3333+ })
3434+ .end()
3535+ }
3636+ // send the user to the app
3737+ res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
3838+ return res.status(302).end()
3939+ }
4040+ // build url from original url in order to preserve query params
4141+ const url = new URL(
4242+ req.originalUrl,
4343+ `https://${ctx.cfg.service.appHostname}`,
4444+ )
4545+ url.pathname = found.path
4646+ res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
4747+ if (contentType === 'json') {
4848+ return res.json({url: url.href}).end()
4949+ }
5050+ res.setHeader('Location', url.href)
5151+ return res.status(301).end()
5252+ }),
5353+ )
5454+}