2
0
forked from jmug/cactoide

feat: initialize federation service v1

This commit is contained in:
Levente Orban
2025-11-06 22:31:16 +01:00
parent efe465d994
commit 9f74d58db1
9 changed files with 300 additions and 40 deletions

View File

@@ -29,7 +29,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" ]

View File

@@ -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.
<p align="center">
<a href="https://cactoide.dalev.hu/" target="blank">
<a href="https://cactoide.org/" target="blank">
<picture>
<img alt="actoide" src="https://github.com/user-attachments/assets/30b87181-1e3b-49d0-869e-bef6dcf7f777" width="840">
</picture>

16
federation.config.js Normal file
View File

@@ -0,0 +1,16 @@
const config = {
name: 'Cactoide Genesis',
instances: [
{
url: 'cactoide.org'
},
{
url: 'cactoide.dalev.hu'
},
{
url: 'localhost:5174'
}
]
};
export default config;

View File

@@ -0,0 +1,146 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { logger } from '$lib/logger';
import type { Event } from '$lib/types';
import config from '../../federation.config.js';
console.log(config.instances);
interface FederationConfig {
name: string;
instances: Array<{ url: string }>;
}
interface FederationEventsResponse {
events: Array<Event & { federation?: boolean }>;
count?: number;
}
/**
* Reads the federation config file
*/
async function readFederationConfig(): Promise<FederationConfig | null> {
try {
const configPath = join(process.cwd(), 'federation.config.js');
// Use dynamic import to load the config file as a module
// This is safer than eval and works with ES modules
const configModule = await import(configPath + '?t=' + Date.now());
const config = (configModule.default || configModule.config) as FederationConfig;
if (config && config.instances && Array.isArray(config.instances)) {
return config;
}
logger.warn('Invalid federation config structure');
return null;
} catch (error) {
// If dynamic import fails, try reading as text and parsing
try {
const configPath = join(process.cwd(), 'federation.config.js');
const configContent = readFileSync(configPath, 'utf-8');
// Try to extract JSON-like structure
const configMatch = configContent.match(/instances:\s*\[([\s\S]*?)\]/);
if (configMatch) {
// Simple parsing - extract URLs
const urlMatches = configContent.matchAll(/url:\s*['"]([^'"]+)['"]/g);
const instances = Array.from(urlMatches, (match) => ({ url: match[1] }));
if (instances.length > 0) {
return {
name: 'Federated Instances',
instances
};
}
}
} catch (fallbackError) {
logger.error({ error: fallbackError }, 'Error parsing federation.config.js as fallback');
}
logger.error({ error }, 'Error reading federation.config.js');
return null;
}
}
/**
* Fetches events from a single federated instance
*/
async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> {
try {
// Ensure URL has protocol and append /api/events
const apiUrl = `http://${instanceUrl}/api/events`;
logger.debug({ apiUrl }, 'Fetching events from federated instance');
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 events from instance');
return [];
}
const data = (await response.json()) as FederationEventsResponse;
if (!data.events || !Array.isArray(data.events)) {
logger.warn({ apiUrl }, 'Invalid events response structure');
return [];
}
// Mark events as federated and add source URL
const federatedEvents: Event[] = data.events.map((event) => ({
...event,
federation: true,
federation_url: `http://${instanceUrl}`
}));
logger.info(
{ apiUrl, eventCount: federatedEvents.length },
'Successfully fetched federated events'
);
return federatedEvents;
} catch (error) {
logger.error(
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
'Error fetching events from instance'
);
return [];
}
}
/**
* Fetches events from all configured federated instances
*/
export async function fetchAllFederatedEvents(): Promise<Event[]> {
const config = await readFederationConfig();
if (!config || !config.instances || config.instances.length === 0) {
logger.debug('No federation config or instances found');
return [];
}
logger.info(
{ instanceCount: config.instances.length },
'Fetching events from federated instances'
);
// Fetch from all instances in parallel
const fetchPromises = config.instances.map((instance) => fetchEventsFromInstance(instance.url));
const results = await Promise.all(fetchPromises);
// Flatten all events into a single array
const allFederatedEvents = results.flat();
logger.info({ totalEvents: allFederatedEvents.length }, 'Completed fetching federated events');
return allFederatedEvents;
}

View File

