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) {
|
} catch (err) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
|
|
||||||
if (err.statusCode === 404) {
|
if (err.statusCode === 500) {
|
||||||
loginError.value = "No account found";
|
|
||||||
} else if (err.statusCode === 500) {
|
|
||||||
loginError.value = "Failed to send email";
|
loginError.value = "Failed to send email";
|
||||||
} else {
|
} else {
|
||||||
loginError.value = "Something went wrong";
|
loginError.value = "Something went wrong";
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,7 @@ const handleLogin = async () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err)
|
console.error('Login error:', err)
|
||||||
|
|
||||||
if (err.statusCode === 404) {
|
if (err.statusCode === 500) {
|
||||||
loginError.value = 'No account found with that email. Please check your email or join Ghost Guild.'
|
|
||||||
} else if (err.statusCode === 500) {
|
|
||||||
loginError.value = 'Failed to send login email. Please try again later.'
|
loginError.value = 'Failed to send login email. Please try again later.'
|
||||||
} else {
|
} else {
|
||||||
loginError.value = err.statusMessage || 'Something went wrong. Please try again.'
|
loginError.value = err.statusMessage || 'Something went wrong. Please try again.'
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
import { marked } from 'marked'
|
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 = () => {
|
export const useMarkdown = () => {
|
||||||
const render = (markdown) => {
|
const render = (markdown) => {
|
||||||
if (!markdown) return ''
|
if (!markdown) return ''
|
||||||
return marked(markdown, {
|
const raw = marked(markdown, {
|
||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true
|
gfm: true
|
||||||
})
|
})
|
||||||
|
return DOMPurify.sanitize(raw, {
|
||||||
|
ALLOWED_TAGS,
|
||||||
|
ALLOWED_ATTR,
|
||||||
|
ALLOW_DATA_ATTR: false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
export default defineNuxtRouteMiddleware((to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
// Skip middleware in server-side rendering to avoid errors
|
if (import.meta.server) return
|
||||||
if (process.server) return
|
|
||||||
|
|
||||||
// TODO: Temporarily disabled for testing - enable when authentication is set up
|
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
|
||||||
// Check if user is authenticated (you'll need to implement proper auth state)
|
|
||||||
// const isAuthenticated = useCookie('auth-token').value
|
|
||||||
|
|
||||||
// if (!isAuthenticated) {
|
if (!isAuthenticated.value) {
|
||||||
// throw createError({
|
await checkMemberStatus()
|
||||||
// statusCode: 401,
|
}
|
||||||
// statusMessage: 'Authentication required'
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO: Add proper role-based authorization
|
if (!isAuthenticated.value) {
|
||||||
// For now, we assume anyone with a valid token is an admin
|
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
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: "2025-07-15",
|
compatibilityDate: "2025-07-15",
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: process.env.NODE_ENV !== 'production' },
|
||||||
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
||||||
build: {
|
build: {
|
||||||
transpile: ["vue-cal"],
|
transpile: ["vue-cal"],
|
||||||
|
|
@ -14,7 +14,7 @@ export default defineNuxtConfig({
|
||||||
// Private keys (server-side only)
|
// Private keys (server-side only)
|
||||||
mongodbUri:
|
mongodbUri:
|
||||||
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
|
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 || "",
|
resendApiKey: process.env.RESEND_API_KEY || "",
|
||||||
helcimApiToken: process.env.HELCIM_API_TOKEN || "",
|
helcimApiToken: process.env.HELCIM_API_TOKEN || "",
|
||||||
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,11 @@
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import Event from '../../models/event.js'
|
import Event from '../../models/event.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import jwt from 'jsonwebtoken'
|
import { requireAdmin } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
await requireAdmin(event)
|
||||||
// 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 connectDB()
|
await connectDB()
|
||||||
|
|
||||||
// Get stats
|
// Get stats
|
||||||
|
|
@ -62,6 +49,7 @@ export default defineEventHandler(async (event) => {
|
||||||
upcomingEvents
|
upcomingEvents
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.statusCode) throw error
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Failed to fetch dashboard data'
|
statusMessage: 'Failed to fetch dashboard data'
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
import Event from '../../models/event.js'
|
import Event from '../../models/event.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import jwt from 'jsonwebtoken'
|
import { requireAdmin } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
await requireAdmin(event)
|
||||||
// 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 connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const events = await Event.find()
|
const events = await Event.find()
|
||||||
|
|
@ -26,6 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return events
|
return events
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.statusCode) throw error
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Failed to fetch events'
|
statusMessage: 'Failed to fetch events'
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import Event from "../../models/event.js";
|
import Event from "../../models/event.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
import jwt from "jsonwebtoken";
|
import { requireAdmin } from "../../utils/auth.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
const admin = await requireAdmin(event);
|
||||||
// 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 body = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
|
@ -31,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
const eventData = {
|
const eventData = {
|
||||||
...body,
|
...body,
|
||||||
createdBy: "admin@ghostguild.org", // TODO: Use actual authenticated user
|
createdBy: admin.email,
|
||||||
startDate: new Date(body.startDate),
|
startDate: new Date(body.startDate),
|
||||||
endDate: new Date(body.endDate),
|
endDate: new Date(body.endDate),
|
||||||
registrationDeadline: body.registrationDeadline
|
registrationDeadline: body.registrationDeadline
|
||||||
|
|
@ -67,6 +56,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return savedEvent;
|
return savedEvent;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.statusCode) throw error;
|
||||||
console.error("Error creating event:", error);
|
console.error("Error creating event:", error);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import Event from '../../../models/event.js'
|
import Event from '../../../models/event.js'
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
import jwt from 'jsonwebtoken'
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
await requireAdmin(event)
|
||||||
// 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 eventId = getRouterParam(event, 'id')
|
const eventId = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
|
@ -32,6 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return { success: true, message: 'Event deleted successfully' }
|
return { success: true, message: 'Event deleted successfully' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.statusCode) throw error
|
||||||
console.error('Error deleting event:', error)
|
console.error('Error deleting event:', error)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,28 @@
|
||||||
import Event from '../../../models/event.js'
|
import Event from '../../../models/event.js'
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
import jwt from 'jsonwebtoken'
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
await requireAdmin(event)
|
||||||
// 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 eventId = getRouterParam(event, 'id')
|
const eventId = getRouterParam(event, 'id')
|
||||||
console.log('🔍 API: Get event by ID called')
|
|
||||||
console.log('🔍 API: Event ID param:', eventId)
|
|
||||||
|
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const eventData = await Event.findById(eventId)
|
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) {
|
if (!eventData) {
|
||||||
console.log('❌ API: Event not found in database')
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: 'Event not found'
|
statusMessage: 'Event not found'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ API: Returning event data')
|
|
||||||
return { data: eventData }
|
return { data: eventData }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ API: Error fetching event:', error)
|
if (error.statusCode) throw error
|
||||||
|
console.error('Error fetching event:', error)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: error.message || 'Failed to fetch event'
|
statusMessage: error.message || 'Failed to fetch event'
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import Event from '../../../models/event.js'
|
import Event from '../../../models/event.js'
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
import jwt from 'jsonwebtoken'
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
await requireAdmin(event)
|
||||||
// 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 eventId = getRouterParam(event, 'id')
|
const eventId = getRouterParam(event, 'id')
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
@ -70,6 +59,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return updatedEvent
|
return updatedEvent
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.statusCode) throw error
|
||||||
console.error('Error updating event:', error)
|
console.error('Error updating event:', error)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import jwt from 'jsonwebtoken'
|
import { requireAdmin } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
await requireAdmin(event)
|
||||||
// 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 connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const members = await Member.find()
|
const members = await Member.find()
|
||||||
|
|
@ -26,6 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return members
|
return members
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.statusCode) throw error
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Failed to fetch members'
|
statusMessage: 'Failed to fetch members'
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import jwt from 'jsonwebtoken'
|
import { requireAdmin } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
await requireAdmin(event)
|
||||||
// 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)
|
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import Series from "../../models/series.js";
|
import Series from "../../models/series.js";
|
||||||
import Event from "../../models/event.js";
|
import Event from "../../models/event.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
import { requireAdmin } from "../../utils/auth.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAdmin(event);
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
// Fetch all series
|
// Fetch all series
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import Series from '../../models/series.js'
|
import Series from '../../models/series.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
const admin = await requireAdmin(event)
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
@ -22,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
||||||
description: body.description,
|
description: body.description,
|
||||||
type: body.type || 'workshop_series',
|
type: body.type || 'workshop_series',
|
||||||
totalEvents: body.totalEvents || null,
|
totalEvents: body.totalEvents || null,
|
||||||
createdBy: 'admin' // TODO: Get from authentication
|
createdBy: admin.email
|
||||||
})
|
})
|
||||||
|
|
||||||
await newSeries.save()
|
await newSeries.save()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import Series from '../../models/series.js'
|
import Series from '../../models/series.js'
|
||||||
import Event from '../../models/event.js'
|
import Event from '../../models/event.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAdmin(event)
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import Series from '../../../models/series.js'
|
import Series from '../../../models/series.js'
|
||||||
import Event from '../../../models/event.js'
|
import Event from '../../../models/event.js'
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAdmin(event)
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const id = getRouterParam(event, 'id')
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import Series from '../../../models/series.js'
|
import Series from '../../../models/series.js'
|
||||||
import Event from '../../../models/event.js'
|
import Event from '../../../models/event.js'
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAdmin(event)
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const id = getRouterParam(event, 'id')
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import Series from '../../../models/series.js'
|
import Series from '../../../models/series.js'
|
||||||
import Event from '../../../models/event.js'
|
import Event from '../../../models/event.js'
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAdmin(event)
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const body = await readBody(event)
|
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 });
|
const member = await Member.findOne({ email });
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
throw createError({
|
// Return same response shape to prevent enumeration
|
||||||
statusCode: 404,
|
return {
|
||||||
statusMessage: "No account found with that email address",
|
success: true,
|
||||||
});
|
message: GENERIC_MESSAGE,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate magic link token
|
// Generate magic link token
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ memberId: member._id },
|
{ memberId: member._id },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: "15m" }, // Shorter expiry for security
|
{ expiresIn: "15m" },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the base URL for the magic link
|
// Get the base URL for the magic link
|
||||||
|
|
@ -65,7 +69,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Login link sent to your email",
|
message: GENERIC_MESSAGE,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send email:", error);
|
console.error("Failed to send email:", error);
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,14 @@
|
||||||
import jwt from "jsonwebtoken";
|
import { requireAuth } from "../../utils/auth.js";
|
||||||
import Member from "../../models/member.js";
|
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
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 {
|
return {
|
||||||
_id: member._id,
|
_id: member._id,
|
||||||
id: member._id,
|
id: member._id,
|
||||||
email: member.email,
|
email: member.email,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
role: member.role || 'member',
|
||||||
circle: member.circle,
|
circle: member.circle,
|
||||||
contributionTier: member.contributionTier,
|
contributionTier: member.contributionTier,
|
||||||
membershipLevel: `${member.circle}-${member.contributionTier}`,
|
membershipLevel: `${member.circle}-${member.contributionTier}`,
|
||||||
|
|
@ -50,11 +27,4 @@ export default defineEventHandler(async (event) => {
|
||||||
// Peer support
|
// Peer support
|
||||||
peerSupport: member.peerSupport,
|
peerSupport: member.peerSupport,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
|
||||||
console.error("Token verification error:", err);
|
|
||||||
throw createError({
|
|
||||||
statusCode: 401,
|
|
||||||
statusMessage: "Invalid or expired token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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()
|
await connectDB()
|
||||||
|
|
||||||
const token = getCookie(event, 'auth-token')
|
const token = getCookie(event, 'auth-token')
|
||||||
console.log('🔍 Auth status check - token exists:', !!token)
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return { authenticated: false, member: null }
|
return { authenticated: false, member: null }
|
||||||
}
|
}
|
||||||
|
|
@ -17,11 +15,13 @@ export default defineEventHandler(async (event) => {
|
||||||
const member = await Member.findById(decoded.memberId).select('-__v')
|
const member = await Member.findById(decoded.memberId).select('-__v')
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
console.log('⚠️ Token valid but member not found')
|
|
||||||
return { authenticated: false, member: null }
|
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 {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
member: {
|
member: {
|
||||||
|
|
@ -34,7 +34,6 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Auth status check - token verification failed:', err.message)
|
|
||||||
return { authenticated: false, member: null }
|
return { authenticated: false, member: null }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -33,22 +33,21 @@ export default defineEventHandler(async (event) => {
|
||||||
const sessionToken = jwt.sign(
|
const sessionToken = jwt.sign(
|
||||||
{ memberId: member._id, email: member.email },
|
{ memberId: member._id, email: member.email },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '30d' }
|
{ expiresIn: '7d' }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set the session cookie
|
// Set the session cookie
|
||||||
setCookie(event, 'auth-token', sessionToken, {
|
setCookie(event, 'auth-token', sessionToken, {
|
||||||
httpOnly: false, // Allow JavaScript access for debugging in development
|
httpOnly: true,
|
||||||
secure: false, // Don't require HTTPS in development
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
maxAge: 60 * 60 * 24 * 7 // 7 days
|
||||||
})
|
})
|
||||||
|
|
||||||
// Redirect to the members dashboard or home page
|
// Redirect to the members dashboard or home page
|
||||||
await sendRedirect(event, '/members', 302)
|
await sendRedirect(event, '/members', 302)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Token verification error:', err)
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Invalid or expired token'
|
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
|
// Test the connection first with native fetch
|
||||||
try {
|
try {
|
||||||
|
|
@ -55,8 +53,7 @@ export default defineEventHandler(async (event) => {
|
||||||
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
|
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const testData = await testResponse.json()
|
await testResponse.json()
|
||||||
console.log('Connection test passed:', testData)
|
|
||||||
} catch (testError) {
|
} catch (testError) {
|
||||||
console.error('Connection test failed:', testError)
|
console.error('Connection test failed:', testError)
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -113,18 +110,14 @@ export default defineEventHandler(async (event) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set the session cookie server-side
|
// 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, {
|
setCookie(event, 'auth-token', token, {
|
||||||
httpOnly: true, // Server-only for security
|
httpOnly: true,
|
||||||
secure: false, // Don't require HTTPS in development
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
path: '/',
|
path: '/',
|
||||||
domain: undefined // Let browser set domain automatically
|
domain: undefined // Let browser set domain automatically
|
||||||
})
|
})
|
||||||
console.log('Cookie set successfully')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
customerId: customerData.id,
|
customerId: customerData.id,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
// Initialize HelcimPay.js session
|
// Initialize HelcimPay.js session
|
||||||
|
import { requireAuth } from "../../utils/auth.js";
|
||||||
|
|
||||||
const HELCIM_API_BASE = "https://api.helcim.com/v2";
|
const HELCIM_API_BASE = "https://api.helcim.com/v2";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAuth(event);
|
||||||
const config = useRuntimeConfig(event);
|
const config = useRuntimeConfig(event);
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
|
||||||
// Debug log the request body
|
|
||||||
console.log("Initialize payment request body:", body);
|
|
||||||
|
|
||||||
const helcimToken =
|
const helcimToken =
|
||||||
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
|
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
|
||||||
|
|
@ -43,8 +44,6 @@ export default defineEventHandler(async (event) => {
|
||||||
requestBody.orderNumber = `${body.metadata.eventId}`;
|
requestBody.orderNumber = `${body.metadata.eventId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Helcim request body:", JSON.stringify(requestBody, null, 2));
|
|
||||||
|
|
||||||
// Initialize HelcimPay.js session
|
// Initialize HelcimPay.js session
|
||||||
const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, {
|
const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { getSlackService } from '../../utils/slack.ts'
|
import { getSlackService } from '../../utils/slack.ts'
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
|
||||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||||
|
|
||||||
|
|
@ -72,6 +73,7 @@ async function inviteToSlack(member) {
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAuth(event)
|
||||||
await connectDB()
|
await connectDB()
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const body = await readBody(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
|
// Check if payment is required
|
||||||
if (!requiresPayment(body.contributionTier)) {
|
if (!requiresPayment(body.contributionTier)) {
|
||||||
console.log('No payment required for tier:', body.contributionTier)
|
|
||||||
// For free tier, just update member status
|
// For free tier, just update member status
|
||||||
const member = await Member.findOneAndUpdate(
|
const member = await Member.findOneAndUpdate(
|
||||||
{ helcimCustomerId: body.customerId },
|
{ helcimCustomerId: body.customerId },
|
||||||
|
|
@ -107,8 +106,6 @@ export default defineEventHandler(async (event) => {
|
||||||
{ new: true }
|
{ new: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('Updated member for free tier:', member)
|
|
||||||
|
|
||||||
// Send Slack invitation for free tier members
|
// Send Slack invitation for free tier members
|
||||||
await inviteToSlack(member)
|
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
|
// Get the Helcim plan ID
|
||||||
const planId = getHelcimPlanId(body.contributionTier)
|
const planId = getHelcimPlanId(body.contributionTier)
|
||||||
console.log('Plan ID for tier:', planId)
|
|
||||||
|
|
||||||
// Validate card token is provided
|
// Validate card token is provided
|
||||||
if (!body.cardToken) {
|
if (!body.cardToken) {
|
||||||
|
|
@ -135,8 +129,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// Check if we have a configured plan for this tier
|
// Check if we have a configured plan for this tier
|
||||||
if (!planId) {
|
if (!planId) {
|
||||||
console.log('No Helcim plan configured for tier:', body.contributionTier)
|
|
||||||
|
|
||||||
const member = await Member.findOneAndUpdate(
|
const member = await Member.findOneAndUpdate(
|
||||||
{ helcimCustomerId: body.customerId },
|
{ helcimCustomerId: body.customerId },
|
||||||
{
|
{
|
||||||
|
|
@ -168,8 +160,6 @@ export default defineEventHandler(async (event) => {
|
||||||
// Try to create subscription in Helcim
|
// Try to create subscription in Helcim
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
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)
|
// Generate a proper alphanumeric idempotency key (exactly 25 characters)
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
let idempotencyKey = ''
|
let idempotencyKey = ''
|
||||||
|
|
@ -197,10 +187,6 @@ export default defineEventHandler(async (event) => {
|
||||||
'idempotency-key': idempotencyKey
|
'idempotency-key': idempotencyKey
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Subscription request body:', requestBody)
|
|
||||||
console.log('Request headers:', requestHeaders)
|
|
||||||
console.log('Request URL:', `${HELCIM_API_BASE}/subscriptions`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
|
const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -210,47 +196,11 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (!subscriptionResponse.ok) {
|
if (!subscriptionResponse.ok) {
|
||||||
const errorText = await subscriptionResponse.text()
|
const errorText = await subscriptionResponse.text()
|
||||||
console.error('Subscription creation failed:')
|
console.error('Subscription creation failed:', subscriptionResponse.status)
|
||||||
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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// If it's a validation error, let's try to get more info about available plans
|
// If it's a validation error, let's try to get more info about available plans
|
||||||
if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) {
|
if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) {
|
||||||
console.log('Plan might not exist. Trying to get list of available payment plans...')
|
// Plan might not exist -- update member status and proceed
|
||||||
|
|
||||||
// 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
|
|
||||||
const member = await Member.findOneAndUpdate(
|
const member = await Member.findOneAndUpdate(
|
||||||
{ helcimCustomerId: body.customerId },
|
{ helcimCustomerId: body.customerId },
|
||||||
{
|
{
|
||||||
|
|
@ -287,7 +237,6 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionData = await subscriptionResponse.json()
|
const subscriptionData = await subscriptionResponse.json()
|
||||||
console.log('Subscription created successfully:', subscriptionData)
|
|
||||||
|
|
||||||
// Extract the first subscription from the response array
|
// Extract the first subscription from the response array
|
||||||
const subscription = subscriptionData.data?.[0]
|
const subscription = subscriptionData.data?.[0]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
// Update customer billing address
|
// Update customer billing address
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
|
||||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAuth(event)
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
// Verify payment token from HelcimPay.js
|
// Verify payment token from HelcimPay.js
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
|
||||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAuth(event)
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
|
@ -14,25 +17,57 @@ export default defineEventHandler(async (event) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Payment verification request:', {
|
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
||||||
customerId: body.customerId,
|
|
||||||
cardToken: body.cardToken ? 'present' : 'missing'
|
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,
|
if (!response.ok) {
|
||||||
// we can just return success. The card is already associated with the customer.
|
const errorText = await response.text()
|
||||||
console.log('Payment already verified through HelcimPay.js, returning success')
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
cardToken: body.cardToken,
|
cardToken: body.cardToken,
|
||||||
message: 'Payment verified successfully through HelcimPay.js'
|
message: 'Payment verified with Helcim'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying payment:', error)
|
console.error('Error verifying payment:', error)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: error.statusCode || 500,
|
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
|
// Search by name or bio
|
||||||
if (search) {
|
if (search) {
|
||||||
|
// Escape special regex characters to prevent ReDoS
|
||||||
|
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
dbQuery.$or = [
|
dbQuery.$or = [
|
||||||
{ name: { $regex: search, $options: "i" } },
|
{ name: { $regex: escaped, $options: "i" } },
|
||||||
{ bio: { $regex: search, $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 is also present, combine with AND
|
||||||
if (search) {
|
if (search) {
|
||||||
|
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
dbQuery.$and = [
|
dbQuery.$and = [
|
||||||
{
|
{
|
||||||
$or: [
|
$or: [
|
||||||
{ name: { $regex: search, $options: "i" } },
|
{ name: { $regex: escaped, $options: "i" } },
|
||||||
{ bio: { $regex: search, $options: "i" } },
|
{ bio: { $regex: escaped, $options: "i" } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,9 @@
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import Member from "../../models/member.js";
|
import Member from "../../models/member.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { requireAuth } from "../../utils/auth.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await connectDB();
|
const authedMember = await requireAuth(event);
|
||||||
|
const memberId = authedMember._id;
|
||||||
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 body = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
|
@ -37,7 +17,6 @@ export default defineEventHandler(async (event) => {
|
||||||
"location",
|
"location",
|
||||||
"socialLinks",
|
"socialLinks",
|
||||||
"showInDirectory",
|
"showInDirectory",
|
||||||
"helcimCustomerId",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Define privacy fields
|
// Define privacy fields
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { v2 as cloudinary } from 'cloudinary'
|
import { v2 as cloudinary } from 'cloudinary'
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
|
||||||
// Configure Cloudinary
|
// Configure Cloudinary
|
||||||
cloudinary.config({
|
cloudinary.config({
|
||||||
|
|
@ -9,6 +10,7 @@ cloudinary.config({
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
await requireAuth(event)
|
||||||
// Parse the multipart form data
|
// Parse the multipart form data
|
||||||
const formData = await readMultipartFormData(event)
|
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(),
|
enum: getValidContributionValues(),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
role: {
|
||||||
|
type: String,
|
||||||
|
enum: ["member", "admin"],
|
||||||
|
default: "member",
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ["pending_payment", "active", "suspended", "cancelled"],
|
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 { Resend } from "resend";
|
||||||
|
import { escapeHtml } from "./escapeHtml.js";
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
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({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@babyghosts.org>",
|
from: "Ghost Guild <events@babyghosts.org>",
|
||||||
to: [registration.email],
|
to: [registration.email],
|
||||||
subject: `You're registered for ${eventData.title}`,
|
subject: `You're registered for ${escapeHtml(eventData.title)}`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -105,9 +106,9 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<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="event-details">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
|
|
@ -122,11 +123,11 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="label">Location</div>
|
<div class="label">Location</div>
|
||||||
<div class="value">${eventData.location}</div>
|
<div class="value">${escapeHtml(eventData.location)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${eventData.description ? `<p>${eventData.description}</p>` : ""}
|
${eventData.description ? `<p>${escapeHtml(eventData.description)}</p>` : ""}
|
||||||
|
|
||||||
${
|
${
|
||||||
registration.ticketType &&
|
registration.ticketType &&
|
||||||
|
|
@ -148,7 +149,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
||||||
? `
|
? `
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="label">Transaction ID</div>
|
<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>
|
</div>
|
||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -211,7 +212,7 @@ export async function sendEventCancellationEmail(registration, eventData) {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@ghostguild.org>",
|
from: "Ghost Guild <events@ghostguild.org>",
|
||||||
to: [registration.email],
|
to: [registration.email],
|
||||||
subject: `Registration cancelled: ${eventData.title}`,
|
subject: `Registration cancelled: ${escapeHtml(eventData.title)}`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -264,9 +265,9 @@ export async function sendEventCancellationEmail(registration, eventData) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<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>
|
<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({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@ghostguild.org>",
|
from: "Ghost Guild <events@ghostguild.org>",
|
||||||
to: [waitlistEntry.email],
|
to: [waitlistEntry.email],
|
||||||
subject: `A spot opened up for ${eventData.title}!`,
|
subject: `A spot opened up for ${escapeHtml(eventData.title)}!`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -413,9 +414,9 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<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">
|
<div class="urgent">
|
||||||
<p style="margin: 0; font-weight: 600; color: #92400e;">
|
<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="event-details">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="label">Event</div>
|
<div class="label">Event</div>
|
||||||
<div class="value">${eventData.title}</div>
|
<div class="value">${escapeHtml(eventData.title)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
|
|
@ -441,7 +442,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="label">Location</div>
|
<div class="label">Location</div>
|
||||||
<div class="value">${eventData.location}</div>
|
<div class="value">${escapeHtml(eventData.location)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -529,7 +530,7 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: "Ghost Guild <events@babyghosts.org>",
|
from: "Ghost Guild <events@babyghosts.org>",
|
||||||
to: [to],
|
to: [to],
|
||||||
subject: `Your Series Pass for ${series.title}`,
|
subject: `Your Series Pass for ${escapeHtml(series.title)}`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -620,10 +621,10 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<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>
|
<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"}.
|
You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -647,7 +648,7 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="label">Series</div>
|
<div class="label">Series</div>
|
||||||
<div class="value">${series.title}</div>
|
<div class="value">${escapeHtml(series.title)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${
|
||||||
|
|
@ -655,7 +656,7 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
? `
|
? `
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="label">About</div>
|
<div class="label">About</div>
|
||||||
<div class="value">${series.description}</div>
|
<div class="value">${escapeHtml(series.description)}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -676,7 +677,7 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
? `
|
? `
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="label">Transaction ID</div>
|
<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>
|
</div>
|
||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -699,7 +700,7 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
(event, index) => `
|
(event, index) => `
|
||||||
<div class="event-item">
|
<div class="event-item">
|
||||||
<div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;">
|
<div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;">
|
||||||
Event ${index + 1}: ${event.title}
|
Event ${index + 1}: ${escapeHtml(event.title)}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 14px; color: #666; margin: 5px 0;">
|
<div style="font-size: 14px; color: #666; margin: 5px 0;">
|
||||||
📅 ${formatDate(event.startDate)}
|
📅 ${formatDate(event.startDate)}
|
||||||
|
|
@ -708,7 +709,7 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
🕐 ${formatTime(event.startDate, event.endDate)}
|
🕐 ${formatTime(event.startDate, event.endDate)}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 14px; color: #666; margin: 5px 0;">
|
<div style="font-size: 14px; color: #666; margin: 5px 0;">
|
||||||
📍 ${event.location}
|
📍 ${escapeHtml(event.location)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue