feat: add shared decks and multi-account Mastodon OAuth
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import { request } from 'node:https'
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
export function mastodonOrigins(): string[] {
|
||||
return (process.env.TWITTER_LITE_MASTODON_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.origin !== value ||
|
||||
url.port ||
|
||||
isIP(url.hostname)
|
||||
) {
|
||||
throw new Error(
|
||||
'Mastodon origins must be HTTPS host origins without paths or custom ports.',
|
||||
)
|
||||
}
|
||||
return url.origin
|
||||
})
|
||||
}
|
||||
|
||||
export function requireMastodonOrigin(origin: string): string {
|
||||
if (!mastodonOrigins().includes(origin))
|
||||
throw new Error('This Mastodon instance is not configured.')
|
||||
return origin
|
||||
}
|
||||
|
||||
export function isPublicAddress(address: string): boolean {
|
||||
if (isIP(address) === 4) {
|
||||
const [a = 0, b = 0, c = 0] = address.split('.').map(Number)
|
||||
return !(
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
a >= 224 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||
(a === 203 && b === 0 && c === 113)
|
||||
)
|
||||
}
|
||||
if (isIP(address) === 6) {
|
||||
const normalized = address.toLowerCase()
|
||||
return (
|
||||
/^[23]/.test(normalized) &&
|
||||
!/^2001:(0*:|db8:)/.test(normalized) &&
|
||||
!normalized.startsWith('2002:')
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Fixed approved HTTPS origins, pinned public DNS results, no redirects. */
|
||||
export async function safeMastodonRequest(
|
||||
url: URL,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
requireMastodonOrigin(url.origin)
|
||||
if (url.username || url.password) throw new Error('Invalid Mastodon URL.')
|
||||
const addresses = await lookup(url.hostname, { all: true, verbatim: true })
|
||||
const address = addresses[0]
|
||||
if (!address || addresses.some((value) => !isPublicAddress(value.address))) {
|
||||
throw new Error('Mastodon must resolve to public network addresses.')
|
||||
}
|
||||
const headers = Object.fromEntries(new Headers(init.headers))
|
||||
const body =
|
||||
init.body instanceof URLSearchParams ? init.body.toString() : init.body
|
||||
if (body !== undefined && body !== null && typeof body !== 'string')
|
||||
throw new Error('Unsupported Mastodon request body.')
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = request(
|
||||
url,
|
||||
{
|
||||
method: init.method ?? 'GET',
|
||||
headers,
|
||||
family: address.family,
|
||||
lookup: (_hostname, _options, callback) =>
|
||||
callback(null, address.address, address.family),
|
||||
},
|
||||
(incoming) => {
|
||||
const chunks: Buffer[] = []
|
||||
let bytes = 0
|
||||
incoming.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length
|
||||
if (bytes > 5 * 1024 * 1024)
|
||||
incoming.destroy(new Error('Mastodon response is too large.'))
|
||||
else chunks.push(chunk)
|
||||
})
|
||||
incoming.on('error', () =>
|
||||
reject(new Error('Mastodon response failed.')),
|
||||
)
|
||||
incoming.on('end', () => {
|
||||
const status = incoming.statusCode ?? 502
|
||||
if (status >= 300 && status < 400)
|
||||
return reject(new Error('Mastodon redirects are not allowed.'))
|
||||
const responseHeaders = new Headers()
|
||||
for (const [name, value] of Object.entries(incoming.headers)) {
|
||||
if (value !== undefined)
|
||||
responseHeaders.set(
|
||||
name,
|
||||
Array.isArray(value) ? value.join(', ') : value,
|
||||
)
|
||||
}
|
||||
resolve(
|
||||
new Response(status === 204 ? null : Buffer.concat(chunks), {
|
||||
status,
|
||||
headers: responseHeaders,
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
const timeout = setTimeout(() => {
|
||||
outgoing.destroy(new Error('Mastodon request timed out.'))
|
||||
}, 20_000)
|
||||
outgoing.on('close', () => clearTimeout(timeout))
|
||||
outgoing.on('error', () => reject(new Error('Mastodon request failed.')))
|
||||
outgoing.end(body)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user