/** * 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 adminCommentsRoutes from './routes/admin-comments.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 commentsRoutes from './routes/comments.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 hubRoutes from './routes/hub.js'; import artistsRoutes from './routes/artists.js'; import postsRoutes from './routes/posts.js'; import langRoutes from './routes/lang.js'; import federationRoutes from './routes/federation.js'; import { startCircleSyncLoop } from './services/CircleService.js'; import adminCircleRoutes from './routes/admin-circle.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'; // 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; 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