feat: add shared decks and multi-account Mastodon OAuth

This commit is contained in:
2026-09-24 16:52:55 +09:00
parent d2cbf4dbd3
commit c47f58f065
100 changed files with 9215 additions and 1027 deletions
+128
View File
@@ -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)
})
}