diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index bdbb47d..b32ed17 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -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"; diff --git a/app/components/LoginModal.vue b/app/components/LoginModal.vue index 543a936..a826505 100644 --- a/app/components/LoginModal.vue +++ b/app/components/LoginModal.vue @@ -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.' diff --git a/app/composables/useMarkdown.js b/app/composables/useMarkdown.js index 9794192..b66430b 100644 --- a/app/composables/useMarkdown.js +++ b/app/composables/useMarkdown.js @@ -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 { diff --git a/app/middleware/admin.js b/app/middleware/admin.js index 2c643a7..0f86913 100644 --- a/app/middleware/admin.js +++ b/app/middleware/admin.js @@ -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 -}) \ No newline at end of file +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') + } +}) diff --git a/app/plugins/csrf.client.js b/app/plugins/csrf.client.js new file mode 100644 index 0000000..e5743bc --- /dev/null +++ b/app/plugins/csrf.client.js @@ -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 + } + } + } + }) +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index 208fea0..ab64f1c 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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 || "", diff --git a/server/api/admin/dashboard.get.js b/server/api/admin/dashboard.get.js index 5af6cec..31786e1 100644 --- a/server/api/admin/dashboard.get.js +++ b/server/api/admin/dashboard.get.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/admin/events.get.js b/server/api/admin/events.get.js index 832b642..b29f746 100644 --- a/server/api/admin/events.get.js +++ b/server/api/admin/events.get.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js index 8ea3faa..ed07488 100644 --- a/server/api/admin/events.post.js +++ b/server/api/admin/events.post.js @@ -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, diff --git a/server/api/admin/events/[id].delete.js b/server/api/admin/events/[id].delete.js index e283859..f83686f 100644 --- a/server/api/admin/events/[id].delete.js +++ b/server/api/admin/events/[id].delete.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/admin/events/[id].get.js b/server/api/admin/events/[id].get.js index cd1bf54..ae42859 100644 --- a/server/api/admin/events/[id].get.js +++ b/server/api/admin/events/[id].get.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js index 50ae589..542b727 100644 --- a/server/api/admin/events/[id].put.js +++ b/server/api/admin/events/[id].put.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/admin/members.get.js b/server/api/admin/members.get.js index 3e8bccc..3ababb7 100644 --- a/server/api/admin/members.get.js +++ b/server/api/admin/members.get.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/admin/members.post.js b/server/api/admin/members.post.js index cb9f139..677a6cf 100644 --- a/server/api/admin/members.post.js +++ b/server/api/admin/members.post.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/admin/series.get.js b/server/api/admin/series.get.js index e54ac4d..f7d7991 100644 --- a/server/api/admin/series.get.js +++ b/server/api/admin/series.get.js @@ -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 diff --git a/server/api/admin/series.post.js b/server/api/admin/series.post.js index 2041e23..d61af01 100644 --- a/server/api/admin/series.post.js +++ b/server/api/admin/series.post.js @@ -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() diff --git a/server/api/admin/series.put.js b/server/api/admin/series.put.js index 2af40b8..88a9ba3 100644 --- a/server/api/admin/series.put.js +++ b/server/api/admin/series.put.js @@ -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) diff --git a/server/api/admin/series/[id].delete.js b/server/api/admin/series/[id].delete.js index cc80202..dfcef54 100644 --- a/server/api/admin/series/[id].delete.js +++ b/server/api/admin/series/[id].delete.js @@ -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') diff --git a/server/api/admin/series/[id].put.js b/server/api/admin/series/[id].put.js index 0143938..a303754 100644 --- a/server/api/admin/series/[id].put.js +++ b/server/api/admin/series/[id].put.js @@ -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') diff --git a/server/api/admin/series/tickets.put.js b/server/api/admin/series/tickets.put.js index c087685..50ed385 100644 --- a/server/api/admin/series/tickets.put.js +++ b/server/api/admin/series/tickets.put.js @@ -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) diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index afc9fc1..0806a10 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -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); diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js index d5e2a91..146d13e 100644 --- a/server/api/auth/member.get.js +++ b/server/api/auth/member.get.js @@ -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, + }; }); diff --git a/server/api/auth/refresh.post.js b/server/api/auth/refresh.post.js new file mode 100644 index 0000000..482fef9 --- /dev/null +++ b/server/api/auth/refresh.post.js @@ -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 } +}) diff --git a/server/api/auth/status.get.js b/server/api/auth/status.get.js index 9a7d2da..4d526fa 100644 --- a/server/api/auth/status.get.js +++ b/server/api/auth/status.get.js @@ -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 } } }) \ No newline at end of file diff --git a/server/api/auth/verify.get.js b/server/api/auth/verify.get.js index 31a66f2..3068ed7 100644 --- a/server/api/auth/verify.get.js +++ b/server/api/auth/verify.get.js @@ -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' diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 782a0da..2e20202 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -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, diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js index 3987225..3888bf9 100644 --- a/server/api/helcim/initialize-payment.post.js +++ b/server/api/helcim/initialize-payment.post.js @@ -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", diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index 08cf6fa..dafacc3 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -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] diff --git a/server/api/helcim/update-billing.post.js b/server/api/helcim/update-billing.post.js index 0c1a367..e1199b6 100644 --- a/server/api/helcim/update-billing.post.js +++ b/server/api/helcim/update-billing.post.js @@ -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) diff --git a/server/api/helcim/verify-payment.post.js b/server/api/helcim/verify-payment.post.js index d975ee4..b6c9ffd 100644 --- a/server/api/helcim/verify-payment.post.js +++ b/server/api/helcim/verify-payment.post.js @@ -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' }) } -}) \ No newline at end of file +}) diff --git a/server/api/members/directory.get.js b/server/api/members/directory.get.js index 70c614e..c880480 100644 --- a/server/api/members/directory.get.js +++ b/server/api/members/directory.get.js @@ -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" } }, ], }, { diff --git a/server/api/members/profile.patch.js b/server/api/members/profile.patch.js index 67eb598..83160da 100644 --- a/server/api/members/profile.patch.js +++ b/server/api/members/profile.patch.js @@ -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 diff --git a/server/api/upload/image.post.js b/server/api/upload/image.post.js index b9fb9d5..797b3a4 100644 --- a/server/api/upload/image.post.js +++ b/server/api/upload/image.post.js @@ -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) diff --git a/server/middleware/01.csrf.js b/server/middleware/01.csrf.js new file mode 100644 index 0000000..0ee2eec --- /dev/null +++ b/server/middleware/01.csrf.js @@ -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' + }) + } +}) diff --git a/server/middleware/02.security-headers.js b/server/middleware/02.security-headers.js new file mode 100644 index 0000000..f29ce86 --- /dev/null +++ b/server/middleware/02.security-headers.js @@ -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) + } +}) diff --git a/server/middleware/03.rate-limit.js b/server/middleware/03.rate-limit.js new file mode 100644 index 0000000..2641598 --- /dev/null +++ b/server/middleware/03.rate-limit.js @@ -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.' + }) + } +}) diff --git a/server/models/member.js b/server/models/member.js index 7dcdad0..aa00bf2 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -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"], diff --git a/server/plugins/validate-env.js b/server/plugins/validate-env.js new file mode 100644 index 0000000..886ae6e --- /dev/null +++ b/server/plugins/validate-env.js @@ -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) + } +}) diff --git a/server/utils/auth.js b/server/utils/auth.js new file mode 100644 index 0000000..a0cacfc --- /dev/null +++ b/server/utils/auth.js @@ -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 +} diff --git a/server/utils/escapeHtml.js b/server/utils/escapeHtml.js new file mode 100644 index 0000000..d7c105d --- /dev/null +++ b/server/utils/escapeHtml.js @@ -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]) +} diff --git a/server/utils/resend.js b/server/utils/resend.js index 9317328..5f0b179 100644 --- a/server/utils/resend.js +++ b/server/utils/resend.js @@ -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 ", to: [registration.email], - subject: `You're registered for ${eventData.title}`, + subject: `You're registered for ${escapeHtml(eventData.title)}`, html: ` @@ -105,9 +106,9 @@ export async function sendEventRegistrationEmail(registration, eventData) {
-

