441 lines
14 KiB
JavaScript
441 lines
14 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import crypto from 'node:crypto'
|
|
import express from 'express'
|
|
import cors from 'cors'
|
|
import Stripe from 'stripe'
|
|
import dotenv from 'dotenv'
|
|
import * as applications from './applicationsStore.mjs'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
dotenv.config({ path: path.join(__dirname, '..', '.env') })
|
|
const distDir = path.join(__dirname, '..', 'dist')
|
|
const isProd = process.env.NODE_ENV === 'production'
|
|
const PORT = Number(process.env.PORT) || 4242
|
|
const defaultClientUrl = isProd
|
|
? `http://127.0.0.1:${PORT}`
|
|
: 'http://localhost:5173'
|
|
const clientUrl = (process.env.CLIENT_URL || defaultClientUrl).replace(/\/$/, '')
|
|
const allowedOrigins = new Set([
|
|
clientUrl,
|
|
'http://localhost:5173',
|
|
'http://127.0.0.1:5173',
|
|
`http://localhost:${PORT}`,
|
|
`http://127.0.0.1:${PORT}`,
|
|
'http://home.thetempleofdoom.com',
|
|
'https://home.thetempleofdoom.com',
|
|
])
|
|
|
|
process.umask(0o077)
|
|
|
|
const app = express()
|
|
app.disable('x-powered-by')
|
|
app.set('trust proxy', true)
|
|
|
|
app.use(
|
|
cors({
|
|
origin(origin, cb) {
|
|
if (!origin || allowedOrigins.has(origin)) {
|
|
cb(null, true)
|
|
return
|
|
}
|
|
cb(null, false)
|
|
},
|
|
}),
|
|
)
|
|
|
|
app.use((_, res, next) => {
|
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
|
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
|
next()
|
|
})
|
|
|
|
function rateLimit({ windowMs, max }) {
|
|
const buckets = new Map()
|
|
return (req, res, next) => {
|
|
const key = req.ip || req.socket.remoteAddress || 'unknown'
|
|
const now = Date.now()
|
|
const bucket = buckets.get(key)
|
|
if (!bucket || bucket.resetAt <= now) {
|
|
buckets.set(key, { count: 1, resetAt: now + windowMs })
|
|
next()
|
|
return
|
|
}
|
|
bucket.count += 1
|
|
if (bucket.count > max) {
|
|
res.status(429).json({ error: 'Too many requests. Please try again later.' })
|
|
return
|
|
}
|
|
next()
|
|
}
|
|
}
|
|
|
|
function hasValidApplicationSecret(req, record) {
|
|
const secret = req.get('x-application-secret') || req.body?.applicationSecret
|
|
const storedHash = record?.applicationSecretHash
|
|
if (!secret || !storedHash) return false
|
|
const providedHash = applications.hashApplicationSecret(secret)
|
|
return crypto.timingSafeEqual(Buffer.from(providedHash), Buffer.from(storedHash))
|
|
}
|
|
|
|
const applicationInitLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 })
|
|
const checkoutLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 40 })
|
|
|
|
const stripeSecret = process.env.STRIPE_SECRET_KEY
|
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
|
|
|
/** Webhooks need the raw body for signature verification — must run before express.json(). */
|
|
app.post(
|
|
'/api/webhooks/stripe',
|
|
express.raw({ type: 'application/json' }),
|
|
(req, res) => {
|
|
if (!stripeSecret) {
|
|
return res.status(503).send('Stripe secret not configured.')
|
|
}
|
|
if (!webhookSecret) {
|
|
return res.status(503).send('STRIPE_WEBHOOK_SECRET not set.')
|
|
}
|
|
|
|
const sig = req.headers['stripe-signature']
|
|
if (!sig || typeof sig !== 'string') {
|
|
return res.status(400).send('Missing stripe-signature header.')
|
|
}
|
|
|
|
let event
|
|
try {
|
|
const stripe = new Stripe(stripeSecret)
|
|
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'Invalid payload'
|
|
console.error('Webhook signature verification failed:', msg)
|
|
return res.status(400).send(`Webhook Error: ${msg}`)
|
|
}
|
|
|
|
switch (event.type) {
|
|
case 'checkout.session.completed': {
|
|
const session = event.data.object
|
|
console.info(
|
|
'[Stripe] checkout.session.completed',
|
|
session.id,
|
|
session.metadata?.house_name || '',
|
|
)
|
|
{
|
|
const ref = session.metadata?.application_ref
|
|
if (ref && applications.isValidApplicationRef(ref)) {
|
|
void applications.markPaidFromStripe(ref, session.id).catch((err) => {
|
|
console.error('[applications] mark paid failed', err)
|
|
})
|
|
}
|
|
}
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
res.json({ received: true })
|
|
},
|
|
)
|
|
|
|
app.use(express.json({ limit: '1mb' }))
|
|
|
|
app.get('/api/health', (_, res) => {
|
|
const payload = {
|
|
ok: true,
|
|
site: 'Sacred Villas',
|
|
uptime_s: Math.round(process.uptime()),
|
|
}
|
|
if (!isProd) {
|
|
payload.stripe = {
|
|
secret_key_configured: Boolean(stripeSecret),
|
|
publishable_key_configured: Boolean(process.env.VITE_STRIPE_PUBLISHABLE_KEY),
|
|
webhook_secret_configured: Boolean(webhookSecret),
|
|
}
|
|
payload.client_url_configured = Boolean(process.env.CLIENT_URL)
|
|
payload.applications_store = 'file'
|
|
}
|
|
res.json(payload)
|
|
})
|
|
|
|
app.post('/api/applications/init', applicationInitLimiter, async (_, res) => {
|
|
try {
|
|
const record = await applications.createApplicationRecord({
|
|
payload: { version: 1 },
|
|
})
|
|
res.json({
|
|
applicationRef: record.applicationRef,
|
|
applicationSecret: record.applicationSecret,
|
|
createdAt: record.createdAt,
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: 'Could not create application file.' })
|
|
}
|
|
})
|
|
|
|
app.put('/api/applications/:ref', async (req, res) => {
|
|
try {
|
|
const { ref } = req.params
|
|
if (!applications.isValidApplicationRef(ref)) {
|
|
return res.status(400).json({ error: 'Invalid application reference.' })
|
|
}
|
|
const existing = await applications.readApplication(ref)
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Application not found.' })
|
|
}
|
|
if (!hasValidApplicationSecret(req, existing)) {
|
|
return res.status(401).json({ error: 'Invalid application credentials.' })
|
|
}
|
|
if (existing.status === 'paid') {
|
|
return res.status(409).json({ error: 'Paid applications cannot be changed.' })
|
|
}
|
|
const payload = req.body?.payload
|
|
if (!payload || typeof payload !== 'object') {
|
|
return res.status(400).json({ error: 'payload object is required.' })
|
|
}
|
|
const identity = payload.identity ?? {}
|
|
const nextStatus = req.body?.status
|
|
let status = existing.status
|
|
if (nextStatus === 'submitted') status = 'submitted'
|
|
else if (nextStatus === 'draft') status = 'draft'
|
|
const updated = await applications.updateApplication(ref, {
|
|
payload,
|
|
applicantEmail: String(identity.email ?? '').slice(0, 256),
|
|
applicantPhone: String(identity.primaryPhone ?? '').slice(0, 80),
|
|
paymentType: payload.prefs?.paymentPath
|
|
? String(payload.prefs.paymentPath).slice(0, 32)
|
|
: existing.paymentType,
|
|
status,
|
|
})
|
|
res.json({
|
|
ok: true,
|
|
applicationRef: updated.applicationRef,
|
|
updatedAt: updated.updatedAt,
|
|
status: updated.status,
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: 'Could not save application.' })
|
|
}
|
|
})
|
|
|
|
app.post('/api/applications/sync-payment', checkoutLimiter, async (req, res) => {
|
|
try {
|
|
if (!stripeSecret) {
|
|
return res.status(503).json({ error: 'Stripe is not configured on this host.' })
|
|
}
|
|
const sessionId = req.body?.sessionId
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return res.status(400).json({ error: 'sessionId is required.' })
|
|
}
|
|
const stripe = new Stripe(stripeSecret)
|
|
const session = await stripe.checkout.sessions.retrieve(String(sessionId).slice(0, 128))
|
|
const ref = session.metadata?.application_ref
|
|
if (!ref || !applications.isValidApplicationRef(ref)) {
|
|
return res.json({ ok: false, reason: 'no_application_ref' })
|
|
}
|
|
if (session.status === 'complete' && session.payment_status === 'paid') {
|
|
await applications.markPaidFromStripe(ref, session.id)
|
|
return res.json({ ok: true, applicationRef: ref })
|
|
}
|
|
res.json({
|
|
ok: false,
|
|
reason: 'not_paid',
|
|
status: session.status,
|
|
payment_status: session.payment_status,
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: 'Unable to sync payment status.' })
|
|
}
|
|
})
|
|
|
|
function assertApplicationsAdmin(req, res) {
|
|
const token = process.env.APPLICATIONS_ADMIN_TOKEN
|
|
if (!token) {
|
|
res.status(503).json({
|
|
error:
|
|
'Admin listing is disabled. Set APPLICATIONS_ADMIN_TOKEN in the server environment to enable it.',
|
|
})
|
|
return false
|
|
}
|
|
const auth = req.headers.authorization || ''
|
|
const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
|
|
if (bearer !== token) {
|
|
res.status(401).json({ error: 'Unauthorized.' })
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
app.get('/api/admin/applications', async (req, res) => {
|
|
if (!assertApplicationsAdmin(req, res)) return
|
|
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 80))
|
|
const rows = await applications.listApplicationSummaries(limit)
|
|
res.json({ applications: rows })
|
|
})
|
|
|
|
app.get('/api/admin/applications/:ref', async (req, res) => {
|
|
if (!assertApplicationsAdmin(req, res)) return
|
|
const { ref } = req.params
|
|
if (!applications.isValidApplicationRef(ref)) {
|
|
return res.status(400).json({ error: 'Invalid application reference.' })
|
|
}
|
|
const doc = await applications.readApplication(ref)
|
|
if (!doc) {
|
|
return res.status(404).json({ error: 'Not found.' })
|
|
}
|
|
res.json(doc)
|
|
})
|
|
|
|
app.post('/api/create-checkout-session', checkoutLimiter, async (req, res) => {
|
|
try {
|
|
if (!stripeSecret) {
|
|
return res.status(503).json({
|
|
error:
|
|
'Payments are not configured yet. Add STRIPE_SECRET_KEY on the server.',
|
|
})
|
|
}
|
|
|
|
const stripe = new Stripe(stripeSecret)
|
|
const {
|
|
applicationRef,
|
|
paymentType,
|
|
houseId,
|
|
houseName,
|
|
houseArea,
|
|
customerEmail,
|
|
fullName,
|
|
phone,
|
|
notes,
|
|
} = req.body || {}
|
|
|
|
if (!applicationRef || typeof applicationRef !== 'string') {
|
|
return res.status(400).json({
|
|
error: 'applicationRef is required. Return to /apply and use the housing application wizard.',
|
|
})
|
|
}
|
|
if (!applications.isValidApplicationRef(applicationRef)) {
|
|
return res.status(400).json({ error: 'Invalid application reference format.' })
|
|
}
|
|
const appRow = await applications.readApplication(applicationRef)
|
|
if (!appRow) {
|
|
return res.status(400).json({
|
|
error: 'Unknown application reference. Please restart from the application page.',
|
|
})
|
|
}
|
|
if (!hasValidApplicationSecret(req, appRow)) {
|
|
return res.status(401).json({ error: 'Invalid application credentials.' })
|
|
}
|
|
|
|
if (paymentType !== 'deposit' && paymentType !== 'credit') {
|
|
return res.status(400).json({ error: 'Invalid payment type.' })
|
|
}
|
|
if (!customerEmail || typeof customerEmail !== 'string') {
|
|
return res.status(400).json({ error: 'Email is required.' })
|
|
}
|
|
if (!fullName || typeof fullName !== 'string') {
|
|
return res.status(400).json({ error: 'Full name is required.' })
|
|
}
|
|
|
|
const isDeposit = paymentType === 'deposit'
|
|
const unitAmount = isDeposit ? 50000 : 7500
|
|
const productName = isDeposit
|
|
? 'Holding deposit — Sacred Villas'
|
|
: 'Applicant credit screening — Sacred Villas'
|
|
const propertyLine = houseName
|
|
? `Property: ${String(houseName).slice(0, 180)}`
|
|
: 'Property: to be assigned with your concierge'
|
|
|
|
const lineDescription =
|
|
`${propertyLine}. Applicant: ${String(fullName).slice(0, 100)}. File: ${String(applicationRef).slice(0, 32)}`.slice(
|
|
0,
|
|
500,
|
|
)
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
ui_mode: 'embedded_page',
|
|
mode: 'payment',
|
|
customer_email: customerEmail.trim().slice(0, 256),
|
|
line_items: [
|
|
{
|
|
price_data: {
|
|
currency: 'usd',
|
|
unit_amount: unitAmount,
|
|
product_data: {
|
|
name: productName.slice(0, 120),
|
|
description: lineDescription,
|
|
},
|
|
},
|
|
quantity: 1,
|
|
},
|
|
],
|
|
metadata: {
|
|
application_ref: String(applicationRef).slice(0, 40),
|
|
payment_type: paymentType,
|
|
house_id: houseId ? String(houseId).slice(0, 120) : '',
|
|
house_name: houseName ? String(houseName).slice(0, 120) : '',
|
|
seattle_area: houseArea ? String(houseArea).slice(0, 120) : '',
|
|
full_name: String(fullName).slice(0, 120),
|
|
phone: phone ? String(phone).slice(0, 40) : '',
|
|
notes: notes ? String(notes).slice(0, 500) : '',
|
|
},
|
|
return_url: `${clientUrl}/apply/return?session_id={CHECKOUT_SESSION_ID}`,
|
|
})
|
|
|
|
if (!session.client_secret) {
|
|
return res.status(500).json({
|
|
error: 'Checkout session did not return a client secret.',
|
|
})
|
|
}
|
|
|
|
res.json({ clientSecret: session.client_secret })
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: 'Unable to start checkout.' })
|
|
}
|
|
})
|
|
|
|
app.get('/api/checkout-session-status', checkoutLimiter, async (req, res) => {
|
|
try {
|
|
if (!stripeSecret) {
|
|
return res.status(503).json({
|
|
error:
|
|
'Payments are not configured yet. Add STRIPE_SECRET_KEY on the server.',
|
|
})
|
|
}
|
|
const sessionId = req.query.session_id
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return res.status(400).json({ error: 'session_id query parameter is required.' })
|
|
}
|
|
|
|
const stripe = new Stripe(stripeSecret)
|
|
const session = await stripe.checkout.sessions.retrieve(String(sessionId).slice(0, 128))
|
|
|
|
res.json({
|
|
status: session.status,
|
|
payment_status: session.payment_status,
|
|
customer_email: session.customer_details?.email ?? null,
|
|
application_ref: session.metadata?.application_ref ?? null,
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: 'Unable to load session.' })
|
|
}
|
|
})
|
|
|
|
if (isProd && fs.existsSync(distDir)) {
|
|
app.use(express.static(distDir))
|
|
// Express 5 / path-to-regexp: named splat, not bare '*'
|
|
app.get('/{*splat}', (req, res) => {
|
|
res.sendFile(path.join(distDir, 'index.html'))
|
|
})
|
|
}
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`Sacred Villas API ${isProd ? '+ static' : ''} on http://0.0.0.0:${PORT}`)
|
|
})
|