forked from jmug/cactoide
Compare commits
1 Commits
handmade
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc04502f9 |
@@ -2,7 +2,6 @@ services:
|
||||
# Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
container_name: cactoide-db
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-cactoide_database}
|
||||
@@ -29,8 +28,7 @@ services:
|
||||
|
||||
# Application
|
||||
app:
|
||||
image: cactoide:handmade
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/polaroi8d/cactoide/cactoide:${APP_VERSION:-latest}
|
||||
build: .
|
||||
container_name: cactoide-app
|
||||
ports:
|
||||
@@ -48,6 +46,7 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- cactoide-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
%sveltekit.head%
|
||||
|
||||
<!-- Remove if you don't want to use analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.dalev.hu/script.js"
|
||||
data-website-id="7425d098-e340-4464-bd03-c2e47b004cd9"
|
||||
></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
@@ -27,17 +27,16 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
}
|
||||
|
||||
const cactoideUserId = event.cookies.get('cactoideUserId');
|
||||
const userId = generateUserId();
|
||||
|
||||
const DAYS = 400; // practical upper bound in many browsers for cookies
|
||||
const MAX_AGE = 60 * 60 * 24 * DAYS;
|
||||
const PATH = '/';
|
||||
|
||||
if (!cactoideUserId) {
|
||||
logger.debug({ userId }, 'No cactoideUserId cookie found, generating new one');
|
||||
event.cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
|
||||
logger.debug('No cactoideUserId cookie found, generating new one');
|
||||
event.cookies.set('cactoideUserId', generateUserId(), { path: PATH, maxAge: MAX_AGE });
|
||||
} else {
|
||||
logger.debug({ cactoideUserId }, 'cactoideUserId cookie found, using existing one');
|
||||
logger.debug('cactoideUserId cookie found, using existing one');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const generateUserId = () => {
|
||||
const userId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
return userId;
|
||||
};
|
||||
// This id is the only credential the app has — it must not be guessable,
|
||||
// and it must never be serialized to the client.
|
||||
export const generateUserId = () => 'user_' + randomUUID();
|
||||
|
||||
@@ -259,8 +259,6 @@
|
||||
"layout": {
|
||||
"defaultTitle": "Cactoide -",
|
||||
"defaultDescription": "Crea e gestisci gli RSVP degli eventi",
|
||||
"userIdCookieText": "Il tuo UserID memorizzato come cookie:",
|
||||
"firstTimeVisiting": "Prima visita. Generazione di un nuovo UserID...",
|
||||
"copyright": "© 2025 Cactoide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,16 +103,16 @@
|
||||
"instance": "Instance"
|
||||
},
|
||||
"home": {
|
||||
"title": "RSVP | Handmade Cities",
|
||||
"title": "Cactoide - The RSVP site",
|
||||
"description": "Create and manage event RSVPs. No registration required, instant sharing.",
|
||||
"mainTitle": "Cactoide",
|
||||
"subtitle": "Handmade's Preferred RSVP System",
|
||||
"tagline": "Create, share, and manage events with zero friction.",
|
||||
"mainTitle": "Cactoide(ea)",
|
||||
"subtitle": "The Ultimate RSVP Platform",
|
||||
"tagline": "A federated mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. With built-in federation, discover and share events across a decentralized network of instances.",
|
||||
"openSourceTitle": "Open Source & Self-Hostable",
|
||||
"openSourceDescription": "Cactoide is open source and easily self-hostable. View the source code, contribute, or host your own instance.",
|
||||
"viewOnGitHub": "View on GitHub",
|
||||
"whyCactoideTitle": "Why Cactoide?",
|
||||
"whyCactoideDescription": "Cactoide is lightweight and open source. Meetup Hosts should ALWAYS create private events. We currently don't prevent strangers from spamming public ones:",
|
||||
"whyCactoideTitle": "Why Cactoide(ae)?🌵",
|
||||
"whyCactoideDescription": "Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable.",
|
||||
"createEventNow": "Create Event Now",
|
||||
"discoverPublicEventsTitle": "Discover Public Events",
|
||||
"discoverPublicEventsDescription": "See what others are planning and get inspired",
|
||||
@@ -146,7 +146,7 @@
|
||||
"ctaButton": "Create"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Event - Handmade Cities",
|
||||
"title": "Create Event - Cactoide",
|
||||
"formTitle": "Create New Event",
|
||||
"eventNameLabel": "Name",
|
||||
"eventNamePlaceholder": "Enter event name",
|
||||
@@ -179,10 +179,10 @@
|
||||
"createEventButton": "Create Event"
|
||||
},
|
||||
"event": {
|
||||
"title": "{eventName} - Handmade Cities",
|
||||
"eventTitle": "Event - Handmade Cities",
|
||||
"editTitle": "Edit Event - {eventName} - Handmade Cities",
|
||||
"myEventsTitle": "My Events - Handmade Cities",
|
||||
"title": "{eventName} - Cactoide",
|
||||
"eventTitle": "Event - Cactoide",
|
||||
"editTitle": "Edit Event - {eventName} - Cactoide",
|
||||
"myEventsTitle": "My Events - Cactoide",
|
||||
"eventNotFoundTitle": "Event Not Found",
|
||||
"eventNotFoundDescription": "The event you're looking for doesn't exist or has been removed.",
|
||||
"joinThisEvent": "Join This Event",
|
||||
@@ -230,7 +230,7 @@
|
||||
"inviteLinkExpiresAt": "This link expires when the event starts: {time}"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Discover Events - Handmade Cities",
|
||||
"title": "Discover Events - Cactoide",
|
||||
"noPublicEventsTitle": "No Public Events Yet",
|
||||
"noPublicEventsDescription": "There are no public events available at the moment. Be the first to create one!",
|
||||
"createButton": "Create",
|
||||
@@ -283,16 +283,14 @@
|
||||
"downloadICalDescription": "Download .ics file for any calendar app"
|
||||
},
|
||||
"errors": {
|
||||
"title": "Error - Handmade Cities",
|
||||
"title": "Error - Cactoide",
|
||||
"errorTitle": "Error",
|
||||
"anUnexpectedErrorOccurred": "An unexpected error occurred.",
|
||||
"homeButton": "Home"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Handmade Cities -",
|
||||
"defaultTitle": "Cactoide -",
|
||||
"defaultDescription": "Create and manage event RSVPs",
|
||||
"userIdCookieText": "Your UserID stored as a cookie:",
|
||||
"firstTimeVisiting": "First time visiting. Generating new UserID...",
|
||||
"copyright": "© 2025 Cactoide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface Event {
|
||||
type: EventType;
|
||||
attendee_limit?: number;
|
||||
visibility: EventVisibility;
|
||||
user_id: string;
|
||||
is_creator?: boolean; // Optional: absent on events fetched from federated instances
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
federation?: boolean; // Optional: true if event is from a federated instance
|
||||
@@ -25,7 +25,7 @@ export interface RSVP {
|
||||
id: string;
|
||||
event_id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
is_mine: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function load({ cookies }) {
|
||||
const cactoideUserId = cookies.get('cactoideUserId');
|
||||
|
||||
return {
|
||||
cactoideUserId
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
import { t } from '$lib/i18n/i18n.js';
|
||||
|
||||
let { data, children } = $props();
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -28,12 +28,6 @@
|
||||
<footer class="py-12">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<div class="text-sm">
|
||||
<p class="mb-4 text-gray-100/80">
|
||||
{t('layout.userIdCookieText')}
|
||||
<span class="font-bold text-violet-400"
|
||||
>{data.cactoideUserId ? data.cactoideUserId : t('layout.firstTimeVisiting')}</span
|
||||
>
|
||||
</p>
|
||||
<p>{t('layout.copyright')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,6 @@ export const GET: RequestHandler = async () => {
|
||||
federation: true,
|
||||
attendee_limit: event.attendeeLimit,
|
||||
visibility: event.visibility,
|
||||
user_id: event.userId,
|
||||
created_at: event.createdAt?.toISOString() || '',
|
||||
updated_at: event.updatedAt?.toISOString() || ''
|
||||
}));
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
let isSubmitting = false;
|
||||
let currentUserId = '';
|
||||
|
||||
// Get today's date in YYYY-MM-DD format for min attribute
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
@@ -95,7 +94,6 @@
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<input type="hidden" name="userId" value={currentUserId} />
|
||||
<input type="hidden" name="type" value={eventData.type} />
|
||||
<input type="hidden" name="visibility" value={eventData.visibility} />
|
||||
<input type="hidden" name="location_type" value={eventData.location_type} />
|
||||
|
||||
@@ -26,7 +26,6 @@ export const load: PageServerLoad = async () => {
|
||||
type: event.type,
|
||||
attendee_limit: event.attendeeLimit,
|
||||
visibility: event.visibility,
|
||||
user_id: event.userId,
|
||||
created_at: event.createdAt?.toISOString(),
|
||||
updated_at: event.updatedAt?.toISOString(),
|
||||
federation: false // Add false for local events
|
||||
|
||||
@@ -30,7 +30,6 @@ export const load = async ({ cookies }) => {
|
||||
type: event.type,
|
||||
attendee_limit: event.attendeeLimit,
|
||||
visibility: event.visibility,
|
||||
user_id: event.userId,
|
||||
created_at: event.createdAt?.toISOString() || new Date().toISOString(),
|
||||
updated_at: event.updatedAt?.toISOString() || new Date().toISOString()
|
||||
}));
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
export let data: { events: Event[] };
|
||||
|
||||
let userEvents: Event[] = [];
|
||||
let currentUserId = '';
|
||||
let showDeleteModal = false;
|
||||
let eventToDelete: Event | null = null;
|
||||
|
||||
@@ -28,7 +27,6 @@
|
||||
// Use server-side action for deletion
|
||||
const formData = new FormData();
|
||||
formData.append('eventId', eventId);
|
||||
formData.append('userId', currentUserId);
|
||||
|
||||
const response = await fetch('?/deleteEvent', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { logger } from '$lib/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const eventId = params.id;
|
||||
const userId = cookies.get('cactoideUserId');
|
||||
|
||||
if (!eventId) {
|
||||
throw error(404, 'EventId not found');
|
||||
@@ -29,7 +30,6 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
// Check if this is an invite-only event
|
||||
if (event.visibility === 'invite-only') {
|
||||
// For invite-only events, check if user is the event creator
|
||||
const userId = cookies.get('cactoideUserId');
|
||||
if (event.userId !== userId) {
|
||||
// User is not the creator, redirect to a message about needing invite
|
||||
throw error(403, 'This event requires an invite link to view');
|
||||
@@ -48,7 +48,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
type: event.type,
|
||||
attendee_limit: event.attendeeLimit,
|
||||
visibility: event.visibility,
|
||||
user_id: event.userId,
|
||||
// Never send raw user ids to the client — they are the credential
|
||||
is_creator: !!userId && event.userId === userId,
|
||||
created_at: event.createdAt?.toISOString() || new Date().toISOString(),
|
||||
updated_at: event.updatedAt?.toISOString() || new Date().toISOString()
|
||||
};
|
||||
@@ -57,16 +58,13 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
id: rsvp.id,
|
||||
event_id: rsvp.eventId,
|
||||
name: rsvp.name,
|
||||
user_id: rsvp.userId,
|
||||
is_mine: !!userId && rsvp.userId === userId,
|
||||
created_at: rsvp.createdAt?.toISOString() || new Date().toISOString()
|
||||
}));
|
||||
|
||||
const userId = cookies.get('cactoideUserId');
|
||||
|
||||
return {
|
||||
event: transformedEvent,
|
||||
rsvps: transformedRsvps,
|
||||
userId: userId
|
||||
rsvps: transformedRsvps
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Response) throw err; // This is the 404 error
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
import CalendarModal from '$lib/components/CalendarModal.svelte';
|
||||
import type { CalendarEvent } from '$lib/calendarHelpers.js';
|
||||
import { t } from '$lib/i18n/i18n.js';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let data: { event: Event; rsvps: RSVP[]; userId: string };
|
||||
export let data: { event: Event; rsvps: RSVP[] };
|
||||
type FormDataLocal = { success?: boolean; error?: string; type?: 'add' | 'remove' | 'copy' };
|
||||
export let form: FormDataLocal | undefined;
|
||||
|
||||
@@ -28,22 +27,10 @@
|
||||
let typeToShow: 'add' | 'remove' | 'copy' | undefined;
|
||||
let successHideTimer: number | null = null;
|
||||
|
||||
// Compute eventId early so reactive blocks can use it.
|
||||
const eventId = $page.params.id || '';
|
||||
|
||||
// client-only origin (empty during SSR).
|
||||
let origin = '';
|
||||
|
||||
// Safe: Only runs in browser.
|
||||
onMount(() => {
|
||||
origin = window.location.origin;
|
||||
});
|
||||
|
||||
// Use server-side data
|
||||
$: event = data.event;
|
||||
$: rsvps = data.rsvps;
|
||||
$: currentUserId = data.userId;
|
||||
$: isEventCreator = event.user_id === currentUserId;
|
||||
$: isEventCreator = event.is_creator ?? false;
|
||||
|
||||
// Create calendar event object when event data changes
|
||||
$: if (event && browser) {
|
||||
@@ -52,26 +39,10 @@
|
||||
date: event.date,
|
||||
time: event.time,
|
||||
location: event.location,
|
||||
// Fallback to relative path on server render.
|
||||
url: origin ? `${origin}/event/${eventId}` : `/event/${eventId}`
|
||||
url: `${$page.url.origin}/event/${eventId}`
|
||||
};
|
||||
}
|
||||
|
||||
const copyEventLink = () => {
|
||||
if (browser && isEventCreator) {
|
||||
const url = origin ? `${origin}/event/${eventId}` : `${location.origin}/event/${eventId}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toastType = 'copy';
|
||||
success = t('event.eventLinkCopied');
|
||||
|
||||
setTimeout(() => {
|
||||
success = '';
|
||||
toastType = null;
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form errors from server
|
||||
$: if (form?.error) {
|
||||
error = String(form.error);
|
||||
@@ -107,7 +78,22 @@
|
||||
// Derive toast type from local or server form
|
||||
$: typeToShow = toastType ?? form?.type;
|
||||
|
||||
const eventId = $page.params.id || '';
|
||||
|
||||
const copyEventLink = () => {
|
||||
if (browser && isEventCreator) {
|
||||
const url = `${$page.url.origin}/event/${eventId}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toastType = 'copy';
|
||||
success = t('event.eventLinkCopied');
|
||||
|
||||
setTimeout(() => {
|
||||
success = '';
|
||||
toastType = null;
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearMessages = () => {
|
||||
error = '';
|
||||
@@ -279,7 +265,6 @@
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="userId" value={currentUserId} />
|
||||
<div>
|
||||
<label for="attendeeName" class=" mb-2 block text-sm font-semibold">
|
||||
{t('event.yourNameLabel')}
|
||||
@@ -414,7 +399,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if attendee.user_id === currentUserId}
|
||||
{#if attendee.is_mine}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/removeRSVP"
|
||||
@@ -489,7 +474,7 @@
|
||||
bind:isOpen={showCalendarModal}
|
||||
event={calendarEvent}
|
||||
{eventId}
|
||||
baseUrl={origin}
|
||||
baseUrl={$page.url.origin}
|
||||
on:close={closeCalendarModal}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -44,10 +44,13 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the owner id — it is the credential, and the load above already proved ownership
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { userId: _owner, ...eventRow } = event[0];
|
||||
|
||||
return {
|
||||
event: event[0],
|
||||
inviteToken,
|
||||
userId
|
||||
event: eventRow,
|
||||
inviteToken
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Invite Link Section (only for invite-only events and event creator) -->
|
||||
{#if eventData.visibility === 'invite-only' && inviteToken && data.event.userId === data.userId}
|
||||
{#if eventData.visibility === 'invite-only' && inviteToken}
|
||||
<div class="rounded-sm border border-amber-500/30 bg-amber-900/20 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-amber-400">Invite Link</h3>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isTokenValid } from '$lib/inviteTokenHelpers.js';
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const eventId = params.id;
|
||||
const token = params.token;
|
||||
const userId = cookies.get('cactoideUserId');
|
||||
|
||||
if (!eventId || !token) {
|
||||
throw error(404, 'Event or token not found');
|
||||
@@ -59,7 +60,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
type: event.type,
|
||||
attendee_limit: event.attendeeLimit,
|
||||
visibility: event.visibility,
|
||||
user_id: event.userId,
|
||||
// Never send raw user ids to the client — they are the credential
|
||||
is_creator: !!userId && event.userId === userId,
|
||||
created_at: event.createdAt?.toISOString() || new Date().toISOString(),
|
||||
updated_at: event.updatedAt?.toISOString() || new Date().toISOString()
|
||||
};
|
||||
@@ -68,16 +70,13 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
id: rsvp.id,
|
||||
event_id: rsvp.eventId,
|
||||
name: rsvp.name,
|
||||
user_id: rsvp.userId,
|
||||
is_mine: !!userId && rsvp.userId === userId,
|
||||
created_at: rsvp.createdAt?.toISOString() || new Date().toISOString()
|
||||
}));
|
||||
|
||||
const userId = cookies.get('cactoideUserId');
|
||||
|
||||
return {
|
||||
event: transformedEvent,
|
||||
rsvps: transformedRsvps,
|
||||
userId: userId,
|
||||
inviteToken: {
|
||||
id: inviteToken.id,
|
||||
event_id: inviteToken.eventId,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { CalendarEvent } from '$lib/calendarHelpers.js';
|
||||
import { t } from '$lib/i18n/i18n.js';
|
||||
|
||||
export let data: { event: Event; rsvps: RSVP[]; userId: string; inviteToken: InviteToken };
|
||||
export let data: { event: Event; rsvps: RSVP[]; inviteToken: InviteToken };
|
||||
export let form;
|
||||
|
||||
let event: Event;
|
||||
@@ -26,8 +26,7 @@
|
||||
// Use server-side data
|
||||
$: event = data.event;
|
||||
$: rsvps = data.rsvps;
|
||||
$: currentUserId = data.userId;
|
||||
$: isEventCreator = event.user_id === currentUserId;
|
||||
$: isEventCreator = event.is_creator ?? false;
|
||||
|
||||
// Create calendar event object when event data changes
|
||||
$: if (event && browser) {
|
||||
@@ -240,7 +239,6 @@
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="userId" value={currentUserId} />
|
||||
<div>
|
||||
<label for="attendeeName" class=" mb-2 block text-sm font-semibold">
|
||||
{t('event.yourNameLabel')}
|
||||
@@ -374,7 +372,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if attendee.user_id === currentUserId}
|
||||
{#if attendee.is_mine}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/removeRSVP"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 15 KiB |
@@ -14,10 +14,7 @@ const config = {
|
||||
// see "split" mode in https://github.com/sveltejs/kit/tree/main/packages/adapter-netlify
|
||||
edge: false,
|
||||
split: false
|
||||
}),
|
||||
csrf: {
|
||||
checkOrigin: false
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user