Implement OWASP ASVS L1 security remediation (Phases 0-2)
Auth: Add requireAuth/requireAdmin guards with JWT cookie verification, member status checks (suspended/cancelled = 403), and admin role enforcement. Apply to all admin, upload, and payment endpoints. Add role field to Member model. CSRF: Double-submit cookie middleware with client plugin. Exempt webhook and magic-link verify routes. Headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy on all responses. HSTS and CSP (Helcim/Cloudinary/Plausible sources) in production only. Rate limiting: Auth 5/5min, payment 10/min, upload 10/min, general 100/min via rate-limiter-flexible, keyed by client IP. XSS: DOMPurify sanitization on marked() output with tag/attr allowlists. escapeHtml() utility for email template interpolation. Anti-enumeration: Login returns identical response for existing and non-existing emails. Remove 404 handling from login UI components. Mass assignment: Remove helcimCustomerId from profile allowedFields. Session: 7-day token expiry, refresh endpoint, httpOnly+secure cookies. Environment: Validate required secrets on startup via server plugin. Remove JWT_SECRET hardcoded fallback.
This commit is contained in:
parent
29c96a207e
commit
26c300c357
41 changed files with 566 additions and 380 deletions
|
|
@ -211,9 +211,7 @@ const handleLogin = async () => {
|
|||
} catch (err) {
|
||||
console.error("Login error:", err);
|
||||
|
||||
if (err.statusCode === 404) {
|
||||
loginError.value = "No account found";
|
||||
} else if (err.statusCode === 500) {
|
||||
if (err.statusCode === 500) {
|
||||
loginError.value = "Failed to send email";
|
||||
} else {
|
||||
loginError.value = "Something went wrong";
|
||||
|
|
|
|||
|
|
@ -167,9 +167,7 @@ const handleLogin = async () => {
|
|||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
|
||||
if (err.statusCode === 404) {
|
||||
loginError.value = 'No account found with that email. Please check your email or join Ghost Guild.'
|
||||
} else if (err.statusCode === 500) {
|
||||
if (err.statusCode === 500) {
|
||||
loginError.value = 'Failed to send login email. Please try again later.'
|
||||
} else {
|
||||
loginError.value = err.statusMessage || 'Something went wrong. Please try again.'
|
||||
|
|
|
|||
|
|
@ -1,12 +1,29 @@
|
|||
import { marked } from 'marked'
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
'a', 'strong', 'em', 'b', 'i', 'u',
|
||||
'ul', 'ol', 'li', 'p', 'br',
|
||||
'code', 'pre', 'blockquote',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'del', 'sup', 'sub'
|
||||
]
|
||||
|
||||
const ALLOWED_ATTR = ['href', 'target', 'rel', 'class']
|
||||
|
||||
export const useMarkdown = () => {
|
||||
const render = (markdown) => {
|
||||
if (!markdown) return ''
|
||||
return marked(markdown, {
|
||||
const raw = marked(markdown, {
|
||||
breaks: true,
|
||||
gfm: true
|
||||
})
|
||||
return DOMPurify.sanitize(raw, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
export default defineNuxtRouteMiddleware((to) => {
|
||||
// Skip middleware in server-side rendering to avoid errors
|
||||
if (process.server) return
|
||||
|
||||
// TODO: Temporarily disabled for testing - enable when authentication is set up
|
||||
// Check if user is authenticated (you'll need to implement proper auth state)
|
||||
// const isAuthenticated = useCookie('auth-token').value
|
||||
|
||||
// if (!isAuthenticated) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// TODO: Add proper role-based authorization
|
||||
// For now, we assume anyone with a valid token is an admin
|
||||
})
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (import.meta.server) return
|
||||
|
||||
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
await checkMemberStatus()
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
|
||||
if (memberData.value?.role !== 'admin') {
|
||||
return navigateTo('/members')
|
||||
}
|
||||
})
|
||||
|
|
|
|||
23
app/plugins/csrf.client.js
Normal file
23
app/plugins/csrf.client.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export default defineNuxtPlugin(() => {
|
||||
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
globalThis.$fetch = globalThis.$fetch.create({
|
||||
onRequest({ options }) {
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
if (safeMethods.has(method)) return
|
||||
|
||||
// Read CSRF token from cookie
|
||||
const csrfToken = useCookie('csrf-token').value
|
||||
if (csrfToken) {
|
||||
options.headers = options.headers || {}
|
||||
if (options.headers instanceof Headers) {
|
||||
options.headers.set('x-csrf-token', csrfToken)
|
||||
} else if (Array.isArray(options.headers)) {
|
||||
options.headers.push(['x-csrf-token', csrfToken])
|
||||
} else {
|
||||
options.headers['x-csrf-token'] = csrfToken
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-07-15",
|
||||
devtools: { enabled: true },
|
||||
devtools: { enabled: process.env.NODE_ENV !== 'production' },
|
||||
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
||||
build: {
|
||||
transpile: ["vue-cal"],
|
||||
|
|
@ -14,7 +14,7 @@ export default defineNuxtConfig({
|
|||
// Private keys (server-side only)
|
||||
mongodbUri:
|
||||
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
|
||||
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-in-production",
|
||||
jwtSecret: process.env.JWT_SECRET || "",
|
||||
resendApiKey: process.env.RESEND_API_KEY || "",
|
||||
helcimApiToken: process.env.HELCIM_API_TOKEN || "",
|
||||
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
||||
|
|
|
|||
|
|
@ -1,26 +1,13 @@
|
|||
import Member from '../../models/member.js'
|
||||
import Event from '../../models/event.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// Basic auth check
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// jwt.verify(token, config.jwtSecret)
|
||||
|
||||
await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
|
||||
// Get stats
|
||||
const totalMembers = await Member.countDocuments()
|
||||
const now = new Date()
|
||||
|
|
@ -28,21 +15,21 @@ export default defineEventHandler(async (event) => {
|
|||
startDate: { $lte: now },
|
||||
endDate: { $gte: now }
|
||||
})
|
||||
|
||||
|
||||
// Calculate monthly revenue from member contributions
|
||||
const members = await Member.find({}, 'contributionTier').lean()
|
||||
const monthlyRevenue = members.reduce((total, member) => {
|
||||
return total + parseInt(member.contributionTier || '0')
|
||||
}, 0)
|
||||
|
||||
|
||||
const pendingSlackInvites = await Member.countDocuments({ slackInvited: false })
|
||||
|
||||
|
||||
// Get recent members (last 5)
|
||||
const recentMembers = await Member.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(5)
|
||||
.lean()
|
||||
|
||||
|
||||
// Get upcoming events (next 5)
|
||||
const upcomingEvents = await Event.find({
|
||||
startDate: { $gte: now }
|
||||
|
|
@ -50,7 +37,7 @@ export default defineEventHandler(async (event) => {
|
|||
.sort({ startDate: 1 })
|
||||
.limit(5)
|
||||
.lean()
|
||||
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalMembers,
|
||||
|
|
@ -62,9 +49,10 @@ export default defineEventHandler(async (event) => {
|
|||
upcomingEvents
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to fetch dashboard data'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,34 +1,22 @@
|
|||
import Event from '../../models/event.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// Basic auth check - you may want to implement proper admin role checking
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// jwt.verify(token, config.jwtSecret)
|
||||
|
||||
await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
|
||||
const events = await Event.find()
|
||||
.sort({ startDate: 1 })
|
||||
.lean()
|
||||
|
||||
return events
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to fetch events'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,21 +1,10 @@
|
|||
import Event from "../../models/event.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { requireAdmin } from "../../utils/auth.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// const decoded = jwt.verify(token, config.jwtSecret)
|
||||
const admin = await requireAdmin(event);
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
|
|
@ -31,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
const eventData = {
|
||||
...body,
|
||||
createdBy: "admin@ghostguild.org", // TODO: Use actual authenticated user
|
||||
createdBy: admin.email,
|
||||
startDate: new Date(body.startDate),
|
||||
endDate: new Date(body.endDate),
|
||||
registrationDeadline: body.registrationDeadline
|
||||
|
|
@ -67,6 +56,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
return savedEvent;
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error;
|
||||
console.error("Error creating event:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,15 @@
|
|||
import Event from '../../../models/event.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// const decoded = jwt.verify(token, config.jwtSecret)
|
||||
await requireAdmin(event)
|
||||
|
||||
const eventId = getRouterParam(event, 'id')
|
||||
|
||||
await connectDB()
|
||||
|
||||
|
||||
const deletedEvent = await Event.findByIdAndDelete(eventId)
|
||||
|
||||
if (!deletedEvent) {
|
||||
|
|
@ -29,13 +18,14 @@ export default defineEventHandler(async (event) => {
|
|||
statusMessage: 'Event not found'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return { success: true, message: 'Event deleted successfully' }
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
console.error('Error deleting event:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to delete event'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,47 +1,31 @@
|
|||
import Event from '../../../models/event.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// const decoded = jwt.verify(token, config.jwtSecret)
|
||||
await requireAdmin(event)
|
||||
|
||||
const eventId = getRouterParam(event, 'id')
|
||||
console.log('🔍 API: Get event by ID called')
|
||||
console.log('🔍 API: Event ID param:', eventId)
|
||||
|
||||
await connectDB()
|
||||
|
||||
|
||||
const eventData = await Event.findById(eventId)
|
||||
console.log('🔍 API: Event data found:', eventData ? 'YES' : 'NO')
|
||||
console.log('🔍 API: Event data preview:', eventData ? { id: eventData._id, title: eventData.title } : null)
|
||||
|
||||
if (!eventData) {
|
||||
console.log('❌ API: Event not found in database')
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
})
|
||||
}
|
||||
|
||||
console.log('✅ API: Returning event data')
|
||||
|
||||
return { data: eventData }
|
||||
} catch (error) {
|
||||
console.error('❌ API: Error fetching event:', error)
|
||||
if (error.statusCode) throw error
|
||||
console.error('Error fetching event:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to fetch event'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,25 +1,14 @@
|
|||
import Event from '../../../models/event.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// const decoded = jwt.verify(token, config.jwtSecret)
|
||||
await requireAdmin(event)
|
||||
|
||||
const eventId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.description || !body.startDate || !body.endDate) {
|
||||
throw createError({
|
||||
|
|
@ -29,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
|
||||
const updateData = {
|
||||
...body,
|
||||
startDate: new Date(body.startDate),
|
||||
|
|
@ -37,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
|||
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
|
||||
// Handle ticket data
|
||||
if (body.tickets) {
|
||||
updateData.tickets = {
|
||||
|
|
@ -67,13 +56,14 @@ export default defineEventHandler(async (event) => {
|
|||
statusMessage: 'Event not found'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return updatedEvent
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
console.error('Error updating event:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to update event'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,34 +1,22 @@
|
|||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// Basic auth check - you may want to implement proper admin role checking
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// jwt.verify(token, config.jwtSecret)
|
||||
|
||||
await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
|
||||
const members = await Member.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
|
||||
return members
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to fetch members'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,24 +1,13 @@
|
|||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
// if (!token) {
|
||||
// throw createError({
|
||||
// statusCode: 401,
|
||||
// statusMessage: 'Authentication required'
|
||||
// })
|
||||
// }
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
// jwt.verify(token, config.jwtSecret)
|
||||
await requireAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.email || !body.circle || !body.contributionTier) {
|
||||
throw createError({
|
||||
|
|
@ -28,7 +17,7 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
|
||||
// Check if member already exists
|
||||
const existingMember = await Member.findOne({ email: body.email })
|
||||
if (existingMember) {
|
||||
|
|
@ -47,14 +36,14 @@ export default defineEventHandler(async (event) => {
|
|||
})
|
||||
|
||||
const savedMember = await newMember.save()
|
||||
|
||||
|
||||
return savedMember
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create member'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import Series from "../../models/series.js";
|
||||
import Event from "../../models/event.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
import { requireAdmin } from "../../utils/auth.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAdmin(event);
|
||||
await connectDB();
|
||||
|
||||
// Fetch all series
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import Series from '../../models/series.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const admin = await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if (!body.id || !body.title || !body.description) {
|
||||
throw createError({
|
||||
|
|
@ -14,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|||
statusMessage: 'Series ID, title, and description are required'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Create new series
|
||||
const newSeries = new Series({
|
||||
id: body.id,
|
||||
|
|
@ -22,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
|||
description: body.description,
|
||||
type: body.type || 'workshop_series',
|
||||
totalEvents: body.totalEvents || null,
|
||||
createdBy: 'admin' // TODO: Get from authentication
|
||||
createdBy: admin.email
|
||||
})
|
||||
|
||||
await newSeries.save()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import Series from '../../models/series.js'
|
||||
import Event from '../../models/event.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import { requireAdmin } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
const body = await readBody(event)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import Series from '../../../models/series.js'
|
||||
import Event from '../../../models/event.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import Series from '../../../models/series.js'
|
||||
import Event from '../../../models/event.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import Series from '../../../models/series.js'
|
||||
import Event from '../../../models/event.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import { requireAdmin } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
const body = await readBody(event)
|
||||
|
|
|
|||
|
|
@ -19,19 +19,23 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
||||
|
||||
const member = await Member.findOne({ email });
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "No account found with that email address",
|
||||
});
|
||||
// Return same response shape to prevent enumeration
|
||||
return {
|
||||
success: true,
|
||||
message: GENERIC_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate magic link token
|
||||
const token = jwt.sign(
|
||||
{ memberId: member._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "15m" }, // Shorter expiry for security
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
// Get the base URL for the magic link
|
||||
|
|
@ -65,7 +69,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
return {
|
||||
success: true,
|
||||
message: "Login link sent to your email",
|
||||
message: GENERIC_MESSAGE,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
|
|
|
|||
|
|
@ -1,60 +1,30 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Member from "../../models/member.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
import { requireAuth } from "../../utils/auth.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
const member = await requireAuth(event);
|
||||
|
||||
const token = getCookie(event, "auth-token");
|
||||
console.log("Auth check - token found:", !!token);
|
||||
|
||||
if (!token) {
|
||||
console.log("No auth token found in cookies");
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Not authenticated",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const member = await Member.findById(decoded.memberId).select("-__v");
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Member not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
_id: member._id,
|
||||
id: member._id,
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
circle: member.circle,
|
||||
contributionTier: member.contributionTier,
|
||||
membershipLevel: `${member.circle}-${member.contributionTier}`,
|
||||
// Profile fields
|
||||
pronouns: member.pronouns,
|
||||
timeZone: member.timeZone,
|
||||
avatar: member.avatar,
|
||||
studio: member.studio,
|
||||
bio: member.bio,
|
||||
location: member.location,
|
||||
socialLinks: member.socialLinks,
|
||||
offering: member.offering,
|
||||
lookingFor: member.lookingFor,
|
||||
showInDirectory: member.showInDirectory,
|
||||
privacy: member.privacy,
|
||||
// Peer support
|
||||
peerSupport: member.peerSupport,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Token verification error:", err);
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
return {
|
||||
_id: member._id,
|
||||
id: member._id,
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
role: member.role || 'member',
|
||||
circle: member.circle,
|
||||
contributionTier: member.contributionTier,
|
||||
membershipLevel: `${member.circle}-${member.contributionTier}`,
|
||||
// Profile fields
|
||||
pronouns: member.pronouns,
|
||||
timeZone: member.timeZone,
|
||||
avatar: member.avatar,
|
||||
studio: member.studio,
|
||||
bio: member.bio,
|
||||
location: member.location,
|
||||
socialLinks: member.socialLinks,
|
||||
offering: member.offering,
|
||||
lookingFor: member.lookingFor,
|
||||
showInDirectory: member.showInDirectory,
|
||||
privacy: member.privacy,
|
||||
// Peer support
|
||||
peerSupport: member.peerSupport,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
58
server/api/auth/refresh.post.js
Normal file
58
server/api/auth/refresh.post.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB()
|
||||
|
||||
const token = getCookie(event, 'auth-token')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Not authenticated'
|
||||
})
|
||||
}
|
||||
|
||||
let decoded
|
||||
try {
|
||||
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
|
||||
} catch (err) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
})
|
||||
}
|
||||
|
||||
const member = await Member.findById(decoded.memberId)
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Member not found'
|
||||
})
|
||||
}
|
||||
|
||||
if (member.status === 'suspended' || member.status === 'cancelled') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Account is ' + member.status
|
||||
})
|
||||
}
|
||||
|
||||
// Issue a fresh token
|
||||
const newToken = jwt.sign(
|
||||
{ memberId: member._id, email: member.email },
|
||||
useRuntimeConfig().jwtSecret,
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
|
||||
setCookie(event, 'auth-token', newToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 days
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
|
|
@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
|
|||
await connectDB()
|
||||
|
||||
const token = getCookie(event, 'auth-token')
|
||||
console.log('🔍 Auth status check - token exists:', !!token)
|
||||
|
||||
if (!token) {
|
||||
return { authenticated: false, member: null }
|
||||
}
|
||||
|
|
@ -17,11 +15,13 @@ export default defineEventHandler(async (event) => {
|
|||
const member = await Member.findById(decoded.memberId).select('-__v')
|
||||
|
||||
if (!member) {
|
||||
console.log('⚠️ Token valid but member not found')
|
||||
return { authenticated: false, member: null }
|
||||
}
|
||||
|
||||
console.log('✅ Auth status check - member found:', member.email)
|
||||
|
||||
if (member.status === 'suspended' || member.status === 'cancelled') {
|
||||
return { authenticated: false, member: null, reason: 'account_' + member.status }
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
member: {
|
||||
|
|
@ -34,7 +34,6 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Auth status check - token verification failed:', err.message)
|
||||
return { authenticated: false, member: null }
|
||||
}
|
||||
})
|
||||
|
|
@ -33,22 +33,21 @@ export default defineEventHandler(async (event) => {
|
|||
const sessionToken = jwt.sign(
|
||||
{ memberId: member._id, email: member.email },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
|
||||
|
||||
// Set the session cookie
|
||||
setCookie(event, 'auth-token', sessionToken, {
|
||||
httpOnly: false, // Allow JavaScript access for debugging in development
|
||||
secure: false, // Don't require HTTPS in development
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 days
|
||||
})
|
||||
|
||||
// Redirect to the members dashboard or home page
|
||||
await sendRedirect(event, '/members', 302)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Token verification error:', err)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ export default defineEventHandler(async (event) => {
|
|||
})
|
||||
}
|
||||
|
||||
// Debug: Log token (first few chars only)
|
||||
console.log('Using Helcim token:', helcimToken.substring(0, 10) + '...')
|
||||
|
||||
// Test the connection first with native fetch
|
||||
try {
|
||||
|
|
@ -55,8 +53,7 @@ export default defineEventHandler(async (event) => {
|
|||
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
|
||||
}
|
||||
|
||||
const testData = await testResponse.json()
|
||||
console.log('Connection test passed:', testData)
|
||||
await testResponse.json()
|
||||
} catch (testError) {
|
||||
console.error('Connection test failed:', testError)
|
||||
throw createError({
|
||||
|
|
@ -113,18 +110,14 @@ export default defineEventHandler(async (event) => {
|
|||
)
|
||||
|
||||
// Set the session cookie server-side
|
||||
console.log('Setting auth-token cookie for member:', member.email)
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV)
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true, // Server-only for security
|
||||
secure: false, // Don't require HTTPS in development
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/',
|
||||
domain: undefined // Let browser set domain automatically
|
||||
})
|
||||
console.log('Cookie set successfully')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
customerId: customerData.id,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
// Initialize HelcimPay.js session
|
||||
import { requireAuth } from "../../utils/auth.js";
|
||||
|
||||
const HELCIM_API_BASE = "https://api.helcim.com/v2";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAuth(event);
|
||||
const config = useRuntimeConfig(event);
|
||||
const body = await readBody(event);
|
||||
|
||||
// Debug log the request body
|
||||
console.log("Initialize payment request body:", body);
|
||||
|
||||
const helcimToken =
|
||||
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
|
||||
|
|
@ -43,8 +44,6 @@ export default defineEventHandler(async (event) => {
|
|||
requestBody.orderNumber = `${body.metadata.eventId}`;
|
||||
}
|
||||
|
||||
console.log("Helcim request body:", JSON.stringify(requestBody, null, 2));
|
||||
|
||||
// Initialize HelcimPay.js session
|
||||
const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
|
|||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import { getSlackService } from '../../utils/slack.ts'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||
|
||||
|
|
@ -72,6 +73,7 @@ async function inviteToSlack(member) {
|
|||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAuth(event)
|
||||
await connectDB()
|
||||
const config = useRuntimeConfig(event)
|
||||
const body = await readBody(event)
|
||||
|
|
@ -91,11 +93,8 @@ export default defineEventHandler(async (event) => {
|
|||
})
|
||||
}
|
||||
|
||||
console.log('Subscription request body:', body)
|
||||
|
||||
// Check if payment is required
|
||||
if (!requiresPayment(body.contributionTier)) {
|
||||
console.log('No payment required for tier:', body.contributionTier)
|
||||
// For free tier, just update member status
|
||||
const member = await Member.findOneAndUpdate(
|
||||
{ helcimCustomerId: body.customerId },
|
||||
|
|
@ -107,8 +106,6 @@ export default defineEventHandler(async (event) => {
|
|||
{ new: true }
|
||||
)
|
||||
|
||||
console.log('Updated member for free tier:', member)
|
||||
|
||||
// Send Slack invitation for free tier members
|
||||
await inviteToSlack(member)
|
||||
|
||||
|
|
@ -119,11 +116,8 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
}
|
||||
|
||||
console.log('Payment required for tier:', body.contributionTier)
|
||||
|
||||
// Get the Helcim plan ID
|
||||
const planId = getHelcimPlanId(body.contributionTier)
|
||||
console.log('Plan ID for tier:', planId)
|
||||
|
||||
// Validate card token is provided
|
||||
if (!body.cardToken) {
|
||||
|
|
@ -135,8 +129,6 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
// Check if we have a configured plan for this tier
|
||||
if (!planId) {
|
||||
console.log('No Helcim plan configured for tier:', body.contributionTier)
|
||||
|
||||
const member = await Member.findOneAndUpdate(
|
||||
{ helcimCustomerId: body.customerId },
|
||||
{
|
||||
|
|
@ -168,8 +160,6 @@ export default defineEventHandler(async (event) => {
|
|||
// Try to create subscription in Helcim
|
||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
||||
|
||||
console.log('Attempting to create Helcim subscription with plan ID:', planId)
|
||||
|
||||
// Generate a proper alphanumeric idempotency key (exactly 25 characters)
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let idempotencyKey = ''
|
||||
|
|
@ -197,10 +187,6 @@ export default defineEventHandler(async (event) => {
|
|||
'idempotency-key': idempotencyKey
|
||||
}
|
||||
|
||||
console.log('Subscription request body:', requestBody)
|
||||
console.log('Request headers:', requestHeaders)
|
||||
console.log('Request URL:', `${HELCIM_API_BASE}/subscriptions`)
|
||||
|
||||
try {
|
||||
const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
|
||||
method: 'POST',
|
||||
|
|
@ -210,47 +196,11 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
if (!subscriptionResponse.ok) {
|
||||
const errorText = await subscriptionResponse.text()
|
||||
console.error('Subscription creation failed:')
|
||||
console.error('Status:', subscriptionResponse.status)
|
||||
console.error('Status Text:', subscriptionResponse.statusText)
|
||||
console.error('Headers:', Object.fromEntries(subscriptionResponse.headers.entries()))
|
||||
console.error('Response Body:', errorText)
|
||||
console.error('Request was:', {
|
||||
url: `${HELCIM_API_BASE}/subscriptions`,
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'api-token': helcimToken ? 'present' : 'missing'
|
||||
}
|
||||
})
|
||||
console.error('Subscription creation failed:', subscriptionResponse.status)
|
||||
|
||||
// If it's a validation error, let's try to get more info about available plans
|
||||
if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) {
|
||||
console.log('Plan might not exist. Trying to get list of available payment plans...')
|
||||
|
||||
// Try to fetch available payment plans
|
||||
try {
|
||||
const plansResponse = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'api-token': helcimToken
|
||||
}
|
||||
})
|
||||
|
||||
if (plansResponse.ok) {
|
||||
const plansData = await plansResponse.json()
|
||||
console.log('Available payment plans:', JSON.stringify(plansData, null, 2))
|
||||
} else {
|
||||
console.log('Could not fetch payment plans:', plansResponse.status, plansResponse.statusText)
|
||||
}
|
||||
} catch (planError) {
|
||||
console.log('Error fetching payment plans:', planError.message)
|
||||
}
|
||||
|
||||
// For now, just update member status and let user know we need to configure plans
|
||||
// Plan might not exist -- update member status and proceed
|
||||
const member = await Member.findOneAndUpdate(
|
||||
{ helcimCustomerId: body.customerId },
|
||||
{
|
||||
|
|
@ -287,7 +237,6 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
const subscriptionData = await subscriptionResponse.json()
|
||||
console.log('Subscription created successfully:', subscriptionData)
|
||||
|
||||
// Extract the first subscription from the response array
|
||||
const subscription = subscriptionData.data?.[0]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
// Update customer billing address
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAuth(event)
|
||||
const config = useRuntimeConfig(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
// Verify payment token from HelcimPay.js
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAuth(event)
|
||||
const config = useRuntimeConfig(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if (!body.cardToken || !body.customerId) {
|
||||
throw createError({
|
||||
|
|
@ -14,25 +17,57 @@ export default defineEventHandler(async (event) => {
|
|||
})
|
||||
}
|
||||
|
||||
console.log('Payment verification request:', {
|
||||
customerId: body.customerId,
|
||||
cardToken: body.cardToken ? 'present' : 'missing'
|
||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
||||
|
||||
if (!helcimToken) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Helcim API token not configured'
|
||||
})
|
||||
}
|
||||
|
||||
// Verify the card token by fetching the customer's cards from Helcim
|
||||
const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}/cards`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'api-token': helcimToken
|
||||
}
|
||||
})
|
||||
|
||||
// Since HelcimPay.js already verified the payment and we have the card token,
|
||||
// we can just return success. The card is already associated with the customer.
|
||||
console.log('Payment already verified through HelcimPay.js, returning success')
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Payment verification failed:', response.status, errorText)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Payment verification failed with Helcim'
|
||||
})
|
||||
}
|
||||
|
||||
const cards = await response.json()
|
||||
|
||||
// Verify the card token exists for this customer
|
||||
const cardExists = Array.isArray(cards) && cards.some(card =>
|
||||
card.cardToken === body.cardToken || card.id
|
||||
)
|
||||
|
||||
if (!cardExists && Array.isArray(cards) && cards.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'No payment method found for this customer'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
cardToken: body.cardToken,
|
||||
message: 'Payment verified successfully through HelcimPay.js'
|
||||
message: 'Payment verified with Helcim'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying payment:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.message || 'Failed to verify payment'
|
||||
statusMessage: error.statusMessage || 'Failed to verify payment'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -46,9 +46,11 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
// Search by name or bio
|
||||
if (search) {
|
||||
// Escape special regex characters to prevent ReDoS
|
||||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
dbQuery.$or = [
|
||||
{ name: { $regex: search, $options: "i" } },
|
||||
{ bio: { $regex: search, $options: "i" } },
|
||||
{ name: { $regex: escaped, $options: "i" } },
|
||||
{ bio: { $regex: escaped, $options: "i" } },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -60,11 +62,12 @@ export default defineEventHandler(async (event) => {
|
|||
];
|
||||
// If search is also present, combine with AND
|
||||
if (search) {
|
||||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
dbQuery.$and = [
|
||||
{
|
||||
$or: [
|
||||
{ name: { $regex: search, $options: "i" } },
|
||||
{ bio: { $regex: search, $options: "i" } },
|
||||
{ name: { $regex: escaped, $options: "i" } },
|
||||
{ bio: { $regex: escaped, $options: "i" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,29 +1,9 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Member from "../../models/member.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
import { requireAuth } from "../../utils/auth.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
|
||||
const token = getCookie(event, "auth-token");
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Not authenticated",
|
||||
});
|
||||
}
|
||||
|
||||
let memberId;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
memberId = decoded.memberId;
|
||||
} catch (err) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
const authedMember = await requireAuth(event);
|
||||
const memberId = authedMember._id;
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
|
|
@ -37,7 +17,6 @@ export default defineEventHandler(async (event) => {
|
|||
"location",
|
||||
"socialLinks",
|
||||
"showInDirectory",
|
||||
"helcimCustomerId",
|
||||
];
|
||||
|
||||
// Define privacy fields
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { v2 as cloudinary } from 'cloudinary'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
// Configure Cloudinary
|
||||
cloudinary.config({
|
||||
|
|
@ -9,6 +10,7 @@ cloudinary.config({
|
|||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAuth(event)
|
||||
// Parse the multipart form data
|
||||
const formData = await readMultipartFormData(event)
|
||||
|
||||
|
|
|
|||
46
server/middleware/01.csrf.js
Normal file
46
server/middleware/01.csrf.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import crypto from 'crypto'
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
// Routes exempt from CSRF (external webhooks, magic link verify)
|
||||
const EXEMPT_PREFIXES = [
|
||||
'/api/helcim/webhook',
|
||||
'/api/slack/webhook',
|
||||
'/api/auth/verify',
|
||||
]
|
||||
|
||||
function isExempt(path) {
|
||||
return EXEMPT_PREFIXES.some(prefix => path.startsWith(prefix))
|
||||
}
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const method = getMethod(event)
|
||||
const path = getRequestURL(event).pathname
|
||||
|
||||
// Always set a CSRF token cookie if one doesn't exist
|
||||
let csrfToken = getCookie(event, 'csrf-token')
|
||||
if (!csrfToken) {
|
||||
csrfToken = crypto.randomBytes(32).toString('hex')
|
||||
setCookie(event, 'csrf-token', csrfToken, {
|
||||
httpOnly: false, // Must be readable by JS to include in requests
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
// Only check state-changing methods
|
||||
if (SAFE_METHODS.has(method)) return
|
||||
if (!path.startsWith('/api/')) return
|
||||
if (isExempt(path)) return
|
||||
|
||||
// Double-submit cookie check: header must match cookie
|
||||
const headerToken = getHeader(event, 'x-csrf-token')
|
||||
|
||||
if (!headerToken || headerToken !== csrfToken) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'CSRF token missing or invalid'
|
||||
})
|
||||
}
|
||||
})
|
||||
30
server/middleware/02.security-headers.js
Normal file
30
server/middleware/02.security-headers.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export default defineEventHandler((event) => {
|
||||
const headers = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '0',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
|
||||
// CSP: allow self, Cloudinary images, HelcimPay.js, Plausible analytics
|
||||
headers['Content-Security-Policy'] = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://myposjs.helcim.com https://plausible.io",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https://res.cloudinary.com https://*.cloudinary.com",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self' https://api.helcim.com https://myposjs.helcim.com https://plausible.io",
|
||||
"frame-src 'self' https://myposjs.helcim.com https://secure.helcim.com",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; ')
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
setHeader(event, key, value)
|
||||
}
|
||||
})
|
||||
65
server/middleware/03.rate-limit.js
Normal file
65
server/middleware/03.rate-limit.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
// Strict rate limit for auth endpoints
|
||||
const authLimiter = new RateLimiterMemory({
|
||||
points: 5, // 5 requests
|
||||
duration: 300, // per 5 minutes
|
||||
keyPrefix: 'rl_auth'
|
||||
})
|
||||
|
||||
// Moderate rate limit for payment endpoints
|
||||
const paymentLimiter = new RateLimiterMemory({
|
||||
points: 10,
|
||||
duration: 60,
|
||||
keyPrefix: 'rl_payment'
|
||||
})
|
||||
|
||||
// Light rate limit for upload endpoints
|
||||
const uploadLimiter = new RateLimiterMemory({
|
||||
points: 10,
|
||||
duration: 60,
|
||||
keyPrefix: 'rl_upload'
|
||||
})
|
||||
|
||||
// General API rate limit
|
||||
const generalLimiter = new RateLimiterMemory({
|
||||
points: 100,
|
||||
duration: 60,
|
||||
keyPrefix: 'rl_general'
|
||||
})
|
||||
|
||||
function getClientIp(event) {
|
||||
return getHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| getHeader(event, 'x-real-ip')
|
||||
|| event.node.req.socket.remoteAddress
|
||||
|| 'unknown'
|
||||
}
|
||||
|
||||
const AUTH_PATHS = new Set(['/api/auth/login'])
|
||||
const PAYMENT_PREFIXES = ['/api/helcim/']
|
||||
const UPLOAD_PATHS = new Set(['/api/upload/image'])
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const path = getRequestURL(event).pathname
|
||||
if (!path.startsWith('/api/')) return
|
||||
|
||||
const ip = getClientIp(event)
|
||||
|
||||
try {
|
||||
if (AUTH_PATHS.has(path)) {
|
||||
await authLimiter.consume(ip)
|
||||
} else if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
|
||||
await paymentLimiter.consume(ip)
|
||||
} else if (UPLOAD_PATHS.has(path)) {
|
||||
await uploadLimiter.consume(ip)
|
||||
} else {
|
||||
await generalLimiter.consume(ip)
|
||||
}
|
||||
} catch (rateLimiterRes) {
|
||||
setHeader(event, 'Retry-After', Math.ceil(rateLimiterRes.msBeforeNext / 1000))
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: 'Too many requests. Please try again later.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -22,6 +22,11 @@ const memberSchema = new mongoose.Schema({
|
|||
enum: getValidContributionValues(),
|
||||
required: true,
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
enum: ["member", "admin"],
|
||||
default: "member",
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["pending_payment", "active", "suspended", "cancelled"],
|
||||
|
|
|
|||
9
server/plugins/validate-env.js
Normal file
9
server/plugins/validate-env.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default defineNitroPlugin(() => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!config.jwtSecret) {
|
||||
console.error('FATAL: JWT_SECRET environment variable is not set. Server cannot start without it.')
|
||||
console.error('Set JWT_SECRET in your .env file or environment variables.')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
65
server/utils/auth.js
Normal file
65
server/utils/auth.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../models/member.js'
|
||||
import { connectDB } from './mongoose.js'
|
||||
|
||||
/**
|
||||
* Verify JWT from cookie and return the decoded member.
|
||||
* Throws 401 if token is missing or invalid.
|
||||
*/
|
||||
export async function requireAuth(event) {
|
||||
await connectDB()
|
||||
|
||||
const token = getCookie(event, 'auth-token')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
})
|
||||
}
|
||||
|
||||
let decoded
|
||||
try {
|
||||
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
|
||||
} catch (err) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
})
|
||||
}
|
||||
|
||||
const member = await Member.findById(decoded.memberId)
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Member not found'
|
||||
})
|
||||
}
|
||||
|
||||
if (member.status === 'suspended' || member.status === 'cancelled') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Account is ' + member.status
|
||||
})
|
||||
}
|
||||
|
||||
return member
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT and require admin role.
|
||||
* Throws 401 if not authenticated, 403 if not admin.
|
||||
*/
|
||||
export async function requireAdmin(event) {
|
||||
const member = await requireAuth(event)
|
||||
|
||||
if (member.role !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin access required'
|
||||
})
|
||||
}
|
||||
|
||||
return member
|
||||
}
|
||||
18
server/utils/escapeHtml.js
Normal file
18
server/utils/escapeHtml.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
const ESCAPE_MAP = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
|
||||
const ESCAPE_RE = /[&<>"']/g
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS in email templates.
|
||||
* Returns empty string for null/undefined input.
|
||||
*/
|
||||
export function escapeHtml(str) {
|
||||
if (str == null) return ''
|
||||
return String(str).replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch])
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Resend } from "resend";
|
||||
import { escapeHtml } from "./escapeHtml.js";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
|||
const { data, error } = await resend.emails.send({
|
||||
from: "Ghost Guild <events@babyghosts.org>",
|
||||
to: [registration.email],
|
||||
subject: `You're registered for ${eventData.title}`,
|
||||
subject: `You're registered for ${escapeHtml(eventData.title)}`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -105,9 +106,9 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
|||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi ${registration.name},</p>
|
||||
<p>Hi ${escapeHtml(registration.name)},</p>
|
||||
|
||||
<p>Thank you for registering for <strong>${eventData.title}</strong>!</p>
|
||||
<p>Thank you for registering for <strong>${escapeHtml(eventData.title)}</strong>!</p>
|
||||
|
||||
<div class="event-details">
|
||||
<div class="detail-row">
|
||||
|
|
@ -122,11 +123,11 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
|||
|
||||
<div class="detail-row">
|
||||
<div class="label">Location</div>
|
||||
<div class="value">${eventData.location}</div>
|
||||
<div class="value">${escapeHtml(eventData.location)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${eventData.description ? `<p>${eventData.description}</p>` : ""}
|
||||
${eventData.description ? `<p>${escapeHtml(eventData.description)}</p>` : ""}
|
||||
|
||||
${
|
||||
registration.ticketType &&
|
||||
|
|
@ -148,7 +149,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
|||
? `
|
||||
<div class="detail-row">
|
||||
<div class="label">Transaction ID</div>
|
||||
<div class="value" style="font-size: 12px; font-family: monospace;">${registration.paymentId}</div>
|
||||
<div class="value" style="font-size: 12px; font-family: monospace;">${escapeHtml(registration.paymentId)}</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
|
|
@ -211,7 +212,7 @@ export async function sendEventCancellationEmail(registration, eventData) {
|
|||
const { data, error } = await resend.emails.send({
|
||||
from: "Ghost Guild <events@ghostguild.org>",
|
||||
to: [registration.email],
|
||||
subject: `Registration cancelled: ${eventData.title}`,
|
||||
subject: `Registration cancelled: ${escapeHtml(eventData.title)}`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -264,9 +265,9 @@ export async function sendEventCancellationEmail(registration, eventData) {
|
|||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi ${registration.name},</p>
|
||||
<p>Hi ${escapeHtml(registration.name)},</p>
|
||||
|
||||
<p>Your registration for <strong>${eventData.title}</strong> has been cancelled.</p>
|
||||
<p>Your registration for <strong>${escapeHtml(eventData.title)}</strong> has been cancelled.</p>
|
||||
|
||||
<p>We're sorry you can't make it. You can always register again if your plans change.</p>
|
||||
|
||||
|
|
@ -332,7 +333,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
|
|||
const { data, error } = await resend.emails.send({
|
||||
from: "Ghost Guild <events@ghostguild.org>",
|
||||
to: [waitlistEntry.email],
|
||||
subject: `A spot opened up for ${eventData.title}!`,
|
||||
subject: `A spot opened up for ${escapeHtml(eventData.title)}!`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -413,9 +414,9 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
|
|||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi ${waitlistEntry.name},</p>
|
||||
<p>Hi ${escapeHtml(waitlistEntry.name)},</p>
|
||||
|
||||
<p>Great news! A spot has become available for <strong>${eventData.title}</strong>, and you're on the waitlist.</p>
|
||||
<p>Great news! A spot has become available for <strong>${escapeHtml(eventData.title)}</strong>, and you're on the waitlist.</p>
|
||||
|
||||
<div class="urgent">
|
||||
<p style="margin: 0; font-weight: 600; color: #92400e;">
|
||||
|
|
@ -426,7 +427,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
|
|||
<div class="event-details">
|
||||
<div class="detail-row">
|
||||
<div class="label">Event</div>
|
||||
<div class="value">${eventData.title}</div>
|
||||
<div class="value">${escapeHtml(eventData.title)}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
|
|
@ -441,7 +442,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
|
|||
|
||||
<div class="detail-row">
|
||||
<div class="label">Location</div>
|
||||
<div class="value">${eventData.location}</div>
|
||||
<div class="value">${escapeHtml(eventData.location)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -529,7 +530,7 @@ export async function sendSeriesPassConfirmation(options) {
|
|||
const { data, error } = await resend.emails.send({
|
||||
from: "Ghost Guild <events@babyghosts.org>",
|
||||
to: [to],
|
||||
subject: `Your Series Pass for ${series.title}`,
|
||||
subject: `Your Series Pass for ${escapeHtml(series.title)}`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -620,10 +621,10 @@ export async function sendSeriesPassConfirmation(options) {
|
|||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${name},</p>
|
||||
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${escapeHtml(name)},</p>
|
||||
|
||||
<p>
|
||||
Great news! Your series pass for <strong>${series.title}</strong> is confirmed.
|
||||
Great news! Your series pass for <strong>${escapeHtml(series.title)}</strong> is confirmed.
|
||||
You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}.
|
||||
</p>
|
||||
|
||||
|
|
@ -647,7 +648,7 @@ export async function sendSeriesPassConfirmation(options) {
|
|||
|
||||
<div class="detail-row">
|
||||
<div class="label">Series</div>
|
||||
<div class="value">${series.title}</div>
|
||||
<div class="value">${escapeHtml(series.title)}</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
|
@ -655,7 +656,7 @@ export async function sendSeriesPassConfirmation(options) {
|
|||
? `
|
||||
<div class="detail-row">
|
||||
<div class="label">About</div>
|
||||
<div class="value">${series.description}</div>
|
||||
<div class="value">${escapeHtml(series.description)}</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
|
|
@ -676,7 +677,7 @@ export async function sendSeriesPassConfirmation(options) {
|
|||
? `
|
||||
<div class="detail-row">
|
||||
<div class="label">Transaction ID</div>
|
||||
<div class="value" style="font-family: monospace; font-size: 14px;">${paymentId}</div>
|
||||
<div class="value" style="font-family: monospace; font-size: 14px;">${escapeHtml(paymentId)}</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
|
|
@ -699,7 +700,7 @@ export async function sendSeriesPassConfirmation(options) {
|
|||
(event, index) => `
|
||||
<div class="event-item">
|
||||
<div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;">
|
||||
Event ${index + 1}: ${event.title}
|
||||
Event ${index + 1}: ${escapeHtml(event.title)}
|
||||
</div>
|
||||
<div style="font-size: 14px; color: #666; margin: 5px 0;">
|
||||
📅 ${formatDate(event.startDate)}
|
||||
|
|
@ -708,7 +709,7 @@ export async function sendSeriesPassConfirmation(options) {
|
|||
🕐 ${formatTime(event.startDate, event.endDate)}
|
||||
</div>
|
||||
<div style="font-size: 14px; color: #666; margin: 5px 0;">
|
||||
📍 ${event.location}
|
||||
📍 ${escapeHtml(event.location)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue