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.
105 lines
2.5 KiB
JavaScript
105 lines
2.5 KiB
JavaScript
import Member from "../../models/member.js";
|
|
import { requireAuth } from "../../utils/auth.js";
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const authedMember = await requireAuth(event);
|
|
const memberId = authedMember._id;
|
|
|
|
const body = await readBody(event);
|
|
|
|
// Define allowed profile fields
|
|
const allowedFields = [
|
|
"pronouns",
|
|
"timeZone",
|
|
"avatar",
|
|
"studio",
|
|
"bio",
|
|
"location",
|
|
"socialLinks",
|
|
"showInDirectory",
|
|
];
|
|
|
|
// Define privacy fields
|
|
const privacyFields = [
|
|
"pronounsPrivacy",
|
|
"timeZonePrivacy",
|
|
"avatarPrivacy",
|
|
"studioPrivacy",
|
|
"bioPrivacy",
|
|
"locationPrivacy",
|
|
"socialLinksPrivacy",
|
|
"offeringPrivacy",
|
|
"lookingForPrivacy",
|
|
];
|
|
|
|
// Build update object
|
|
const updateData = {};
|
|
|
|
allowedFields.forEach((field) => {
|
|
if (body[field] !== undefined) {
|
|
updateData[field] = body[field];
|
|
}
|
|
});
|
|
|
|
// Handle offering and lookingFor separately (nested objects)
|
|
if (body.offering !== undefined) {
|
|
updateData.offering = {
|
|
text: body.offering.text || "",
|
|
tags: body.offering.tags || [],
|
|
};
|
|
}
|
|
if (body.lookingFor !== undefined) {
|
|
updateData.lookingFor = {
|
|
text: body.lookingFor.text || "",
|
|
tags: body.lookingFor.tags || [],
|
|
};
|
|
}
|
|
|
|
// Handle privacy settings
|
|
privacyFields.forEach((privacyField) => {
|
|
if (body[privacyField] !== undefined) {
|
|
const baseField = privacyField.replace("Privacy", "");
|
|
updateData[`privacy.${baseField}`] = body[privacyField];
|
|
}
|
|
});
|
|
|
|
try {
|
|
const member = await Member.findByIdAndUpdate(
|
|
memberId,
|
|
{ $set: updateData },
|
|
{ new: true, runValidators: true },
|
|
);
|
|
|
|
if (!member) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
message: "Member not found",
|
|
});
|
|
}
|
|
|
|
// Return sanitized member data
|
|
return {
|
|
id: member._id,
|
|
email: member.email,
|
|
name: member.name,
|
|
circle: member.circle,
|
|
contributionTier: member.contributionTier,
|
|
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,
|
|
};
|
|
} catch (error) {
|
|
console.error("Profile update error:", error);
|
|
throw createError({
|
|
statusCode: 500,
|
|
message: "Failed to update profile",
|
|
});
|
|
}
|
|
});
|