@@ -17,6 +17,8 @@ export interface Event {
user_id: string;
created_at: string;
updated_at: string;
federation?: boolean; // Optional: true if event is from a federated instance
federation_url?: string; // Optional: URL of the federated instance this event came from
}
export interface RSVP {

View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { desc, eq } from 'drizzle-orm';
import { logger } from '$lib/logger';
import { FEDERATION_INSTANCE } from '$env/static/private';
export const GET: RequestHandler = async () => {
try {
if (!FEDERATION_INSTANCE) {
return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 });
}
// Fetch all public and invite-only events ordered by creation date (newest first)
const publicEvents = await database
.select()
.from(events)
.where(eq(events.visibility, 'public'))
.orderBy(desc(events.createdAt));
// Transform events to include federation_event type
const transformedEvents = publicEvents.map((event) => ({
id: event.id,
name: event.name,
date: event.date,
time: event.time,
location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type,
federation: true,
attendee_limit: event.attendeeLimit,
visibility: event.visibility,
user_id: event.userId,
created_at: event.createdAt?.toISOString() || '',
updated_at: event.updatedAt?.toISOString() || ''
}));
return json({
events: transformedEvents,
count: transformedEvents.length
});
} catch (error) {
logger.error({ error }, 'Error fetching events from API');
return json(
{
error: 'Failed to fetch events',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
};

View File

@@ -3,10 +3,11 @@ import { desc, inArray } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { events } from '$lib/database/schema';
import { logger } from '$lib/logger';
import { fetchAllFederatedEvents } from '$lib/fetchFederatedEvents';
export const load: PageServerLoad = async () => {
try {
// Fetch all non-private events (public and invite-only) ordered by creation date (newest first)
// Fetch all non-private events ordered by creation date (newest first)
const publicEvents = await database
.select()
.from(events)
@@ -17,24 +18,36 @@ export const load: PageServerLoad = async () => {
const transformedEvents = publicEvents.map((event) => ({
id: event.id,
name: event.name,
date: event.date, // Already in 'YYYY-MM-DD' format
time: event.time, // Already in 'HH:MM:SS' format
date: event.date,
time: event.time,
location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type,
attendee_limit: event.attendeeLimit, // Note: schema uses camelCase
attendee_limit: event.attendeeLimit,
visibility: event.visibility,
user_id: event.userId, // Note: schema uses camelCase
user_id: event.userId,
created_at: event.createdAt?.toISOString(),
updated_at: event.updatedAt?.toISOString()
updated_at: event.updatedAt?.toISOString(),
federation: false // Add false for local events
}));
// Fetch federated events from federation.config.js
let federatedEvents: typeof transformedEvents = [];
try {
federatedEvents = await fetchAllFederatedEvents();
} catch (error) {
logger.error({ error }, 'Error fetching federated events, continuing with local events only');
}
// Merge local and federated events
const allEvents = [...transformedEvents, ...federatedEvents];
return {
events: transformedEvents
events: allEvents
};
} catch (error) {
logger.error({ error }, 'Error loading public events');
logger.error({ error }, 'Error loading events');
// Return empty array on error to prevent page crash
return {

View File

@@ -267,9 +267,15 @@
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each filteredEvents as event, i (i)}
<div class="rounded-sm border border-slate-200 p-6 shadow-sm">
<div class="mb-4">
<h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3>
{@const isFederated = event.federation === true}
<div
class="flex flex-col rounded-sm border border-slate-200 bg-slate-800/50
p-6 shadow-sm"
>
<div class="mb-4 flex-1">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-300">{event.name}</h3>
</div>
<div class="space-y-2 text-sm text-slate-500">
<div class="flex items-center space-x-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,6 +320,15 @@
<span>{event.location}</span>
{/if}
</div>
{#if isFederated && event.federation_url}
<div class="flex items-center space-x-2">
<span
class="rounded-sm border border-blue-500 px-2 py-1 text-xs
font-medium text-blue-500"
>
{event.federation_url}
</span>
</div>{:else}
<div class="flex items-center space-x-2">
<span
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
@@ -331,19 +346,32 @@
? 'border-teal-500 text-teal-500'
: 'border-amber-600 text-amber-600'}"
>
{event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')}
{event.visibility === 'public'
? t('common.public')
: t('common.inviteOnly')}
</span>
</div>
</div>{/if}
</div>
</div>
<div class="flex">
<div class="mt-auto flex">
{#if isFederated && event.federation_url}
<a
href="{event.federation_url}/event/{event.id}"
target="_blank"
rel="noopener noreferrer"
class="flex-1 rounded-sm border-2 border-blue-500 bg-blue-400/20 px-4 py-2 text-center font-semibold duration-200 hover:bg-blue-400/70"
>
View
</a>
{:else}
<button
on:click={() => goto(`/event/${event.id}`)}
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
>
{t('discover.viewButton')}
</button>
{/if}
</div>
</div>
{/each}