diff --git a/.env.example b/.env.example index 220a21c..df5a7d3 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,20 @@ POSTGRES_USER=cactoide POSTGRES_PASSWORD=cactoide_password POSTGRES_PORT=5432 -# Application configuration DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database" + +# Application configuration APP_VERSION=latest PORT=5173 HOSTNAME=0.0.0.0 +# Logger configuration +LOG_PRETTY=true +LOG_LEVEL=trace + +# If you don't want to use the default home page you can turn off +# in this case the /discovery page remain the home of your site PUBLIC_LANDING_INFO=true + +# Federation config +FEDERATION_INSTANCE=true diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 4eaa13a..cb0a7ef 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -61,6 +61,7 @@ jobs: context: . file: ./Dockerfile build-args: | + FEDERATION_INSTANCE=${{ vars.FEDERATION_INSTANCE }} PUBLIC_LANDING_INFO=${{ vars.PUBLIC_LANDING_INFO }} LOG_PRETTY=${{ vars.LOG_PRETTY }} LOG_LEVEL=${{ vars.LOG_LEVEL }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index def2b99..7637a95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,7 @@ jobs: - name: Build application run: npm run build env: + FEDERATION_INSTANCE: ${{ vars.FEDERATION_INSTANCE }} PUBLIC_LANDING_INFO: ${{ vars.PUBLIC_LANDING_INFO }} LOG_PRETTY: ${{ vars.LOG_PRETTY }} LOG_LEVEL: ${{ vars.LOG_LEVEL }} diff --git a/Dockerfile b/Dockerfile index f65bc00..bd72eac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ RUN npm ci ARG PUBLIC_LANDING_INFO ENV PUBLIC_LANDING_INFO=$PUBLIC_LANDING_INFO +ARG FEDERATION_INSTANCE +ENV FEDERATION_INSTANCE=$FEDERATION_INSTANCE + ARG LOG_PRETTY ENV LOG_PRETTY=$LOG_PRETTY @@ -29,7 +32,7 @@ ENV PORT 3000 ENV HOSTNAME "0.0.0.0" HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/healthz || exit 1 + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/healthz || exit 1 EXPOSE 3000 CMD [ "node", "build" ] diff --git a/README.md b/README.md index 5f79a7a..a36d6d5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Events that thrive anywhere. 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.

- + actoide @@ -14,17 +14,25 @@ Like the cactus, great events bloom under any condition when managed with care. #### What is it? -A mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. +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. ### ✨ Features -- **🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency. -- **πŸ”— One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app. -- **πŸ” All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place. -- **πŸ“… iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling. -- **πŸ‘€ No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly β€” no accounts, no barriers. -- **πŸ›‘οΈ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size. -- **✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve β€” just open, create, and go. +**🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency. + +**πŸ”— One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app. + +**🌐 Federation** - Connect with other Cactoide instances to discover events across the network. Share your public events and creating a decentralized event discovery network. + +**πŸ” All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place. + +**πŸ“… iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling. + +**πŸ‘€ No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly β€” no accounts, no barriers. + +**πŸ›‘οΈ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size. + +**✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve β€” just open, create, and go. ### Quick Start @@ -55,6 +63,53 @@ Your app will be available at `http://localhost:5173`. You can use the Makefile Use the `database/seed.sql` if you want to populate your database with dummy data. +### Federation + +Cactoide supports federation, allowing multiple instances to share and discover public events across the network. This enables users to discover events from other Cactoide instances, creating a decentralized event discovery network. + +

+ Federation Example +

