/**
* Klonkt Beta โ server bootstrap
*
* Personal multi-site platform โ Node + SQLite + htmx.
* Stack: Express + better-sqlite3 + EJS + htmx + ws.
*/
import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import session from 'express-session';
import bodyParser from 'body-parser';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import http from 'http';
import db, { initializeDatabase } from './config/database.js';
import { startScheduler } from './services/Scheduler.js';
import { SqliteSessionStore } from './services/SqliteSessionStore.js';
import { ensurePrimarySite } from './services/ensurePrimarySite.js';
import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
import { isViewer } from './middleware/auth.js';
import { renderPage } from './middleware/render.js';
import { audioEnabled } from './config/features.js';
import authRoutes from './routes/auth.js';
import accountRoutes from './routes/account.js';
import notificationsRoutes from './routes/notifications.js';
import adminRoutes from './routes/admin.js';
import adminAudioRoutes from './routes/admin-audio.js';
import adminPlaylistsRoutes from './routes/admin-playlists.js';
import adminSitesRoutes from './routes/admin-sites.js';
import adminUsersRoutes from './routes/admin-users.js';
import adminSettingsRoutes from './routes/admin-settings.js';
import adminSeoRoutes from './routes/admin-seo.js';
import audioRoutes from './routes/audio.js';
import searchRoutes from './routes/search.js';
import tagsRoutes from './routes/tags.js';
import typesRoutes from './routes/types.js';
import usersRoutes from './routes/users.js';
import feedRoutes from './routes/feed.js';
import postsRoutes from './routes/posts.js';
import langRoutes from './routes/lang.js';
import adminUpdatesRoutes from './routes/admin-updates.js';
import adminPatreonRoutes from './routes/admin-patreon.js';
import adminStatsRoutes from './routes/admin-stats.js';
import circleRoutes from './routes/circle.js';
import epkRoutes from './routes/epk.js';
import newsletterRoutes from './routes/newsletter.js';
import adminNewsletterRoutes from './routes/admin-newsletter.js';
import downloadRoutes from './routes/download.js';
import linkbioRoutes from './routes/linkbio.js';
import embedRoutes from './routes/embed.js';
import showsRoutes from './routes/shows.js';
import adminShowsRoutes from './routes/admin-shows.js';
import adminEpkRoutes from './routes/admin-epk.js';
import changelogRoutes from './routes/changelog.js';
import ogRoutes from './routes/og.js';
import apRoutes from './routes/activitypub.js';
import { apWants, startDeliveryWorker } from './services/ActivityPubService.js';
// SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
// and persist it next to the database, so it stays stable across restarts and
// updates. This lets Docker / bare-Node installs run with zero manual config.
if (!process.env.SESSION_SECRET) {
const dataDir = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
const secretFile = path.join(dataDir, '.session-secret');
try { process.env.SESSION_SECRET = fs.readFileSync(secretFile, 'utf8').trim(); } catch { /* not yet generated */ }
if (!process.env.SESSION_SECRET) {
fs.mkdirSync(dataDir, { recursive: true });
process.env.SESSION_SECRET = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(secretFile, process.env.SESSION_SECRET, { mode: 0o600 });
console.log(`๐ Generated a SESSION_SECRET (stored in ${secretFile})`);
}
}
// A SESSION_SECRET that was explicitly set in the env must still be strong in prod.
if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET.length < 32) {
console.error('โ FATAL: SESSION_SECRET is too weak for production (set a longer, random one in .env)');
process.exit(1);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000;
// Interface to bind. Default 0.0.0.0 (needed for Docker port-forwarding). Behind a
// reverse proxy on the same host, set HOST=127.0.0.1 so the app is NOT reachable
// directly from the internet (only via the proxy) โ see README/install docs.
const HOST = process.env.HOST || '0.0.0.0';
const isDev = process.env.NODE_ENV !== 'production';
const app = express();
const server = http.createServer(app);
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'",
// Our custom embeds (embed-player.js) load the OFFICIAL player APIs
// from these hosts. Without this whitelist the CSP silently blocks them
// (only a console error) and the embed player fails.
"https://www.youtube.com", // YouTube IFrame Player API (+ www-widgetapi.js)
"https://s.ytimg.com", // YouTube player assets
"https://w.soundcloud.com", // SoundCloud Widget API (api.js)
"https://open.spotify.com", // Spotify iFrame API (loader)
"https://*.spotifycdn.com", // Spotify iFrame API (real bundle: embed-cdn.spotifycdn.com)
],
// Helmet's default sets script-src-attr to 'none', which blocks ALL inline
// event handlers (onchange/onclick/onsubmit) โ causing e.g. the avatar
// upload () and the role dropdown to
// silently do nothing. We explicitly allow inline handlers, consistent with
// the already-allowed inline