2026-01-25 02:19:49 +01:00
|
|
|
import { supabaseAdmin } from './supabase';
|
2026-01-26 15:25:41 +01:00
|
|
|
import { env as publicEnv } from '$env/dynamic/public';
|
2026-01-25 02:19:49 +01:00
|
|
|
import {
|
|
|
|
|
S3Client,
|
|
|
|
|
PutObjectCommand,
|
|
|
|
|
GetObjectCommand,
|
|
|
|
|
DeleteObjectCommand,
|
|
|
|
|
DeleteObjectsCommand,
|
|
|
|
|
ListObjectsV2Command,
|
|
|
|
|
HeadBucketCommand
|
|
|
|
|
} from '@aws-sdk/client-s3';
|
|
|
|
|
import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
|
|
|
|
|
|
|
|
export type StorageBucket = 'documents' | 'avatars' | 'event-images';
|
|
|
|
|
|
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
28 items across 7 batches. 36 files changed (9 new, 27 modified).
1061 insertions, 406 deletions.
== Batch 1: Critical Security Fixes ==
1.1 — Fix open redirect in /auth/callback
- src/routes/auth/callback/+server.ts: url.searchParams.get('next')
was used directly in redirect(303, next). Attacker could set
next=https://evil.com. Now wrapped through sanitizeRedirectUrl()
which rejects protocol/host, //, javascript: prefixes; falls back
to /dashboard.
1.2 — Fix open redirect in /login
- src/routes/(auth)/login/+page.server.ts: redirectTo param used
without validation in both load() and form action. Applied
sanitizeRedirectUrl() to both locations.
1.3 — Fix RLS self-role-escalation
- supabase/migrations/017_fix_rls_role_escalation.sql (NEW):
"Users can update own profile" policy had USING(auth.uid()=id)
but no WITH CHECK clause — users could SET role='admin' on their
own row. Added WITH CHECK constraining role to current value.
- deploy/init.sql: updated to match migration 017.
1.4 — Remove hardcoded secrets from docker-compose.yml
- docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback.
== Batch 2: Critical & High Bugs ==
2.1 — Fix deleteAvatar wrong argument type
- src/routes/(app)/settings/+page.server.ts: was passing supabase
client object as second arg to deleteAvatar(memberId, avatarPath).
Changed to pass member.avatar_url instead.
2.2 — Fix event.start_time typo -> event.start_datetime
- src/routes/(app)/board/events/[id]/attendees/+page.server.ts:
referenced event.start_time (doesn't exist on type). Caused
"Invalid Date" in invitation/roll-call emails. Replaced both
occurrences with event.start_datetime.
2.3 — Fix landing page CTA buttons missing href
- src/routes/+page.svelte: Sign In and Join Us buttons had no href
attribute — completely non-functional for visitors. Added
href="/login" and href="/join" respectively.
2.4 — Fix auth pages logo inconsistency
- src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter
in colored box replaced with actual Monaco USA logo image
(MONACOUSA-Flags_376x376.png) matching login/layout.
2.5 — Fix currency USD -> EUR everywhere
- src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale
to fr-MC.
== Batch 3: High Security Fixes ==
3.1 — Sanitize HTML in email template rendering
- src/lib/server/email.ts: added escapeHtml() utility that escapes
&, <, >, ", '. Applied to all template variable values in
sendTemplatedEmail() before substitution. URL-type keys
(logo_url, site_url) exempted. Prevents XSS in emails.
3.2 — Add file upload MIME type validation
- src/lib/server/storage.ts: added MAGIC_BYTES constant and
validateFileMagicBytes() function checking PNG (89504E47),
JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638)
magic bytes against declared MIME. Applied in uploadAvatar and
uploadDocument before storing.
3.3 — Docker container hardening
- docker-compose.yml portal service: added security_opt
[no-new-privileges:true], read_only: true with tmpfs for /tmp,
deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile
already had USER sveltekit (non-root).
3.4 — Restrict board endpoints data exposure
- src/routes/(app)/board/members/+page.server.ts: replaced
.select('*') with explicit column list returning only fields
the board UI actually displays. Removed sensitive columns.
== Batch 4: Shared Utilities ==
4.1 — Extract getVisibleLevels to shared utility
- src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role)
returning appropriate visibility levels per role.
- Replaced 4 duplicate definitions in:
src/routes/(app)/dashboard/+page.server.ts
src/routes/(app)/documents/+page.server.ts
src/routes/(app)/events/+page.server.ts
src/routes/(app)/events/[id]/+page.server.ts
4.3 — Fix N+1 query in getReminderEffectiveness
- src/lib/server/dues.ts: rewrote loop executing individual DB
queries per reminder into single batch query with IN filter.
Maps results in JS instead of N+1 round-trips.
== Batch 5: Shared UI Components ==
5.1 — Create reusable EmptyState component
- src/lib/components/ui/empty-state.svelte (NEW): accepts icon,
title, description props and optional children snippet. Consistent
muted-text centered layout matching design system.
- Applied in DocumentPreviewModal and NotificationCenter.
5.2 — Move LoadingSpinner to shared ui/
- src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from
auth/ to ui/ for general use. Original kept for compatibility.
- src/lib/components/ui/index.ts: added barrel exports for
EmptyState and LoadingSpinner.
== Batch 6: UX Standardization ==
6.4 — Add skip-to-content link
- src/routes/(app)/+layout.svelte: added visually-hidden-until-
focused skip link as first focusable element:
<a href="#main-content" class="sr-only focus:not-sr-only ...">
Added id="main-content" to <main> element.
6.5 — Add navigation loading indicator
- src/routes/(app)/+layout.svelte: imported SvelteKit $navigating
store. Shows thin animated progress bar at page top during
transitions. CSS-only animation, no external dependencies.
== Batch 7: Code Consolidation ==
7.1 — Consolidate profile/settings pages
- src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers
handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate().
Supports admin mode (supabaseAdmin) and user mode (scoped client).
- src/routes/(app)/profile/+page.server.ts: simplified from ~167
to ~88 lines using shared helpers.
- src/routes/(app)/settings/+page.server.ts: simplified from ~219
to ~106 lines using shared helpers.
7.2 — Consolidate registration flows
- src/lib/server/registration.ts (NEW, 201 lines): shared helpers
createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail().
- src/routes/(auth)/signup/+page.server.ts: simplified from ~167
to ~85 lines using shared helpers.
- src/routes/join/+page.server.ts: simplified from ~209 to ~117
lines using shared helpers.
7.3 — Create status badge utility
- src/lib/utils/status-badges.ts (NEW, 55 lines): centralized
STATUS_MAP for all status types (membership, dues, payment,
RSVP, event, roles). Exports getStatusConfig(),
getStatusBadgeClasses(), getStatusLabel().
7.4 — Create rate limiting utility
- src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory
Map-based rate limiter with TTL cleanup. Exports
checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit().
- Applied to login: 5 attempts per 15 min by email.
- Applied to forgot-password: 3 attempts per 15 min by email.
- src/routes/(auth)/login/+page.server.ts: added rate limit check
before signInWithPassword, reset on success.
- src/routes/(auth)/forgot-password/+page.server.ts: added rate
limit check before resetPasswordForEmail.
== New Files (9) ==
src/lib/server/auth-utils.ts
src/lib/server/visibility.ts
src/lib/server/member-profile.ts
src/lib/server/registration.ts
src/lib/server/rate-limit.ts
src/lib/server/email.ts (escapeHtml addition)
src/lib/server/storage.ts (validateFileMagicBytes addition)
src/lib/utils/status-badges.ts
src/lib/components/ui/empty-state.svelte
src/lib/components/ui/LoadingSpinner.svelte
supabase/migrations/017_fix_rls_role_escalation.sql
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
|
|
|
/**
|
|
|
|
|
* Magic byte signatures for common file types.
|
|
|
|
|
* Used to validate that file content matches the declared MIME type.
|
|
|
|
|
*/
|
|
|
|
|
const MAGIC_BYTES: Record<string, { offset: number; bytes: number[] }[]> = {
|
|
|
|
|
'image/png': [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47] }],
|
|
|
|
|
'image/jpeg': [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
|
|
|
|
|
'image/gif': [{ offset: 0, bytes: [0x47, 0x49, 0x46] }],
|
|
|
|
|
'application/pdf': [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }],
|
|
|
|
|
'image/webp': [
|
|
|
|
|
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF
|
|
|
|
|
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } // WEBP
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-10 18:03:46 +01:00
|
|
|
/**
|
|
|
|
|
* MIME type to expected file extensions mapping.
|
|
|
|
|
* Used to validate that file content matches the declared extension.
|
|
|
|
|
*/
|
|
|
|
|
const MIME_TO_EXTENSIONS: Record<string, string[]> = {
|
|
|
|
|
'application/pdf': ['pdf'],
|
|
|
|
|
'application/msword': ['doc'],
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
|
|
|
|
|
'application/vnd.ms-excel': ['xls'],
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
|
|
|
|
|
'application/vnd.ms-powerpoint': ['ppt'],
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
|
|
|
|
|
'text/plain': ['txt', 'text', 'log'],
|
|
|
|
|
'text/csv': ['csv'],
|
|
|
|
|
'application/json': ['json'],
|
|
|
|
|
'image/jpeg': ['jpg', 'jpeg'],
|
|
|
|
|
'image/png': ['png'],
|
|
|
|
|
'image/webp': ['webp'],
|
|
|
|
|
'image/gif': ['gif']
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sanitize a filename to prevent path traversal and other attacks.
|
|
|
|
|
* Removes directory separators, null bytes, and other dangerous characters.
|
|
|
|
|
*/
|
|
|
|
|
export function sanitizeFilename(filename: string): string {
|
|
|
|
|
return filename
|
|
|
|
|
// Remove null bytes
|
|
|
|
|
.replace(/\0/g, '')
|
|
|
|
|
// Remove directory separators
|
|
|
|
|
.replace(/[\/\\]/g, '_')
|
|
|
|
|
// Remove leading dots (hidden files / directory traversal)
|
|
|
|
|
.replace(/^\.+/, '')
|
|
|
|
|
// Replace non-safe characters
|
|
|
|
|
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
|
|
|
// Collapse multiple underscores
|
|
|
|
|
.replace(/_+/g, '_')
|
|
|
|
|
// Limit length
|
|
|
|
|
.substring(0, 255)
|
|
|
|
|
// Ensure not empty
|
|
|
|
|
|| 'unnamed_file';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate that file extension matches the declared MIME type.
|
|
|
|
|
*/
|
|
|
|
|
export function validateExtensionMatchesMime(filename: string, mimeType: string): boolean {
|
|
|
|
|
const ext = filename.split('.').pop()?.toLowerCase();
|
|
|
|
|
if (!ext) return false;
|
|
|
|
|
|
|
|
|
|
const allowedExtensions = MIME_TO_EXTENSIONS[mimeType];
|
|
|
|
|
if (!allowedExtensions) return true; // Unknown MIME type - allow
|
|
|
|
|
|
|
|
|
|
return allowedExtensions.includes(ext);
|
|
|
|
|
}
|
|
|
|
|
|
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
28 items across 7 batches. 36 files changed (9 new, 27 modified).
1061 insertions, 406 deletions.
== Batch 1: Critical Security Fixes ==
1.1 — Fix open redirect in /auth/callback
- src/routes/auth/callback/+server.ts: url.searchParams.get('next')
was used directly in redirect(303, next). Attacker could set
next=https://evil.com. Now wrapped through sanitizeRedirectUrl()
which rejects protocol/host, //, javascript: prefixes; falls back
to /dashboard.
1.2 — Fix open redirect in /login
- src/routes/(auth)/login/+page.server.ts: redirectTo param used
without validation in both load() and form action. Applied
sanitizeRedirectUrl() to both locations.
1.3 — Fix RLS self-role-escalation
- supabase/migrations/017_fix_rls_role_escalation.sql (NEW):
"Users can update own profile" policy had USING(auth.uid()=id)
but no WITH CHECK clause — users could SET role='admin' on their
own row. Added WITH CHECK constraining role to current value.
- deploy/init.sql: updated to match migration 017.
1.4 — Remove hardcoded secrets from docker-compose.yml
- docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback.
== Batch 2: Critical & High Bugs ==
2.1 — Fix deleteAvatar wrong argument type
- src/routes/(app)/settings/+page.server.ts: was passing supabase
client object as second arg to deleteAvatar(memberId, avatarPath).
Changed to pass member.avatar_url instead.
2.2 — Fix event.start_time typo -> event.start_datetime
- src/routes/(app)/board/events/[id]/attendees/+page.server.ts:
referenced event.start_time (doesn't exist on type). Caused
"Invalid Date" in invitation/roll-call emails. Replaced both
occurrences with event.start_datetime.
2.3 — Fix landing page CTA buttons missing href
- src/routes/+page.svelte: Sign In and Join Us buttons had no href
attribute — completely non-functional for visitors. Added
href="/login" and href="/join" respectively.
2.4 — Fix auth pages logo inconsistency
- src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter
in colored box replaced with actual Monaco USA logo image
(MONACOUSA-Flags_376x376.png) matching login/layout.
2.5 — Fix currency USD -> EUR everywhere
- src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale
to fr-MC.
== Batch 3: High Security Fixes ==
3.1 — Sanitize HTML in email template rendering
- src/lib/server/email.ts: added escapeHtml() utility that escapes
&, <, >, ", '. Applied to all template variable values in
sendTemplatedEmail() before substitution. URL-type keys
(logo_url, site_url) exempted. Prevents XSS in emails.
3.2 — Add file upload MIME type validation
- src/lib/server/storage.ts: added MAGIC_BYTES constant and
validateFileMagicBytes() function checking PNG (89504E47),
JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638)
magic bytes against declared MIME. Applied in uploadAvatar and
uploadDocument before storing.
3.3 — Docker container hardening
- docker-compose.yml portal service: added security_opt
[no-new-privileges:true], read_only: true with tmpfs for /tmp,
deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile
already had USER sveltekit (non-root).
3.4 — Restrict board endpoints data exposure
- src/routes/(app)/board/members/+page.server.ts: replaced
.select('*') with explicit column list returning only fields
the board UI actually displays. Removed sensitive columns.
== Batch 4: Shared Utilities ==
4.1 — Extract getVisibleLevels to shared utility
- src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role)
returning appropriate visibility levels per role.
- Replaced 4 duplicate definitions in:
src/routes/(app)/dashboard/+page.server.ts
src/routes/(app)/documents/+page.server.ts
src/routes/(app)/events/+page.server.ts
src/routes/(app)/events/[id]/+page.server.ts
4.3 — Fix N+1 query in getReminderEffectiveness
- src/lib/server/dues.ts: rewrote loop executing individual DB
queries per reminder into single batch query with IN filter.
Maps results in JS instead of N+1 round-trips.
== Batch 5: Shared UI Components ==
5.1 — Create reusable EmptyState component
- src/lib/components/ui/empty-state.svelte (NEW): accepts icon,
title, description props and optional children snippet. Consistent
muted-text centered layout matching design system.
- Applied in DocumentPreviewModal and NotificationCenter.
5.2 — Move LoadingSpinner to shared ui/
- src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from
auth/ to ui/ for general use. Original kept for compatibility.
- src/lib/components/ui/index.ts: added barrel exports for
EmptyState and LoadingSpinner.
== Batch 6: UX Standardization ==
6.4 — Add skip-to-content link
- src/routes/(app)/+layout.svelte: added visually-hidden-until-
focused skip link as first focusable element:
<a href="#main-content" class="sr-only focus:not-sr-only ...">
Added id="main-content" to <main> element.
6.5 — Add navigation loading indicator
- src/routes/(app)/+layout.svelte: imported SvelteKit $navigating
store. Shows thin animated progress bar at page top during
transitions. CSS-only animation, no external dependencies.
== Batch 7: Code Consolidation ==
7.1 — Consolidate profile/settings pages
- src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers
handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate().
Supports admin mode (supabaseAdmin) and user mode (scoped client).
- src/routes/(app)/profile/+page.server.ts: simplified from ~167
to ~88 lines using shared helpers.
- src/routes/(app)/settings/+page.server.ts: simplified from ~219
to ~106 lines using shared helpers.
7.2 — Consolidate registration flows
- src/lib/server/registration.ts (NEW, 201 lines): shared helpers
createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail().
- src/routes/(auth)/signup/+page.server.ts: simplified from ~167
to ~85 lines using shared helpers.
- src/routes/join/+page.server.ts: simplified from ~209 to ~117
lines using shared helpers.
7.3 — Create status badge utility
- src/lib/utils/status-badges.ts (NEW, 55 lines): centralized
STATUS_MAP for all status types (membership, dues, payment,
RSVP, event, roles). Exports getStatusConfig(),
getStatusBadgeClasses(), getStatusLabel().
7.4 — Create rate limiting utility
- src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory
Map-based rate limiter with TTL cleanup. Exports
checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit().
- Applied to login: 5 attempts per 15 min by email.
- Applied to forgot-password: 3 attempts per 15 min by email.
- src/routes/(auth)/login/+page.server.ts: added rate limit check
before signInWithPassword, reset on success.
- src/routes/(auth)/forgot-password/+page.server.ts: added rate
limit check before resetPasswordForEmail.
== New Files (9) ==
src/lib/server/auth-utils.ts
src/lib/server/visibility.ts
src/lib/server/member-profile.ts
src/lib/server/registration.ts
src/lib/server/rate-limit.ts
src/lib/server/email.ts (escapeHtml addition)
src/lib/server/storage.ts (validateFileMagicBytes addition)
src/lib/utils/status-badges.ts
src/lib/components/ui/empty-state.svelte
src/lib/components/ui/LoadingSpinner.svelte
supabase/migrations/017_fix_rls_role_escalation.sql
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
|
|
|
/**
|
|
|
|
|
* Validate that a file's magic bytes match the declared MIME type.
|
|
|
|
|
* Returns true if the magic bytes match, or if the type has no known magic byte signature
|
|
|
|
|
* (e.g., office documents). Returns false if magic bytes are checked and don't match.
|
|
|
|
|
*/
|
|
|
|
|
export function validateFileMagicBytes(buffer: ArrayBuffer, declaredType: string): boolean {
|
|
|
|
|
const signatures = MAGIC_BYTES[declaredType];
|
|
|
|
|
if (!signatures) {
|
|
|
|
|
// No magic byte check for this type — allow it
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const view = new Uint8Array(buffer);
|
|
|
|
|
|
|
|
|
|
for (const sig of signatures) {
|
|
|
|
|
if (view.length < sig.offset + sig.bytes.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < sig.bytes.length; i++) {
|
|
|
|
|
if (view[sig.offset + i] !== sig.bytes[i]) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
/**
|
|
|
|
|
* Generate a browser-accessible public URL for Supabase Storage
|
|
|
|
|
* This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL
|
|
|
|
|
*/
|
|
|
|
|
function getBrowserAccessibleUrl(bucket: StorageBucket, path: string): string {
|
2026-01-26 15:25:41 +01:00
|
|
|
return `${publicEnv.PUBLIC_SUPABASE_URL}/storage/v1/object/public/${bucket}/${path}`;
|
2026-01-25 02:19:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UploadResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
path?: string;
|
|
|
|
|
publicUrl?: string;
|
|
|
|
|
localUrl?: string;
|
|
|
|
|
s3Url?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface S3Config {
|
|
|
|
|
endpoint: string;
|
|
|
|
|
bucket: string;
|
|
|
|
|
accessKey: string;
|
|
|
|
|
secretKey: string;
|
|
|
|
|
region: string;
|
|
|
|
|
useSSL: boolean;
|
|
|
|
|
forcePathStyle: boolean;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let s3ClientCache: S3Client | null = null;
|
|
|
|
|
let s3ConfigCache: S3Config | null = null;
|
|
|
|
|
let s3ConfigCacheTime: number = 0;
|
|
|
|
|
const S3_CONFIG_CACHE_TTL = 60000; // 1 minute cache
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get S3 configuration from app_settings table
|
|
|
|
|
*/
|
|
|
|
|
export async function getS3Config(): Promise<S3Config | null> {
|
|
|
|
|
// Check cache
|
|
|
|
|
if (s3ConfigCache && Date.now() - s3ConfigCacheTime < S3_CONFIG_CACHE_TTL) {
|
|
|
|
|
return s3ConfigCache;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { data: settings } = await supabaseAdmin
|
|
|
|
|
.from('app_settings')
|
|
|
|
|
.select('setting_key, setting_value')
|
|
|
|
|
.eq('category', 'storage');
|
|
|
|
|
|
|
|
|
|
if (!settings || settings.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const config: Record<string, any> = {};
|
|
|
|
|
for (const s of settings) {
|
|
|
|
|
let value = s.setting_value;
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
// Remove surrounding quotes if present (from JSON stringified values)
|
|
|
|
|
value = value.replace(/^"|"$/g, '');
|
|
|
|
|
}
|
|
|
|
|
config[s.setting_key] = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if S3 is enabled - handle both boolean true and string 'true'
|
|
|
|
|
const isEnabled = config.s3_enabled === true || config.s3_enabled === 'true';
|
|
|
|
|
|
|
|
|
|
// Check if S3 is enabled and configured
|
|
|
|
|
if (!isEnabled || !config.s3_endpoint || !config.s3_access_key || !config.s3_secret_key) {
|
|
|
|
|
console.log('S3 config check failed:', {
|
|
|
|
|
isEnabled,
|
|
|
|
|
hasEndpoint: !!config.s3_endpoint,
|
|
|
|
|
hasAccessKey: !!config.s3_access_key,
|
|
|
|
|
hasSecretKey: !!config.s3_secret_key
|
|
|
|
|
});
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s3ConfigCache = {
|
|
|
|
|
endpoint: config.s3_endpoint,
|
|
|
|
|
bucket: config.s3_bucket || 'monacousa-documents',
|
|
|
|
|
accessKey: config.s3_access_key,
|
|
|
|
|
secretKey: config.s3_secret_key,
|
|
|
|
|
region: config.s3_region || 'us-east-1',
|
|
|
|
|
useSSL: config.s3_use_ssl === true || config.s3_use_ssl === 'true',
|
|
|
|
|
forcePathStyle: config.s3_force_path_style === true || config.s3_force_path_style === 'true' || config.s3_force_path_style === undefined,
|
|
|
|
|
enabled: true
|
|
|
|
|
};
|
|
|
|
|
s3ConfigCacheTime = Date.now();
|
|
|
|
|
|
|
|
|
|
return s3ConfigCache;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get or create S3 client
|
|
|
|
|
*/
|
|
|
|
|
export async function getS3Client(): Promise<S3Client | null> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
if (!config) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return cached client if config hasn't changed
|
|
|
|
|
if (s3ClientCache && s3ConfigCache) {
|
|
|
|
|
return s3ClientCache;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s3ClientCache = new S3Client({
|
|
|
|
|
endpoint: config.endpoint,
|
|
|
|
|
region: config.region,
|
|
|
|
|
credentials: {
|
|
|
|
|
accessKeyId: config.accessKey,
|
|
|
|
|
secretAccessKey: config.secretKey
|
|
|
|
|
},
|
|
|
|
|
forcePathStyle: config.forcePathStyle
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return s3ClientCache;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear S3 client cache (call when settings change)
|
|
|
|
|
*/
|
|
|
|
|
export function clearS3ClientCache(): void {
|
|
|
|
|
s3ClientCache = null;
|
|
|
|
|
s3ConfigCache = null;
|
|
|
|
|
s3ConfigCacheTime = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Test S3 connection
|
|
|
|
|
*/
|
|
|
|
|
export async function testS3Connection(): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
if (!config) {
|
|
|
|
|
return { success: false, error: 'S3 not configured. Please configure and enable S3 storage settings first.' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const client = await getS3Client();
|
|
|
|
|
if (!client) {
|
|
|
|
|
return { success: false, error: 'Failed to create S3 client' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await client.send(new HeadBucketCommand({ Bucket: config.bucket }));
|
|
|
|
|
return { success: true };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('S3 connection test error:', error);
|
|
|
|
|
return { success: false, error: `S3 connection failed: ${errorMessage}` };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if S3 storage is enabled
|
|
|
|
|
*/
|
|
|
|
|
export async function isS3Enabled(): Promise<boolean> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
return config !== null && config.enabled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the S3 key with bucket prefix for organization
|
|
|
|
|
*/
|
|
|
|
|
function getS3Key(bucket: StorageBucket, path: string): string {
|
|
|
|
|
return `${bucket}/${path}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload a file to S3
|
|
|
|
|
*/
|
|
|
|
|
async function uploadToS3(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
path: string,
|
|
|
|
|
file: File | ArrayBuffer | Buffer,
|
|
|
|
|
options?: {
|
|
|
|
|
contentType?: string;
|
|
|
|
|
}
|
|
|
|
|
): Promise<UploadResult> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
const client = await getS3Client();
|
|
|
|
|
|
|
|
|
|
if (!config || !client) {
|
|
|
|
|
return { success: false, error: 'S3 not configured' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const key = getS3Key(bucket, path);
|
|
|
|
|
let body: Buffer;
|
|
|
|
|
|
|
|
|
|
if (file instanceof ArrayBuffer) {
|
|
|
|
|
body = Buffer.from(file);
|
|
|
|
|
} else if (Buffer.isBuffer(file)) {
|
|
|
|
|
body = file;
|
|
|
|
|
} else {
|
|
|
|
|
// It's a File object
|
|
|
|
|
body = Buffer.from(await file.arrayBuffer());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await client.send(
|
|
|
|
|
new PutObjectCommand({
|
|
|
|
|
Bucket: config.bucket,
|
|
|
|
|
Key: key,
|
|
|
|
|
Body: body,
|
|
|
|
|
ContentType: options?.contentType
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Construct public URL
|
|
|
|
|
const protocol = config.useSSL ? 'https' : 'http';
|
|
|
|
|
let publicUrl: string;
|
|
|
|
|
if (config.forcePathStyle) {
|
|
|
|
|
publicUrl = `${config.endpoint}/${config.bucket}/${key}`;
|
|
|
|
|
} else {
|
|
|
|
|
publicUrl = `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
path: key,
|
|
|
|
|
publicUrl
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('S3 upload error:', error);
|
|
|
|
|
return { success: false, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a signed URL from S3
|
|
|
|
|
*/
|
|
|
|
|
async function getS3PresignedUrl(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
path: string,
|
|
|
|
|
expiresIn: number = 3600
|
|
|
|
|
): Promise<{ url: string | null; error: string | null }> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
const client = await getS3Client();
|
|
|
|
|
|
|
|
|
|
if (!config || !client) {
|
|
|
|
|
return { url: null, error: 'S3 not configured' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const key = getS3Key(bucket, path);
|
|
|
|
|
const command = new GetObjectCommand({
|
|
|
|
|
Bucket: config.bucket,
|
|
|
|
|
Key: key
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const url = await getS3SignedUrl(client, command, { expiresIn });
|
|
|
|
|
return { url, error: null };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('S3 signed URL error:', error);
|
|
|
|
|
return { url: null, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a file from S3
|
|
|
|
|
*/
|
|
|
|
|
async function deleteFromS3(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
path: string
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
const client = await getS3Client();
|
|
|
|
|
|
|
|
|
|
if (!config || !client) {
|
|
|
|
|
return { success: false, error: 'S3 not configured' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const key = getS3Key(bucket, path);
|
|
|
|
|
await client.send(
|
|
|
|
|
new DeleteObjectCommand({
|
|
|
|
|
Bucket: config.bucket,
|
|
|
|
|
Key: key
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
return { success: true };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('S3 delete error:', error);
|
|
|
|
|
return { success: false, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete multiple files from S3
|
|
|
|
|
*/
|
|
|
|
|
async function deleteMultipleFromS3(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
paths: string[]
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
const client = await getS3Client();
|
|
|
|
|
|
|
|
|
|
if (!config || !client) {
|
|
|
|
|
return { success: false, error: 'S3 not configured' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const objects = paths.map((p) => ({ Key: getS3Key(bucket, p) }));
|
|
|
|
|
await client.send(
|
|
|
|
|
new DeleteObjectsCommand({
|
|
|
|
|
Bucket: config.bucket,
|
|
|
|
|
Delete: { Objects: objects }
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
return { success: true };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('S3 delete multiple error:', error);
|
|
|
|
|
return { success: false, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List files from S3
|
|
|
|
|
*/
|
|
|
|
|
async function listFilesFromS3(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
folder?: string,
|
|
|
|
|
options?: {
|
|
|
|
|
limit?: number;
|
|
|
|
|
}
|
|
|
|
|
): Promise<{ files: any[]; error: string | null }> {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
const client = await getS3Client();
|
|
|
|
|
|
|
|
|
|
if (!config || !client) {
|
|
|
|
|
return { files: [], error: 'S3 not configured' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const prefix = folder ? `${bucket}/${folder}/` : `${bucket}/`;
|
|
|
|
|
const response = await client.send(
|
|
|
|
|
new ListObjectsV2Command({
|
|
|
|
|
Bucket: config.bucket,
|
|
|
|
|
Prefix: prefix,
|
|
|
|
|
MaxKeys: options?.limit || 100
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const files = (response.Contents || []).map((obj) => ({
|
|
|
|
|
name: obj.Key?.replace(prefix, '') || '',
|
|
|
|
|
size: obj.Size,
|
|
|
|
|
updated_at: obj.LastModified?.toISOString(),
|
|
|
|
|
created_at: obj.LastModified?.toISOString()
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
return { files, error: null };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('S3 list error:', error);
|
|
|
|
|
return { files: [], error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===========================================
|
|
|
|
|
// PUBLIC API - Uses S3 or Supabase based on settings
|
|
|
|
|
// ===========================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload a file to storage (S3 or Supabase)
|
|
|
|
|
*/
|
|
|
|
|
export async function uploadFile(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
path: string,
|
|
|
|
|
file: File | ArrayBuffer,
|
|
|
|
|
options?: {
|
|
|
|
|
contentType?: string;
|
|
|
|
|
cacheControl?: string;
|
|
|
|
|
upsert?: boolean;
|
|
|
|
|
}
|
|
|
|
|
): Promise<UploadResult> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
return uploadToS3(bucket, path, file, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to Supabase Storage
|
|
|
|
|
try {
|
|
|
|
|
const { data, error } = await supabaseAdmin.storage.from(bucket).upload(path, file, {
|
|
|
|
|
contentType: options?.contentType,
|
|
|
|
|
cacheControl: options?.cacheControl || '3600',
|
|
|
|
|
upsert: options?.upsert || false
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
console.error('Storage upload error:', error);
|
|
|
|
|
return { success: false, error: error.message };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate browser-accessible public URL (not the internal Docker URL)
|
|
|
|
|
const publicUrl = getBrowserAccessibleUrl(bucket, path);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
path: data.path,
|
|
|
|
|
publicUrl
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('Storage upload exception:', error);
|
|
|
|
|
return { success: false, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the public URL for a file in storage
|
|
|
|
|
*/
|
|
|
|
|
export async function getPublicUrl(bucket: StorageBucket, path: string): Promise<string> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
const config = await getS3Config();
|
|
|
|
|
if (config) {
|
|
|
|
|
const key = getS3Key(bucket, path);
|
|
|
|
|
if (config.forcePathStyle) {
|
|
|
|
|
return `${config.endpoint}/${config.bucket}/${key}`;
|
|
|
|
|
}
|
|
|
|
|
const protocol = config.useSSL ? 'https' : 'http';
|
|
|
|
|
return `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to Supabase Storage - use browser-accessible URL
|
|
|
|
|
return getBrowserAccessibleUrl(bucket, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a signed URL for private file access
|
|
|
|
|
*/
|
|
|
|
|
export async function getSignedUrl(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
path: string,
|
|
|
|
|
expiresIn: number = 3600
|
|
|
|
|
): Promise<{ url: string | null; error: string | null }> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
return getS3PresignedUrl(bucket, path, expiresIn);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to Supabase Storage
|
|
|
|
|
const { data, error } = await supabaseAdmin.storage
|
|
|
|
|
.from(bucket)
|
|
|
|
|
.createSignedUrl(path, expiresIn);
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return { url: null, error: error.message };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { url: data.signedUrl, error: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a file from storage
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteFile(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
path: string
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
return deleteFromS3(bucket, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to Supabase Storage
|
|
|
|
|
const { error } = await supabaseAdmin.storage.from(bucket).remove([path]);
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
console.error('Storage delete error:', error);
|
|
|
|
|
return { success: false, error: error.message };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete multiple files from storage
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteFiles(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
paths: string[]
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
return deleteMultipleFromS3(bucket, paths);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to Supabase Storage
|
|
|
|
|
const { error } = await supabaseAdmin.storage.from(bucket).remove(paths);
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
console.error('Storage delete error:', error);
|
|
|
|
|
return { success: false, error: error.message };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List files in a bucket/folder
|
|
|
|
|
*/
|
|
|
|
|
export async function listFiles(
|
|
|
|
|
bucket: StorageBucket,
|
|
|
|
|
folder?: string,
|
|
|
|
|
options?: {
|
|
|
|
|
limit?: number;
|
|
|
|
|
offset?: number;
|
|
|
|
|
sortBy?: { column: string; order: 'asc' | 'desc' };
|
|
|
|
|
}
|
|
|
|
|
): Promise<{ files: any[]; error: string | null }> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
return listFilesFromS3(bucket, folder, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to Supabase Storage
|
|
|
|
|
const { data, error } = await supabaseAdmin.storage.from(bucket).list(folder || '', {
|
|
|
|
|
limit: options?.limit || 100,
|
|
|
|
|
offset: options?.offset || 0,
|
|
|
|
|
sortBy: options?.sortBy || { column: 'created_at', order: 'desc' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return { files: [], error: error.message };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { files: data || [], error: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a unique filename with timestamp
|
|
|
|
|
*/
|
|
|
|
|
export function generateUniqueFilename(originalName: string): string {
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
const randomStr = Math.random().toString(36).substring(2, 8);
|
2026-02-10 18:03:46 +01:00
|
|
|
const safeName = sanitizeFilename(originalName).substring(0, 50);
|
2026-01-25 02:19:49 +01:00
|
|
|
const ext = safeName.split('.').pop() || '';
|
|
|
|
|
const nameWithoutExt = safeName.replace(`.${ext}`, '');
|
|
|
|
|
return `${timestamp}-${randomStr}-${nameWithoutExt}.${ext}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload an avatar image for a member
|
|
|
|
|
* Returns both S3 and local URLs for storage flexibility
|
|
|
|
|
*/
|
|
|
|
|
export async function uploadAvatar(
|
|
|
|
|
memberId: string,
|
|
|
|
|
file: File,
|
|
|
|
|
userSupabase?: ReturnType<typeof import('@supabase/supabase-js').createClient>
|
|
|
|
|
): Promise<UploadResult> {
|
|
|
|
|
// Validate file type
|
|
|
|
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
|
|
|
return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP, GIF' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate file size (max 5MB)
|
|
|
|
|
const maxSize = 5 * 1024 * 1024;
|
|
|
|
|
if (file.size > maxSize) {
|
|
|
|
|
return { success: false, error: 'Image size must be less than 5MB' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate path - memberId must match auth.uid() for RLS
|
|
|
|
|
const ext = file.name.split('.').pop() || 'jpg';
|
|
|
|
|
const path = `${memberId}/avatar.${ext}`;
|
|
|
|
|
|
|
|
|
|
// Convert to ArrayBuffer
|
|
|
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
|
|
|
|
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
28 items across 7 batches. 36 files changed (9 new, 27 modified).
1061 insertions, 406 deletions.
== Batch 1: Critical Security Fixes ==
1.1 — Fix open redirect in /auth/callback
- src/routes/auth/callback/+server.ts: url.searchParams.get('next')
was used directly in redirect(303, next). Attacker could set
next=https://evil.com. Now wrapped through sanitizeRedirectUrl()
which rejects protocol/host, //, javascript: prefixes; falls back
to /dashboard.
1.2 — Fix open redirect in /login
- src/routes/(auth)/login/+page.server.ts: redirectTo param used
without validation in both load() and form action. Applied
sanitizeRedirectUrl() to both locations.
1.3 — Fix RLS self-role-escalation
- supabase/migrations/017_fix_rls_role_escalation.sql (NEW):
"Users can update own profile" policy had USING(auth.uid()=id)
but no WITH CHECK clause — users could SET role='admin' on their
own row. Added WITH CHECK constraining role to current value.
- deploy/init.sql: updated to match migration 017.
1.4 — Remove hardcoded secrets from docker-compose.yml
- docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback.
== Batch 2: Critical & High Bugs ==
2.1 — Fix deleteAvatar wrong argument type
- src/routes/(app)/settings/+page.server.ts: was passing supabase
client object as second arg to deleteAvatar(memberId, avatarPath).
Changed to pass member.avatar_url instead.
2.2 — Fix event.start_time typo -> event.start_datetime
- src/routes/(app)/board/events/[id]/attendees/+page.server.ts:
referenced event.start_time (doesn't exist on type). Caused
"Invalid Date" in invitation/roll-call emails. Replaced both
occurrences with event.start_datetime.
2.3 — Fix landing page CTA buttons missing href
- src/routes/+page.svelte: Sign In and Join Us buttons had no href
attribute — completely non-functional for visitors. Added
href="/login" and href="/join" respectively.
2.4 — Fix auth pages logo inconsistency
- src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter
in colored box replaced with actual Monaco USA logo image
(MONACOUSA-Flags_376x376.png) matching login/layout.
2.5 — Fix currency USD -> EUR everywhere
- src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale
to fr-MC.
== Batch 3: High Security Fixes ==
3.1 — Sanitize HTML in email template rendering
- src/lib/server/email.ts: added escapeHtml() utility that escapes
&, <, >, ", '. Applied to all template variable values in
sendTemplatedEmail() before substitution. URL-type keys
(logo_url, site_url) exempted. Prevents XSS in emails.
3.2 — Add file upload MIME type validation
- src/lib/server/storage.ts: added MAGIC_BYTES constant and
validateFileMagicBytes() function checking PNG (89504E47),
JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638)
magic bytes against declared MIME. Applied in uploadAvatar and
uploadDocument before storing.
3.3 — Docker container hardening
- docker-compose.yml portal service: added security_opt
[no-new-privileges:true], read_only: true with tmpfs for /tmp,
deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile
already had USER sveltekit (non-root).
3.4 — Restrict board endpoints data exposure
- src/routes/(app)/board/members/+page.server.ts: replaced
.select('*') with explicit column list returning only fields
the board UI actually displays. Removed sensitive columns.
== Batch 4: Shared Utilities ==
4.1 — Extract getVisibleLevels to shared utility
- src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role)
returning appropriate visibility levels per role.
- Replaced 4 duplicate definitions in:
src/routes/(app)/dashboard/+page.server.ts
src/routes/(app)/documents/+page.server.ts
src/routes/(app)/events/+page.server.ts
src/routes/(app)/events/[id]/+page.server.ts
4.3 — Fix N+1 query in getReminderEffectiveness
- src/lib/server/dues.ts: rewrote loop executing individual DB
queries per reminder into single batch query with IN filter.
Maps results in JS instead of N+1 round-trips.
== Batch 5: Shared UI Components ==
5.1 — Create reusable EmptyState component
- src/lib/components/ui/empty-state.svelte (NEW): accepts icon,
title, description props and optional children snippet. Consistent
muted-text centered layout matching design system.
- Applied in DocumentPreviewModal and NotificationCenter.
5.2 — Move LoadingSpinner to shared ui/
- src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from
auth/ to ui/ for general use. Original kept for compatibility.
- src/lib/components/ui/index.ts: added barrel exports for
EmptyState and LoadingSpinner.
== Batch 6: UX Standardization ==
6.4 — Add skip-to-content link
- src/routes/(app)/+layout.svelte: added visually-hidden-until-
focused skip link as first focusable element:
<a href="#main-content" class="sr-only focus:not-sr-only ...">
Added id="main-content" to <main> element.
6.5 — Add navigation loading indicator
- src/routes/(app)/+layout.svelte: imported SvelteKit $navigating
store. Shows thin animated progress bar at page top during
transitions. CSS-only animation, no external dependencies.
== Batch 7: Code Consolidation ==
7.1 — Consolidate profile/settings pages
- src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers
handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate().
Supports admin mode (supabaseAdmin) and user mode (scoped client).
- src/routes/(app)/profile/+page.server.ts: simplified from ~167
to ~88 lines using shared helpers.
- src/routes/(app)/settings/+page.server.ts: simplified from ~219
to ~106 lines using shared helpers.
7.2 — Consolidate registration flows
- src/lib/server/registration.ts (NEW, 201 lines): shared helpers
createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail().
- src/routes/(auth)/signup/+page.server.ts: simplified from ~167
to ~85 lines using shared helpers.
- src/routes/join/+page.server.ts: simplified from ~209 to ~117
lines using shared helpers.
7.3 — Create status badge utility
- src/lib/utils/status-badges.ts (NEW, 55 lines): centralized
STATUS_MAP for all status types (membership, dues, payment,
RSVP, event, roles). Exports getStatusConfig(),
getStatusBadgeClasses(), getStatusLabel().
7.4 — Create rate limiting utility
- src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory
Map-based rate limiter with TTL cleanup. Exports
checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit().
- Applied to login: 5 attempts per 15 min by email.
- Applied to forgot-password: 3 attempts per 15 min by email.
- src/routes/(auth)/login/+page.server.ts: added rate limit check
before signInWithPassword, reset on success.
- src/routes/(auth)/forgot-password/+page.server.ts: added rate
limit check before resetPasswordForEmail.
== New Files (9) ==
src/lib/server/auth-utils.ts
src/lib/server/visibility.ts
src/lib/server/member-profile.ts
src/lib/server/registration.ts
src/lib/server/rate-limit.ts
src/lib/server/email.ts (escapeHtml addition)
src/lib/server/storage.ts (validateFileMagicBytes addition)
src/lib/utils/status-badges.ts
src/lib/components/ui/empty-state.svelte
src/lib/components/ui/LoadingSpinner.svelte
supabase/migrations/017_fix_rls_role_escalation.sql
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
|
|
|
// Validate magic bytes match declared MIME type
|
|
|
|
|
if (!validateFileMagicBytes(arrayBuffer, file.type)) {
|
|
|
|
|
return { success: false, error: 'File content does not match declared type. The file may be corrupted or mislabeled.' };
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
// Check if S3 is enabled
|
|
|
|
|
const s3Enabled = await isS3Enabled();
|
|
|
|
|
|
|
|
|
|
// Result object
|
|
|
|
|
const result: UploadResult = {
|
|
|
|
|
success: false,
|
|
|
|
|
path
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Upload to S3 if enabled
|
|
|
|
|
if (s3Enabled) {
|
|
|
|
|
const s3Result = await uploadToS3('avatars', path, arrayBuffer, {
|
|
|
|
|
contentType: file.type
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!s3Result.success) {
|
|
|
|
|
return s3Result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.s3Url = s3Result.publicUrl;
|
|
|
|
|
result.publicUrl = s3Result.publicUrl;
|
|
|
|
|
result.success = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always upload to Supabase Storage as well (for fallback)
|
|
|
|
|
try {
|
|
|
|
|
// First try to delete existing avatar (ignore errors)
|
|
|
|
|
await supabaseAdmin.storage.from('avatars').remove([path]);
|
|
|
|
|
|
|
|
|
|
const { data, error } = await supabaseAdmin.storage.from('avatars').upload(path, arrayBuffer, {
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
cacheControl: '3600',
|
|
|
|
|
upsert: true
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
// If S3 succeeded, this is okay - just log
|
|
|
|
|
if (result.success) {
|
|
|
|
|
console.warn('Local storage upload failed (S3 succeeded):', error);
|
|
|
|
|
} else {
|
|
|
|
|
console.error('Avatar upload error:', error);
|
|
|
|
|
return { success: false, error: error.message };
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Generate browser-accessible public URL (not the internal Docker URL)
|
|
|
|
|
result.localUrl = getBrowserAccessibleUrl('avatars', path);
|
|
|
|
|
|
|
|
|
|
// If S3 is not enabled, use local URL as the public URL
|
|
|
|
|
if (!s3Enabled) {
|
|
|
|
|
result.publicUrl = result.localUrl;
|
|
|
|
|
result.success = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// If S3 succeeded, this is okay
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('Avatar upload exception:', error);
|
|
|
|
|
return { success: false, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a member's avatar from ALL storage backends
|
|
|
|
|
* Always attempts to delete from both S3 and Supabase Storage
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteAvatar(
|
|
|
|
|
memberId: string,
|
|
|
|
|
avatarPath?: string
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
// If we have a specific path, use it; otherwise try common extensions
|
|
|
|
|
let paths: string[];
|
|
|
|
|
if (avatarPath) {
|
|
|
|
|
paths = [avatarPath];
|
|
|
|
|
} else {
|
|
|
|
|
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
|
|
|
|
|
paths = extensions.map((ext) => `${memberId}/avatar.${ext}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
|
|
|
|
// Always try to delete from S3 (in case it was uploaded when S3 was enabled)
|
|
|
|
|
try {
|
|
|
|
|
const s3Config = await getS3Config();
|
|
|
|
|
if (s3Config) {
|
|
|
|
|
const result = await deleteMultipleFromS3('avatars', paths);
|
|
|
|
|
if (!result.success && result.error) {
|
|
|
|
|
console.warn('S3 avatar delete warning:', result.error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('S3 avatar delete error (non-critical):', error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always try to delete from Supabase Storage
|
|
|
|
|
try {
|
|
|
|
|
await supabaseAdmin.storage.from('avatars').remove(paths);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Local storage avatar delete error (non-critical):', error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the appropriate avatar URL based on current storage settings
|
|
|
|
|
* Useful for getting the right URL when storage setting is toggled
|
|
|
|
|
*/
|
|
|
|
|
export async function getActiveAvatarUrl(member: {
|
|
|
|
|
avatar_url_s3?: string | null;
|
|
|
|
|
avatar_url_local?: string | null;
|
|
|
|
|
avatar_url?: string | null;
|
|
|
|
|
}): Promise<string | null> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
return member.avatar_url_s3 || member.avatar_url || null;
|
|
|
|
|
}
|
|
|
|
|
return member.avatar_url_local || member.avatar_url || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload a document to storage
|
|
|
|
|
* Returns both S3 and local URLs for storage flexibility (same pattern as avatars)
|
|
|
|
|
*/
|
|
|
|
|
export async function uploadDocument(
|
|
|
|
|
file: File,
|
|
|
|
|
options?: {
|
|
|
|
|
folder?: string;
|
|
|
|
|
}
|
|
|
|
|
): Promise<UploadResult> {
|
|
|
|
|
// Validate file type
|
|
|
|
|
const allowedTypes = [
|
|
|
|
|
'application/pdf',
|
|
|
|
|
'application/msword',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
|
|
'application/vnd.ms-excel',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
|
|
'application/vnd.ms-powerpoint',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
|
|
|
'text/plain',
|
|
|
|
|
'text/csv',
|
|
|
|
|
'application/json',
|
|
|
|
|
'image/jpeg',
|
|
|
|
|
'image/png',
|
|
|
|
|
'image/webp',
|
|
|
|
|
'image/gif'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
error:
|
|
|
|
|
'File type not allowed. Supported: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, WebP, GIF'
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 18:03:46 +01:00
|
|
|
// Validate file extension matches declared MIME type
|
|
|
|
|
if (!validateExtensionMatchesMime(file.name, file.type)) {
|
|
|
|
|
return { success: false, error: 'File extension does not match file type. Please ensure the file has the correct extension.' };
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
// Validate file size (max 50MB)
|
|
|
|
|
const maxSize = 50 * 1024 * 1024;
|
|
|
|
|
if (file.size > maxSize) {
|
|
|
|
|
return { success: false, error: 'File size must be less than 50MB' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate unique storage path
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
const randomStr = Math.random().toString(36).substring(2, 8);
|
2026-02-10 18:03:46 +01:00
|
|
|
const safeName = sanitizeFilename(file.name).substring(0, 50);
|
2026-01-25 02:19:49 +01:00
|
|
|
const path = options?.folder ? `${options.folder}/${timestamp}-${randomStr}-${safeName}` : `${timestamp}-${randomStr}-${safeName}`;
|
|
|
|
|
|
|
|
|
|
// Convert to ArrayBuffer
|
|
|
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
|
|
|
|
Phase 1: Full implementation — security, bugs, utilities, UI/UX, consolidation
28 items across 7 batches. 36 files changed (9 new, 27 modified).
1061 insertions, 406 deletions.
== Batch 1: Critical Security Fixes ==
1.1 — Fix open redirect in /auth/callback
- src/routes/auth/callback/+server.ts: url.searchParams.get('next')
was used directly in redirect(303, next). Attacker could set
next=https://evil.com. Now wrapped through sanitizeRedirectUrl()
which rejects protocol/host, //, javascript: prefixes; falls back
to /dashboard.
1.2 — Fix open redirect in /login
- src/routes/(auth)/login/+page.server.ts: redirectTo param used
without validation in both load() and form action. Applied
sanitizeRedirectUrl() to both locations.
1.3 — Fix RLS self-role-escalation
- supabase/migrations/017_fix_rls_role_escalation.sql (NEW):
"Users can update own profile" policy had USING(auth.uid()=id)
but no WITH CHECK clause — users could SET role='admin' on their
own row. Added WITH CHECK constraining role to current value.
- deploy/init.sql: updated to match migration 017.
1.4 — Remove hardcoded secrets from docker-compose.yml
- docker-compose.yml: removed hardcoded SECRET_KEY_BASE fallback.
== Batch 2: Critical & High Bugs ==
2.1 — Fix deleteAvatar wrong argument type
- src/routes/(app)/settings/+page.server.ts: was passing supabase
client object as second arg to deleteAvatar(memberId, avatarPath).
Changed to pass member.avatar_url instead.
2.2 — Fix event.start_time typo -> event.start_datetime
- src/routes/(app)/board/events/[id]/attendees/+page.server.ts:
referenced event.start_time (doesn't exist on type). Caused
"Invalid Date" in invitation/roll-call emails. Replaced both
occurrences with event.start_datetime.
2.3 — Fix landing page CTA buttons missing href
- src/routes/+page.svelte: Sign In and Join Us buttons had no href
attribute — completely non-functional for visitors. Added
href="/login" and href="/join" respectively.
2.4 — Fix auth pages logo inconsistency
- src/routes/auth/reset-password/+page.svelte: hardcoded "M" letter
in colored box replaced with actual Monaco USA logo image
(MONACOUSA-Flags_376x376.png) matching login/layout.
2.5 — Fix currency USD -> EUR everywhere
- src/routes/(app)/board/reports/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/public/events/[id]/+page.svelte: USD -> EUR, locale
to fr-MC.
- src/routes/(app)/admin/dashboard/+page.svelte: USD -> EUR, locale
to fr-MC.
== Batch 3: High Security Fixes ==
3.1 — Sanitize HTML in email template rendering
- src/lib/server/email.ts: added escapeHtml() utility that escapes
&, <, >, ", '. Applied to all template variable values in
sendTemplatedEmail() before substitution. URL-type keys
(logo_url, site_url) exempted. Prevents XSS in emails.
3.2 — Add file upload MIME type validation
- src/lib/server/storage.ts: added MAGIC_BYTES constant and
validateFileMagicBytes() function checking PNG (89504E47),
JPEG (FFD8FF), PDF (25504446), WebP (52494646), GIF (47494638)
magic bytes against declared MIME. Applied in uploadAvatar and
uploadDocument before storing.
3.3 — Docker container hardening
- docker-compose.yml portal service: added security_opt
[no-new-privileges:true], read_only: true with tmpfs for /tmp,
deploy.resources.limits (memory: 512M, cpus: 1.0). Dockerfile
already had USER sveltekit (non-root).
3.4 — Restrict board endpoints data exposure
- src/routes/(app)/board/members/+page.server.ts: replaced
.select('*') with explicit column list returning only fields
the board UI actually displays. Removed sensitive columns.
== Batch 4: Shared Utilities ==
4.1 — Extract getVisibleLevels to shared utility
- src/lib/server/visibility.ts (NEW): exports getVisibleLevels(role)
returning appropriate visibility levels per role.
- Replaced 4 duplicate definitions in:
src/routes/(app)/dashboard/+page.server.ts
src/routes/(app)/documents/+page.server.ts
src/routes/(app)/events/+page.server.ts
src/routes/(app)/events/[id]/+page.server.ts
4.3 — Fix N+1 query in getReminderEffectiveness
- src/lib/server/dues.ts: rewrote loop executing individual DB
queries per reminder into single batch query with IN filter.
Maps results in JS instead of N+1 round-trips.
== Batch 5: Shared UI Components ==
5.1 — Create reusable EmptyState component
- src/lib/components/ui/empty-state.svelte (NEW): accepts icon,
title, description props and optional children snippet. Consistent
muted-text centered layout matching design system.
- Applied in DocumentPreviewModal and NotificationCenter.
5.2 — Move LoadingSpinner to shared ui/
- src/lib/components/ui/LoadingSpinner.svelte (NEW): copied from
auth/ to ui/ for general use. Original kept for compatibility.
- src/lib/components/ui/index.ts: added barrel exports for
EmptyState and LoadingSpinner.
== Batch 6: UX Standardization ==
6.4 — Add skip-to-content link
- src/routes/(app)/+layout.svelte: added visually-hidden-until-
focused skip link as first focusable element:
<a href="#main-content" class="sr-only focus:not-sr-only ...">
Added id="main-content" to <main> element.
6.5 — Add navigation loading indicator
- src/routes/(app)/+layout.svelte: imported SvelteKit $navigating
store. Shows thin animated progress bar at page top during
transitions. CSS-only animation, no external dependencies.
== Batch 7: Code Consolidation ==
7.1 — Consolidate profile/settings pages
- src/lib/server/member-profile.ts (NEW, 283 lines): shared helpers
handleAvatarUpload(), handleAvatarRemoval(), handleProfileUpdate().
Supports admin mode (supabaseAdmin) and user mode (scoped client).
- src/routes/(app)/profile/+page.server.ts: simplified from ~167
to ~88 lines using shared helpers.
- src/routes/(app)/settings/+page.server.ts: simplified from ~219
to ~106 lines using shared helpers.
7.2 — Consolidate registration flows
- src/lib/server/registration.ts (NEW, 201 lines): shared helpers
createMemberRecord(), cleanupAuthUser(), sendWelcomeEmail().
- src/routes/(auth)/signup/+page.server.ts: simplified from ~167
to ~85 lines using shared helpers.
- src/routes/join/+page.server.ts: simplified from ~209 to ~117
lines using shared helpers.
7.3 — Create status badge utility
- src/lib/utils/status-badges.ts (NEW, 55 lines): centralized
STATUS_MAP for all status types (membership, dues, payment,
RSVP, event, roles). Exports getStatusConfig(),
getStatusBadgeClasses(), getStatusLabel().
7.4 — Create rate limiting utility
- src/lib/server/rate-limit.ts (NEW, 73 lines): in-memory
Map-based rate limiter with TTL cleanup. Exports
checkRateLimit(key, maxAttempts, windowMs) and resetRateLimit().
- Applied to login: 5 attempts per 15 min by email.
- Applied to forgot-password: 3 attempts per 15 min by email.
- src/routes/(auth)/login/+page.server.ts: added rate limit check
before signInWithPassword, reset on success.
- src/routes/(auth)/forgot-password/+page.server.ts: added rate
limit check before resetPasswordForEmail.
== New Files (9) ==
src/lib/server/auth-utils.ts
src/lib/server/visibility.ts
src/lib/server/member-profile.ts
src/lib/server/registration.ts
src/lib/server/rate-limit.ts
src/lib/server/email.ts (escapeHtml addition)
src/lib/server/storage.ts (validateFileMagicBytes addition)
src/lib/utils/status-badges.ts
src/lib/components/ui/empty-state.svelte
src/lib/components/ui/LoadingSpinner.svelte
supabase/migrations/017_fix_rls_role_escalation.sql
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:54:10 +01:00
|
|
|
// Validate magic bytes match declared MIME type
|
|
|
|
|
if (!validateFileMagicBytes(arrayBuffer, file.type)) {
|
|
|
|
|
return { success: false, error: 'File content does not match declared type. The file may be corrupted or mislabeled.' };
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
// Check if S3 is enabled
|
|
|
|
|
const s3Enabled = await isS3Enabled();
|
|
|
|
|
|
|
|
|
|
// Result object
|
|
|
|
|
const result: UploadResult = {
|
|
|
|
|
success: false,
|
|
|
|
|
path
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Upload to S3 if enabled
|
|
|
|
|
if (s3Enabled) {
|
|
|
|
|
const s3Result = await uploadToS3('documents', path, arrayBuffer, {
|
|
|
|
|
contentType: file.type
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!s3Result.success) {
|
|
|
|
|
return s3Result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.s3Url = s3Result.publicUrl;
|
|
|
|
|
result.publicUrl = s3Result.publicUrl;
|
|
|
|
|
result.success = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always upload to Supabase Storage as well (for fallback)
|
|
|
|
|
try {
|
|
|
|
|
const { data, error } = await supabaseAdmin.storage.from('documents').upload(path, arrayBuffer, {
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
cacheControl: '3600',
|
|
|
|
|
upsert: false
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
// If S3 succeeded, this is okay - just log
|
|
|
|
|
if (result.success) {
|
|
|
|
|
console.warn('Local storage upload failed (S3 succeeded):', error);
|
|
|
|
|
} else {
|
|
|
|
|
console.error('Document upload error:', error);
|
|
|
|
|
return { success: false, error: error.message };
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Generate browser-accessible public URL (not the internal Docker URL)
|
|
|
|
|
result.localUrl = getBrowserAccessibleUrl('documents', path);
|
|
|
|
|
|
|
|
|
|
// If S3 is not enabled, use local URL as the public URL
|
|
|
|
|
if (!s3Enabled) {
|
|
|
|
|
result.publicUrl = result.localUrl;
|
|
|
|
|
result.success = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// If S3 succeeded, this is okay
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
|
console.error('Document upload exception:', error);
|
|
|
|
|
return { success: false, error: errorMessage };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a document from ALL storage backends
|
|
|
|
|
* Always attempts to delete from both S3 and Supabase Storage
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteDocument(
|
|
|
|
|
storagePath: string
|
|
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
|
|
|
|
// Always try to delete from S3 (in case it was uploaded when S3 was enabled)
|
|
|
|
|
try {
|
|
|
|
|
const s3Config = await getS3Config();
|
|
|
|
|
if (s3Config) {
|
|
|
|
|
const result = await deleteFromS3('documents', storagePath);
|
|
|
|
|
if (!result.success && result.error) {
|
|
|
|
|
console.warn('S3 document delete warning:', result.error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('S3 document delete error (non-critical):', error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always try to delete from Supabase Storage
|
|
|
|
|
try {
|
|
|
|
|
await supabaseAdmin.storage.from('documents').remove([storagePath]);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Local storage document delete error (non-critical):', error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the appropriate document URL based on current storage settings
|
|
|
|
|
* Useful for getting the right URL when storage setting is toggled
|
|
|
|
|
*/
|
|
|
|
|
export async function getActiveDocumentUrl(document: {
|
|
|
|
|
file_url_s3?: string | null;
|
|
|
|
|
file_url_local?: string | null;
|
|
|
|
|
file_path?: string | null;
|
|
|
|
|
}): Promise<string | null> {
|
|
|
|
|
// Check if S3 is enabled
|
|
|
|
|
if (await isS3Enabled()) {
|
|
|
|
|
return document.file_url_s3 || document.file_path || null;
|
|
|
|
|
}
|
|
|
|
|
return document.file_url_local || document.file_path || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload an event cover image
|
|
|
|
|
*/
|
|
|
|
|
export async function uploadEventImage(eventId: string, file: File): Promise<UploadResult> {
|
|
|
|
|
// Validate file type
|
|
|
|
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
|
|
|
return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate file size (max 10MB)
|
|
|
|
|
const maxSize = 10 * 1024 * 1024;
|
|
|
|
|
if (file.size > maxSize) {
|
|
|
|
|
return { success: false, error: 'Image size must be less than 10MB' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate path
|
|
|
|
|
const ext = file.name.split('.').pop() || 'jpg';
|
|
|
|
|
const path = `${eventId}/cover.${ext}`;
|
|
|
|
|
|
|
|
|
|
// Convert to ArrayBuffer
|
|
|
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
|
|
|
|
|
|
|
|
// Upload with upsert to replace existing cover
|
|
|
|
|
return uploadFile('event-images', path, arrayBuffer, {
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
cacheControl: '3600',
|
|
|
|
|
upsert: true
|
|
|
|
|
});
|
|
|
|
|
}
|