+ +#### How it works + +Federation is managed through the `federation.config.js` file, which contains: + +- **Instance name**: The display name for your instance when exposing events to the federation +- **Instance list**: An array of federated instance URLs. Add instance URLs here to discover events from other federated instances. + +```javascript +const config = { + name: 'Cactoide Genesis', + instances: [{ url: 'js-meetups.seattle.io' }, { url: 'ai-events.seattle.com' }] +}; +``` + +#### Opt-in + +To enable federation on your instance, you need to: + +1. **Set the environment variable**: Add `FEDERATION_INSTANCE=true` to your `.env` file. This enables the federation API endpoints on your instance. + +2. **Configure your instance name**: Update the `name` field in your `federation.config.js` file to set your instance's display name. + +Your instance will automatically expose: + +- `/api/federation/events` - Returns all public events from your instance +- `/api/federation/info` - Returns your instance name and public events count + +#### Adding your instance + +To add your instance to the global federation list (so other instances can discover your events): + +1. Fork the [Cactoide repository](https://github.com/polaroi8d/cactoide) +2. Add your instance URL to the `instances` array in [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js): +3. Open a pull request to the main repository + +Once merged, your instance will appear in the federation network, and other instances will be able to discover and display your public events. + +You can view all registered federated instances in the main repository: [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js) file. + ### Options #### 1. Landing page option @@ -90,7 +145,6 @@ It isn’t backed by a big company. Development depends on the support and gener You can support in a few ways: -- Send a one-time donation via [paypal.me/zenoazurben](paypal.me/zenoazurben) - Reach me directly: leventeorb[@]gmail.com If you enjoy using Cactoide, or if your business depends on it, please consider sponsoring its development. Your support keeps the project alive, improves it for everyone, and helps create educational content like blog posts and videos for the whole Cactoide community. diff --git a/docs/federation_example.png b/docs/federation_example.png new file mode 100644 index 0000000..7511041 Binary files /dev/null and b/docs/federation_example.png differ diff --git a/federation.config.js b/federation.config.js new file mode 100644 index 0000000..05e6467 --- /dev/null +++ b/federation.config.js @@ -0,0 +1,19 @@ +const config = { + name: 'Cactoide Genesis', + instances: [ + { + url: 'cactoide.org' + }, + { + url: 'cactoide.dalev.hu' + }, + { + url: 'localhost:5174' + }, + { + url: 'localhost:5175' + } + ] +}; + +export default config; diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte index e056062..290814d 100644 --- a/src/lib/components/Navbar.svelte +++ b/src/lib/components/Navbar.svelte @@ -48,6 +48,13 @@ {t('navigation.create')} + + +
+ {#if isFederated && event.federation_url} + + View + + {:else} + + {/if}
{/each} diff --git a/src/routes/healthz/+server.ts b/src/routes/healthz/+server.ts deleted file mode 100644 index ea1e6a0..0000000 --- a/src/routes/healthz/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -// src/routes/healthz/+server.ts -import { json } from '@sveltejs/kit'; -import { database } from '$lib/database/db'; -import { sql } from 'drizzle-orm'; - -export async function GET() { - try { - await database.execute(sql`select 1`); - return json({ ok: true }, { headers: { 'cache-control': 'no-store' } }); - } catch (err) { - return json( - { ok: false, error: (err as Error)?.message, message: 'Database unreachable.' }, - { status: 503, headers: { 'cache-control': 'no-store' } } - ); - } -} diff --git a/src/routes/instance/+page.server.ts b/src/routes/instance/+page.server.ts new file mode 100644 index 0000000..1f4450e --- /dev/null +++ b/src/routes/instance/+page.server.ts @@ -0,0 +1,119 @@ +import type { PageServerLoad } from './$types'; +import { logger } from '$lib/logger'; +import federationConfig from '../../../federation.config.js'; + +interface InstanceInfo { + name: string; + publicEventsCount: number; +} + +interface HealthStatus { + ok: boolean; + responseTime?: number; + responseTimeUnit?: string; + error?: string; +} + +interface InstanceData { + url: string; + name: string | null; + events: number | null; + healthStatus: 'healthy' | 'unhealthy' | 'unknown'; + responseTime: number | null; + error?: string; +} + +async function fetchInstanceInfo(instanceUrl: string): Promise { + try { + const apiUrl = `http://${instanceUrl}/api/federation/info`; + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json' + }, + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + + if (!response.ok) { + logger.warn({ apiUrl, status: response.status }, 'Failed to fetch instance info'); + return null; + } + + const data = (await response.json()) as InstanceInfo; + return data; + } catch (error) { + logger.error( + { instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' }, + 'Error fetching instance info' + ); + return null; + } +} + +async function fetchHealthStatus(instanceUrl: string): Promise { + try { + const apiUrl = `http://${instanceUrl}/api/healthz`; + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json' + }, + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + + if (!response.ok) { + logger.warn({ apiUrl, status: response.status }, 'Failed to fetch health status'); + return { ok: false, error: `HTTP ${response.status}` }; + } + + const data = (await response.json()) as HealthStatus; + return data; + } catch (error) { + logger.error( + { instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' }, + 'Error fetching health status' + ); + return { ok: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + +export const load: PageServerLoad = async () => { + try { + const instances = federationConfig.instances || []; + + // Fetch data from all instances in parallel + const instanceDataPromises = instances.map(async (instance): Promise => { + const [info, health] = await Promise.all([ + fetchInstanceInfo(instance.url), + fetchHealthStatus(instance.url) + ]); + + const responseTime = health?.responseTime ?? null; + const healthStatus: 'healthy' | 'unhealthy' | 'unknown' = health?.ok + ? 'healthy' + : health === null + ? 'unknown' + : 'unhealthy'; + + return { + url: instance.url, + name: info?.name ?? null, + events: info?.publicEventsCount ?? null, + healthStatus, + responseTime, + error: health?.error + }; + }); + + const instanceData = await Promise.all(instanceDataPromises); + + return { + instances: instanceData + }; + } catch (error) { + logger.error({ error }, 'Error loading instance data'); + return { + instances: [] + }; + } +}; diff --git a/src/routes/instance/+page.svelte b/src/routes/instance/+page.svelte new file mode 100644 index 0000000..4ad5c67 --- /dev/null +++ b/src/routes/instance/+page.svelte @@ -0,0 +1,148 @@ + + +
+
+ + + + + + + + + + + + {#each data.instances as instance, i (i)} + + + + + + + + {/each} + +
+ {t('instance.name')} + + {t('instance.url')} + + {t('instance.events')} + + {t('instance.healthStatus')} + + {t('instance.responseTime')} +
+ + {instance.name || t('instance.notAvailable')} + + + + {instance.url} + + + + {instance.events !== null ? instance.events : t('instance.notAvailable')} + + +
+ + + {getHealthStatusText(instance.healthStatus)} + + {#if instance.error} + ({instance.error}) + {/if} +
+
+
+ + + {formatResponseTime(instance.responseTime)} + +
+
+ +

+ {t('instance.description')} + {t('instance.configFile')} + {t('instance.file')} +

+ + {#if data.instances.length === 0} +
{t('instance.noInstances')}
+ {/if} +
+
+ +