このコミットが含まれているのは:
+30
-67
@@ -1,7 +1,7 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
||||
import { createServer, request as requestHTTP } from 'node:http'
|
||||
import { request as requestHTTPS } from 'node:https'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -11,39 +11,6 @@ const root = fileURLToPath (new URL ('./dist/client/', import.meta.url))
|
||||
const template = await readFile (path.join (root, 'index.html'), 'utf8')
|
||||
const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin
|
||||
const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002'
|
||||
const sessionSecret = process.env.SSR_COOKIE_SECRET
|
||||
if (process.env.NODE_ENV === 'production' && (!sessionSecret || sessionSecret.length < 32))
|
||||
throw new Error ('SSR_COOKIE_SECRET must contain at least 32 characters in production')
|
||||
const sessionKey = createHash ('sha256').update (sessionSecret ?? randomBytes (32)).digest ()
|
||||
const cookieName = '__Host-btrc-session'
|
||||
const sessionSeconds = 7 * 24 * 60 * 60
|
||||
|
||||
const encodeSession = code => {
|
||||
const iv = randomBytes (12)
|
||||
const cipher = createCipheriv ('aes-256-gcm', sessionKey, iv)
|
||||
const payload = JSON.stringify ({ code, expires: Date.now () + sessionSeconds * 1000 })
|
||||
const encrypted = Buffer.concat ([cipher.update (payload, 'utf8'), cipher.final ()])
|
||||
return Buffer.concat ([iv, cipher.getAuthTag (), encrypted]).toString ('base64url')
|
||||
}
|
||||
|
||||
const decodeSession = cookie => {
|
||||
try
|
||||
{
|
||||
const token = cookie?.split (';').map (part => part.trim ())
|
||||
.find (part => part.startsWith (`${cookieName}=`))?.slice (cookieName.length + 1)
|
||||
if (!token || token.length > 2048) return undefined
|
||||
const payload = Buffer.from (token, 'base64url')
|
||||
const decipher = createDecipheriv ('aes-256-gcm', sessionKey, payload.subarray (0, 12))
|
||||
decipher.setAuthTag (payload.subarray (12, 28))
|
||||
const data = JSON.parse (Buffer.concat ([
|
||||
decipher.update (payload.subarray (28)), decipher.final ()]).toString ('utf8'))
|
||||
return typeof data.code === 'string' && data.expires > Date.now () ? data.code : undefined
|
||||
}
|
||||
catch
|
||||
{
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const types = {
|
||||
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
|
||||
'.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg',
|
||||
@@ -57,38 +24,32 @@ if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN)
|
||||
const server = createServer (async (request, response) => {
|
||||
response.setHeader ('Cache-Control', 'private, no-store')
|
||||
const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress
|
||||
if (request.url === '/_ssr/session')
|
||||
if (request.url === '/api' || request.url.startsWith ('/api/'))
|
||||
{
|
||||
if (request.method !== 'POST' || request.headers.origin !== origin)
|
||||
{
|
||||
response.writeHead (403).end ()
|
||||
return
|
||||
}
|
||||
const code = request.headers['x-transfer-code']
|
||||
if (typeof code !== 'string' || code.length > 512 || code.length === 0)
|
||||
{
|
||||
response.writeHead (400).end ()
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
const verified = await fetch (new URL ('users/session', apiBaseURL.replace (/\/?$/, '/')), {
|
||||
headers: { 'X-Transfer-Code': code, 'X-Forwarded-For': forwardedFor },
|
||||
signal: AbortSignal.timeout (10_000) })
|
||||
if (!verified.ok)
|
||||
{
|
||||
response.writeHead (verified.status === 401 || verified.status === 403
|
||||
? verified.status : 503).end ()
|
||||
return
|
||||
}
|
||||
response.setHeader ('Set-Cookie', `${cookieName}=${encodeSession (code)}; `
|
||||
+ `Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionSeconds}`)
|
||||
response.writeHead (204).end ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
response.writeHead (503).end ()
|
||||
}
|
||||
const address = new URL (request.url, origin)
|
||||
const target = new URL (apiBaseURL)
|
||||
target.pathname = target.pathname.replace (/\/$/, '') + address.pathname.slice (4)
|
||||
target.search = address.search
|
||||
const send = target.protocol === 'https:' ? requestHTTPS : requestHTTP
|
||||
const upstream = send (target, {
|
||||
method: request.method,
|
||||
headers: { ...request.headers, host: target.host,
|
||||
'x-forwarded-host': new URL (origin).host,
|
||||
'x-forwarded-proto': new URL (origin).protocol.replace (':', ''),
|
||||
'x-forwarded-for': forwardedFor } }, result => {
|
||||
response.writeHead (result.statusCode ?? 502, result.headers)
|
||||
result.on ('error', () => response.destroy ())
|
||||
result.pipe (response)
|
||||
})
|
||||
upstream.setTimeout (10_000, () => upstream.destroy (new Error ('API timeout')))
|
||||
upstream.on ('error', () => {
|
||||
if (response.headersSent)
|
||||
response.destroy ()
|
||||
else
|
||||
response.writeHead (503).end ()
|
||||
})
|
||||
request.on ('aborted', () => upstream.destroy ())
|
||||
request.pipe (upstream)
|
||||
return
|
||||
}
|
||||
if (!['GET', 'HEAD'].includes (request.method))
|
||||
@@ -124,7 +85,9 @@ const server = createServer (async (request, response) => {
|
||||
return
|
||||
}
|
||||
const result = await render (url.pathname + url.search, origin, apiBaseURL, {
|
||||
transferCode: decodeSession (request.headers.cookie), forwardedFor })
|
||||
cookie: request.headers.cookie, forwardedFor })
|
||||
if (result?.setCookies?.length > 0)
|
||||
response.setHeader ('Set-Cookie', result.setCookies)
|
||||
if (result?.location != null)
|
||||
{
|
||||
response.writeHead (result.status, { Location: result.location }).end ()
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする