mirror of
https://github.com/polaroi8d/cactoide.git
synced 2026-03-22 06:05:28 +00:00
feat: initialize federation service v1
This commit is contained in:
@@ -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" ]
|
||||
|
||||
@@ -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
16
federation.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const config = {
|
||||
name: 'Cactoide Genesis',
|
||||
instances: [
|
||||
{
|
||||
url: 'cactoide.org'
|
||||
},
|
||||
{
|
||||
url: 'cactoide.dalev.hu'
|
||||
},
|
||||
{
|
||||
url: 'localhost:5174'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
146
src/lib/fetchFederatedEvents.ts
Normal file
146
src/lib/fetchFederatedEvents.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
55
src/routes/api/events/+server.ts
Normal file
55
src/routes/api/events/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,36 +320,58 @@
|
||||
<span>{event.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
|
||||
'limited'
|
||||
? 'border-amber-600 text-amber-600'
|
||||
: 'border-teal-500 text-teal-500'}"
|
||||
>
|
||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||
'public'
|
||||
? 'border-teal-500 text-teal-500'
|
||||
: 'border-amber-600 text-amber-600'}"
|
||||
>
|
||||
{event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')}
|
||||
</span>
|
||||
</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 ===
|
||||
'limited'
|
||||
? 'border-amber-600 text-amber-600'
|
||||
: 'border-teal-500 text-teal-500'}"
|
||||
>
|
||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||
'public'
|
||||
? 'border-teal-500 text-teal-500'
|
||||
: 'border-amber-600 text-amber-600'}"
|
||||
>
|
||||
{event.visibility === 'public'
|
||||
? t('common.public')
|
||||
: t('common.inviteOnly')}
|
||||
</span>
|
||||
</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<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>
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user