Hi ${registration.name},

+

Hi ${escapeHtml(registration.name)},

-

Thank you for registering for ${eventData.title}!

+

Thank you for registering for ${escapeHtml(eventData.title)}!

@@ -122,11 +123,11 @@ export async function sendEventRegistrationEmail(registration, eventData) {
Location
-
${eventData.location}
+
${escapeHtml(eventData.location)}
- ${eventData.description ? `

${eventData.description}

` : ""} + ${eventData.description ? `

${escapeHtml(eventData.description)}

` : ""} ${ registration.ticketType && @@ -148,7 +149,7 @@ export async function sendEventRegistrationEmail(registration, eventData) { ? `
Transaction ID
-
${registration.paymentId}
+
${escapeHtml(registration.paymentId)}
` : "" @@ -211,7 +212,7 @@ export async function sendEventCancellationEmail(registration, eventData) { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", to: [registration.email], - subject: `Registration cancelled: ${eventData.title}`, + subject: `Registration cancelled: ${escapeHtml(eventData.title)}`, html: ` @@ -264,9 +265,9 @@ export async function sendEventCancellationEmail(registration, eventData) {
-

Hi ${registration.name},

+

Hi ${escapeHtml(registration.name)},

-

Your registration for ${eventData.title} has been cancelled.

+

Your registration for ${escapeHtml(eventData.title)} has been cancelled.

We're sorry you can't make it. You can always register again if your plans change.

@@ -332,7 +333,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", to: [waitlistEntry.email], - subject: `A spot opened up for ${eventData.title}!`, + subject: `A spot opened up for ${escapeHtml(eventData.title)}!`, html: ` @@ -413,9 +414,9 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
-

Hi ${waitlistEntry.name},

+

Hi ${escapeHtml(waitlistEntry.name)},

-

Great news! A spot has become available for ${eventData.title}, and you're on the waitlist.

+

Great news! A spot has become available for ${escapeHtml(eventData.title)}, and you're on the waitlist.

@@ -426,7 +427,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {

Event
-
${eventData.title}
+
${escapeHtml(eventData.title)}
@@ -441,7 +442,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
Location
-
${eventData.location}
+
${escapeHtml(eventData.location)}
@@ -529,7 +530,7 @@ export async function sendSeriesPassConfirmation(options) { const { data, error } = await resend.emails.send({ from: "Ghost Guild ", to: [to], - subject: `Your Series Pass for ${series.title}`, + subject: `Your Series Pass for ${escapeHtml(series.title)}`, html: ` @@ -620,10 +621,10 @@ export async function sendSeriesPassConfirmation(options) {
-

Hi ${name},

+

Hi ${escapeHtml(name)},

- Great news! Your series pass for ${series.title} is confirmed. + Great news! Your series pass for ${escapeHtml(series.title)} is confirmed. You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}.

@@ -647,7 +648,7 @@ export async function sendSeriesPassConfirmation(options) {
Series
-
${series.title}
+
${escapeHtml(series.title)}
${ @@ -655,7 +656,7 @@ export async function sendSeriesPassConfirmation(options) { ? `
About
-
${series.description}
+
${escapeHtml(series.description)}
` : "" @@ -676,7 +677,7 @@ export async function sendSeriesPassConfirmation(options) { ? `
Transaction ID
-
${paymentId}
+
${escapeHtml(paymentId)}
` : "" @@ -699,7 +700,7 @@ export async function sendSeriesPassConfirmation(options) { (event, index) => `
- Event ${index + 1}: ${event.title} + Event ${index + 1}: ${escapeHtml(event.title)}
📅 ${formatDate(event.startDate)} @@ -708,7 +709,7 @@ export async function sendSeriesPassConfirmation(options) { 🕐 ${formatTime(event.startDate, event.endDate)}
- 📍 ${event.location} + 📍 ${escapeHtml(event.location)}
`,