/** * Emberline — Profiles Server * ───────────────────────────── * Install: npm install ws express * Run: node server.js * * Privacy & legal: * - IP addresses are written only to abuse.log (timestamp + rule + IP), never * alongside chats, profiles or reports * - Profiles, requests and chats live in memory only, while their owner is online * - No message content is ever stored (E2EE relay only) * - Reports are written to reports.log (reason + timestamp + optional details) * - Privacy policy served at /privacy * * Environment: * PORT listen port (default 3000) * LOG_DIR directory for abuse.log / reports.log (default: project root). * Must never be inside PUBLIC_DIR. * TRUST_PROXY number of reverse proxies in front of this process * (default 1). Used to pick the real client IP out of * X-Forwarded-For. Set to 0 if clients connect directly. * BAN_ALLOWLIST comma-separated IPs that are never auto-banned * MAX_CONNS_PER_IP concurrent WebSocket connections per IP (default 20) * MAX_PROFILES_PER_IP concurrent profiles per IP (default 5) * BOT_TOKEN shared secret for the operator's AI chat bots (bots/ember-bot.js). * Unset = AI chat disabled. Never commit it. * Tests shorten these (ms): REQUEST_TTL_MS, IDLE_WARNING_MS, IDLE_LOGOFF_MS, * RECONNECT_GRACE_MS, COUNTS_TICK_MS * * Name and word filters (plain text, one entry per line, # for comments), read * from LOG_DIR and reloaded when they change: * reserved-names.txt names containing an entry are refused (default list below) * blocked-words.txt refused in names and interests (empty by default) */ 'use strict'; const express = require('express'); const http = require('http'); const WS = require('ws'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { execSync } = require('child_process'); const PORT = process.env.PORT || 3000; // Only this directory is ever served over HTTP. Everything else in the project // root (server source, node_modules, .git, logs, BUILD_VERSION) stays private. const PUBLIC_DIR = path.join(__dirname, 'public'); // Logs are written outside PUBLIC_DIR so they can never be fetched by URL. const LOG_DIR = path.resolve(process.env.LOG_DIR || __dirname); if (LOG_DIR === PUBLIC_DIR || LOG_DIR.startsWith(PUBLIC_DIR + path.sep)) { console.error('[config] LOG_DIR must not be inside the public directory'); process.exit(1); } // Number of trusted reverse-proxy hops. Each proxy appends the address it // received the request from to X-Forwarded-For, so the real client IP is the // entry TRUST_PROXY positions from the right (counting the socket address). // Anything further left was supplied by the client and cannot be trusted. const TRUST_PROXY = envInt('TRUST_PROXY', 1); // Non-negative integer from the environment, or the default if unset/invalid. function envInt(name, fallback) { const n = parseInt(process.env[name] ?? '', 10); return Number.isInteger(n) && n >= 0 ? n : fallback; } const app = express(); const server = http.createServer(app); // ───────────────────────────────────────────────────────────────────────────── // Constants — all tuneable limits in one place // ───────────────────────────────────────────────────────────────────────────── const MAX_CONNS_PER_IP = envInt('MAX_CONNS_PER_IP', 20); // concurrent WS connections per IP const MAX_WS_CONNECTS_PER_MIN = 20; // new WS connections per IP per minute const MAX_HTTP_API_RPM = 60; // /challenge and the policy pages, per IP per minute const MAX_HTTP_STATIC_RPM = 300; // static assets per IP per minute const MAX_REPORTS_PER_IP = 10; // abuse reports per IP per hour const MAX_CHALLENGES_PER_IP = 60; // challenge tokens per IP per hour const CHALLENGE_TTL_MS = 60_000; const POW_DIFFICULTY = 4; const HEARTBEAT_INTERVAL_MS = 60_000; // 60s — halves ping overhead vs 30s while still // detecting zombie connections within a minute // Automatic bans. Every abuse event (the same events written to abuse.log) // is a strike against the client IP. Too many strikes in the window, or a // single honeypot hit, bans the IP in memory: HTTP gets 403, WebSocket // upgrades are refused. This works regardless of network topology — unlike a // firewall on the app host, which only sees the tunnel/proxy address. // Hitting a rate limit is something a busy shared IP (school, office, VPN // exit) does in normal use, so it counts as at most one strike per IP per // minute and can never reach STRIKE_LIMIT on its own. const STRIKE_LIMIT = 20; // abuse events per window… const STRIKE_WINDOW_MS = 10 * 60 * 1000; // …within 10 minutes const BAN_DURATION_MS = 24 * 60 * 60 * 1000; const BAN_ALLOWLIST = new Set( (process.env.BAN_ALLOWLIST || '').split(',').map(s => s.trim()).filter(Boolean) ); // Hard memory ceilings — protect against flood even if rate limits are bypassed const MAX_CHALLENGES_STORED = 5_000; const MAX_PROFILES = 20_000; const MAX_REQUESTS = 100_000; const MAX_CHATS = 100_000; // Profiles (see PROFILES.md). All in memory, all per person. const MAX_PROFILES_PER_IP = envInt('MAX_PROFILES_PER_IP', 5); // concurrent const MAX_INTERESTS = 10; const GENDERS = new Set(['', 'woman', 'man', 'trans woman (MtF)', 'trans man (FtM)', 'non-binary', 'genderfluid']); const MAX_OUTGOING_REQUESTS = 20; // open requests a profile has sent const MAX_INCOMING_REQUESTS = 30; // open requests a profile can receive const MAX_ACTIVE_CHATS = 20; const MAX_QUEUED_PER_CHAT = 50; // messages waiting for someone who is reconnecting const REQUEST_TTL_MS = envInt('REQUEST_TTL_MS', 10 * 60_000); const REQUEST_BURST = 5; // 5 requests… const REQUEST_REFILL_MS = 12_000; // …a minute const MAX_REQUEST_CHARS = 200; const MAX_REQUEST_CT_B64 = Math.ceil((MAX_REQUEST_CHARS * 3 + 16) / 3) * 4; // 824 const SEARCH_RESULTS = 20; // random sample of the matches const SEARCH_BURST = 10; const SEARCH_REFILL_MS = 3_000; const IDLE_WARNING_MS = envInt('IDLE_WARNING_MS', 28 * 60_000); const IDLE_LOGOFF_MS = envInt('IDLE_LOGOFF_MS', 30 * 60_000); const RECONNECT_GRACE_MS = envInt('RECONNECT_GRACE_MS', 5 * 60_000); const COUNTS_TICK_MS = Math.max(100, envInt('COUNTS_TICK_MS', 5_000)); const TOP_INTERESTS = 50; // "popular now" on Discover const MAX_WATCHED_INTERESTS = 40; const MAX_ALL_INTERESTS = 200; // Chat message relay. Token bucket per connection: bursts of MSG_BURST are // fine (network jitter can bunch frames together), sustained rate is one // message per MSG_REFILL_MS. const MSG_BURST = 5; const MSG_REFILL_MS = 300; // Every frame a person's socket sends, of any type. A real client stays far // below this (paced messages + a typing ping every 2s); going over it closes // the socket and counts as a strike. const FRAME_BURST = 30; const FRAME_REFILL_MS = 100; // Creating a profile and starting an AI chat. A proof-of-work is only needed // once per socket, so this caps how fast one socket can churn through // profiles or bots. Over the limit the client is asked to retry later (no strike). const JOIN_BURST = 5; const JOIN_REFILL_MS = 3_000; const MAX_JOINS_PER_IP_PER_MIN = 120; // across all sockets of one IP // Must match maxlength on the chat input. A JS string of N UTF-16 units encodes // to at most 3N UTF-8 bytes; NaCl box adds a 16-byte tag; base64 is 4/3. const MAX_MESSAGE_CHARS = 300; const MAX_CIPHERTEXT_B64 = Math.ceil((MAX_MESSAGE_CHARS * 3 + 16) / 3) * 4; // 1224 const CIPHERTEXT_RE = /^[A-Za-z0-9+/]+={0,2}$/; const NONCE_RE = /^[A-Za-z0-9+/]{32}$/; // 24-byte NaCl nonce const HONEYPOT_KEYWORD = '__honeypot__'; const VALID_REASONS = new Set(['csam', 'harassment', 'illegal', 'spam']); // report categories // Allowed WebSocket origins — set to your production domain(s). // null/undefined origin (non-browser clients) is allowed so CLI tools still work. const ALLOWED_WS_ORIGINS = new Set([ `http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`, 'https://emberline.ch', 'https://www.emberline.ch', ]); // NaCl box public key: exactly 32 bytes → 43 base64 chars + one '=' pad const PUBKEY_RE = /^[A-Za-z0-9+/]{43}=$/; // ───────────────────────────────────────────────────────────────────────────── // In-memory state (never written to disk) // ───────────────────────────────────────────────────────────────────────────── const challenges = new Map(); // token → { prefix, expiresAt, ip } const profiles = new Map(); // id → profile (see createProfile) const byName = new Map(); // lowercased name → id const byToken = new Map(); // resume token → id const byInterest = new Map(); // interest → Set; also the source of the counts const profilesPerIp = new Map(); // ip → concurrent profiles const requests = new Map(); // id → { from, to, ciphertext, nonce, expiresAt, declined } const chats = new Map(); // id → { a, b, ai, bot, open: Set } // Per-IP rate tracking — values are ephemeral, never persisted const ipConnections = new Map(); // ip → count (concurrent) const wsConnectRate = new Map(); // ip → { count, resetAt } const httpApiRate = new Map(); // ip → { count, resetAt } const httpStaticRate = new Map(); // ip → { count, resetAt } const reportThrottle = new Map(); // ip → { count, resetAt } const challengeRate = new Map(); // ip → { count, resetAt } const joinRate = new Map(); // ip → { count, resetAt } const lastRateStrike = new Map(); // ip → timestamp of the last rate-limit strike // Operator-run AI chat bots (see bots/). A person only ever chats with a bot // after choosing to, and the chat is always labeled as AI. const botSockets = new Set(); // every authenticated bot connection const idleBots = new Set(); // bots ready for a new conversation const abuseStrikes = new Map(); // ip → { count, resetAt } const bannedIPs = new Map(); // ip → bannedUntil timestamp // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function getIP(req) { const fwd = req.headers['x-forwarded-for']; const chain = (typeof fwd === 'string' ? fwd.split(',') : []) .map(s => s.trim()) .filter(Boolean); chain.push(req.socket?.remoteAddress || 'unknown'); // Walk back TRUST_PROXY hops from the socket; clamp to the leftmost entry. return chain[Math.max(0, chain.length - 1 - TRUST_PROXY)]; } function send(ws, obj) { if (ws.readyState === WS.OPEN) ws.send(JSON.stringify(obj)); } function randomId() { return crypto.randomBytes(8).toString('hex'); // 64-bit cryptographically random room ID } // Abuse log — timestamp, rule and client IP only. No content, keywords or reports. const ABUSE_LOG = path.join(LOG_DIR, 'abuse.log'); const REPORTS_LOG = path.join(LOG_DIR, 'reports.log'); function writeAbuseLine(reason, ip) { const line = `${new Date().toISOString()} [${reason}] ip=${ip}\n`; fs.appendFile(ABUSE_LOG, line, err => { if (err) console.error('[abuse-log] write failed:', err.message); }); } // Records an abuse event and counts it as a strike towards an automatic ban. function logAbuse(reason, ip) { writeAbuseLine(reason, ip); if (rateExceeded(abuseStrikes, ip, STRIKE_LIMIT, STRIKE_WINDOW_MS)) banIP(ip); } // A rate limit was hit: logged and counted at most once per IP per minute // (see STRIKE_LIMIT), so shared IPs aren't banned for being busy. function logRateHit(reason, ip) { const now = Date.now(); if (now - (lastRateStrike.get(ip) || 0) < 60_000) return; lastRateStrike.set(ip, now); logAbuse(reason, ip); } function banIP(ip) { if (BAN_ALLOWLIST.has(ip) || isBanned(ip)) return; bannedIPs.set(ip, Date.now() + BAN_DURATION_MS); abuseStrikes.delete(ip); writeAbuseLine('banned', ip); console.log(`[ban] banned=${bannedIPs.size}`); } // Bots authenticate with "Authorization: Bearer " on the upgrade. // Browsers can't set that header, so a page can never pose as a bot. const BOT_TOKEN_HASH = process.env.BOT_TOKEN ? crypto.createHash('sha256').update(process.env.BOT_TOKEN).digest() : null; function isBotRequest(req) { if (!BOT_TOKEN_HASH) return false; const auth = req.headers['authorization']; if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) return false; const given = crypto.createHash('sha256').update(auth.slice(7)).digest(); return crypto.timingSafeEqual(given, BOT_TOKEN_HASH); } function isBanned(ip) { const until = bannedIPs.get(ip); if (!until) return false; if (Date.now() > until) { bannedIPs.delete(ip); return false; } return true; } // Fixed-window rate limiter. Returns true if the IP is over the limit. function rateExceeded(map, ip, maxPerWindow, windowMs) { const now = Date.now(); const rec = map.get(ip) || { count: 0, resetAt: now + windowMs }; if (now > rec.resetAt) { rec.count = 0; rec.resetAt = now + windowMs; } rec.count++; map.set(ip, rec); return rec.count > maxPerWindow; } // Token bucket stored on the socket: up to `burst` tokens, one more every // `refillMs`. Takes a token and returns 0, or returns the ms until one is free. function takeToken(ws, key, burst, refillMs) { const now = Date.now(); const b = ws[key] ??= { tokens: burst, at: now }; b.tokens = Math.min(burst, b.tokens + (now - b.at) / refillMs); b.at = now; if (b.tokens >= 1) { b.tokens -= 1; return 0; } return Math.ceil((1 - b.tokens) * refillMs); } // How long a person must wait before their next profile or AI chat (0 = go ahead). function joinWait(ws) { const wait = takeToken(ws, '_joinBucket', JOIN_BURST, JOIN_REFILL_MS); if (wait) return wait; if (rateExceeded(joinRate, ws._ip, MAX_JOINS_PER_IP_PER_MIN, 60_000)) { return Math.max(1000, joinRate.get(ws._ip).resetAt - Date.now()); } return 0; } // Name and word filters, editable without a release (see header) const DEFAULT_RESERVED_NAMES = ['admin', 'emberline', 'moderator', 'support', 'system', 'official']; const FILTER_FILES = { reserved: path.join(LOG_DIR, 'reserved-names.txt'), blocked: path.join(LOG_DIR, 'blocked-words.txt'), }; let reservedNames = DEFAULT_RESERVED_NAMES; let blockedWords = []; function readList(file, fallback) { try { return fs.readFileSync(file, 'utf8').split('\n') .map(l => l.replace(/#.*/, '').trim().normalize('NFC').toLowerCase()).filter(Boolean); } catch { return fallback; } // no file: defaults } function loadFilters() { reservedNames = readList(FILTER_FILES.reserved, DEFAULT_RESERVED_NAMES); blockedWords = readList(FILTER_FILES.blocked, []); } loadFilters(); // Polling rather than fs.watch: the files are bind-mounted into the container for (const f of Object.values(FILTER_FILES)) fs.watchFile(f, { interval: 10_000, persistent: false }, loadFilters); // ───────────────────────────────────────────────────────────────────────────── // Periodic sweep — evict expired entries, keep all maps bounded // ───────────────────────────────────────────────────────────────────────────── setInterval(() => { const now = Date.now(); for (const [k, v] of bannedIPs) { if (now > v) bannedIPs.delete(k); } for (const [k, v] of abuseStrikes) { if (now > v.resetAt) abuseStrikes.delete(k); } for (const [k, v] of wsConnectRate) { if (now > v.resetAt) wsConnectRate.delete(k); } for (const [k, v] of httpApiRate) { if (now > v.resetAt) httpApiRate.delete(k); } for (const [k, v] of httpStaticRate) { if (now > v.resetAt) httpStaticRate.delete(k); } for (const [k, v] of reportThrottle) { if (now > v.resetAt) reportThrottle.delete(k); } for (const [k, v] of challengeRate) { if (now > v.resetAt) challengeRate.delete(k); } for (const [k, v] of joinRate) { if (now > v.resetAt) joinRate.delete(k); } for (const [k, v] of lastRateStrike) { if (now - v > 60_000) lastRateStrike.delete(k); } }, 60 * 60 * 1000); // hourly // Challenge tokens expire after 60s but the hourly sweep let stale entries // accumulate (~36k at 10 challenges/sec). This dedicated timer keeps the map // tight without waiting for the hourly pass or relying on per-request sweeps. setInterval(() => { const now = Date.now(); for (const [k, v] of challenges) { if (now > v.expiresAt) challenges.delete(k); } }, 60_000); // every 60s — matches CHALLENGE_TTL_MS // ───────────────────────────────────────────────────────────────────────────── // HTTP middleware — security headers + split-budget rate limiting // All middleware must be declared AFTER the helpers above. // ───────────────────────────────────────────────────────────────────────────── app.disable('x-powered-by'); app.set('query parser', false); // no route reads req.query — keep qs off the request path // No 'unsafe-inline' for styles: pages have no style="" attributes, and each // inline

Privacy Policy

Effective date: ${POLICY_EFFECTIVE_DATE}  ·  Jurisdiction: Switzerland

Emberline is an ephemeral chat platform with temporary profiles. This policy describes what data we collect, what we do not collect, and your rights under Swiss law (nFADP).

What we do not collect

We do not collect email addresses, phone numbers, real names, or any other identifying information. There is no registration and no account. We do not store chat messages — messages are relayed in real time using end-to-end encryption and are never written to disk. If the other person's connection drops for a moment, your messages wait for them in server memory, still encrypted, for up to 5 minutes; if they don't come back, the messages are discarded. We have no ability to retrieve or reconstruct past conversations.

Your profile

To go online you create a temporary profile: a username, optionally a gender, and up to ten interests. Your profile is visible to anyone who is online at the same time: it can appear in their random list of people online, and in their searches for one of your interests. It is held only in the server's memory, never written to disk, and deleted when you log off, when you close or reload the page, after 30 minutes without activity, 5 minutes after your connection drops, or when the server restarts.

Other people can see, remember or copy what your profile shows while you are online; we cannot delete what they saw. Gender is optional. It, and your interests, can reveal sensitive information about you (for example that you are transgender, or your religion, health or orientation). Only add what you are comfortable showing to strangers, and nothing that identifies you.

The server refuses usernames containing certain reserved words (such as "admin" or "emberline") and may refuse words on a block list in usernames and interests. These checks run in memory when you create a profile; nothing is logged.

Requests and chats

Nobody can write to you until you accept their request. A request carries a short message, which is end-to-end encrypted like chat messages: the server holds it, encrypted, for up to 10 minutes until it is accepted, declined or expires, and cannot read it. A declined request looks to the sender exactly like one that wasn't answered.

For the rest of your session the server also keeps, in memory: whether you accept requests, who you blocked, and who did not answer your requests (you can't ask them again that session). All of it is deleted with your profile.

When a chat ends, it is removed from your list; the other person keeps their copy on their screen until they close it.

Reports

When you report someone, we record the report timestamp, the reason category, the reported person's username, and — only if you choose to write it — up to 500 characters of free-text details. If you choose to attach the request message that person sent you, it is recorded too, marked as unverified: because of the encryption, we cannot check that it is what they actually sent. No chat messages are attached, and no IP address or information about you is recorded with the report. Please do not include personal information in the details. Reports are retained for a maximum of 90 days.

IP addresses

We do not log IP addresses in association with chat content, profiles, reports, or any durable user record. An IP-based abuse defense runs at the connection layer: when a client trips a rate limit, floods the server with messages, fails a proof-of-work check, hits a honeypot, or tries to connect from another website, an entry is written to an abuse log containing only a timestamp, the triggered rule, and the source IP. The same log records when an IP is banned. Separately, to enforce rate limits and the limit of five profiles per network at a time, the server keeps IP addresses in memory while you are connected and for up to two hours afterwards (24 hours for a banned IP); this is never written to disk. The abuse log feeds a ban system that temporarily blocks repeat offenders and is rotated after 90 days. It is never cross-referenced against reports, profiles, or conversations — and cannot be, because none of those are stored. This is the minimum defense a fully anonymous service requires to remain functional.

Hosting

Emberline is reached through a virtual server we rent from a hosting provider in Switzerland. It terminates the HTTPS connection and forwards the traffic through an encrypted tunnel to the server that runs Emberline, also in Switzerland. Because it sits in the connection path, the provider's infrastructure necessarily handles your IP address and the traffic passing through — for chat messages and requests, only end-to-end encrypted data. Access logging is disabled on this server. The provider only supplies the infrastructure; we share no data with it for any other purpose. Both servers are located in Switzerland.

End-to-end encryption

Requests and chat messages are encrypted on your device using the NaCl box construction (Curve25519 + XSalsa20 + Poly1305). Only the two participants can decrypt them. The server relays encrypted data it cannot read. In an AI chat, the AI is the other participant (see below).

AI chat

You can choose to chat with an AI instead of a person. This only happens if you start it, and an AI chat is labeled as such for its entire duration. The AI is a language model running on hardware operated by Emberline. It is the other participant in the conversation, so to reply it decrypts your messages and receives your profile: your username, your gender if you chose to show one, and your interests as conversation topics. AI conversations are held in memory only while the chat lasts; they are not stored, logged, or used to train models. The AI can be wrong or say strange things — do not rely on it for advice, and do not share personal information with it.

Cookies, tracking, and storage

We use no cookies, no analytics, no tracking pixels, and no third-party services in your browser. We do not use localStorage, sessionStorage, or any other form of persistent client-side storage: your profile, requests and chats exist only in the open page. If you open Emberline in a second tab of the same browser, that tab can take over your session; the two tabs hand it over directly in memory, and nothing is stored. All fonts and cryptography libraries are self-hosted — no external requests are made by your browser.

Illegal content

Use of Emberline to share, solicit, or facilitate illegal content — including CSAM, harassment, or content illegal under Swiss law — is strictly prohibited. We cooperate with Swiss law enforcement under the Swiss Criminal Code.

Your rights under nFADP

You have the right to request access to any personal data we hold about you and to request its deletion. Your profile, requests and session data are deleted automatically when you log off, and we store no messages and no session history, so there is typically nothing to disclose or delete. Reports contain the username of the person reported, which is no longer linked to anyone once their profile is gone. The one category of data that could constitute personal data about you under nFADP is the IP entries in the abuse log described above; these can be removed on request if you provide the IP and an approximate time window. Contact: contactall@emberline.ch.

Changes

We may update this policy as the platform evolves. The effective date above reflects the most recent revision.


← Back to Emberline

`); app.get('/privacy', (req, res) => res.send(PRIVACY_HTML)); // ───────────────────────────────────────────────────────────────────────────── // REST: /terms // ───────────────────────────────────────────────────────────────────────────── const TERMS_HTML = allowStyleBlocks(` Terms of Service — Emberline

Terms of Service

Effective date: ${TERMS_EFFECTIVE_DATE}  ·  Jurisdiction: Switzerland  ·  Governing law: Swiss Code of Obligations (OR)

By using Emberline, you agree to these terms. If you do not agree, please do not use the platform.

1. What Emberline is

Emberline is an ephemeral chat service. You go online with a temporary profile (a username, optionally a gender, and your interests), find people who share an interest, and chat with them once they accept your request. No accounts required. No messages stored. Requests and conversations are end-to-end encrypted. Your profile, requests and chats are deleted when you log off.

2. Eligibility

You must be at least 18 years old to use Emberline. By creating a profile you confirm this.

3. Your profile

Your profile is visible to other people online. You are responsible for what it shows. Do not use a username or interests that impersonate another person or Emberline, that are illegal, hateful or sexually explicit, or that contain someone else's personal data. Emberline may refuse usernames and words without giving a reason.

4. Requests and chats

A request must carry a message, so the other person can decide whether to accept. Do not send requests in bulk, and respect a request that is not answered: you cannot ask the same person again in that session. You can end a chat, block a person or report them at any time.

5. Prohibited conduct

You agree not to transmit, solicit, share, or facilitate:

  • Child sexual abuse material (CSAM) or any content sexualising minors
  • Threats of violence, harassment, stalking, or intimidation
  • Content illegal under Swiss law or your country of residence
  • Coordinated manipulation, deception, or fraud
  • Automated access (bots, scrapers, scripts), other than Emberline's own clearly labeled AI chat
  • Attempts to circumvent security or encryption mechanisms

6. CSAM — zero tolerance

Any user who transmits, solicits, or facilitates CSAM will be reported to KOBIK immediately. Report directly at www.kobik.ch.

7. AI chat

Emberline offers an optional chat with an AI model operated by Emberline. It only starts at your request and is labeled as AI for its entire duration. AI replies are generated automatically, can be inaccurate or inappropriate, and are not advice of any kind. These terms apply to AI chats as well. The AI ends a chat if a user indicates they are under 18.

8. Anonymity and its limits

Emberline is designed to be anonymous. We require no accounts, store no messages, and keep profiles only while you are online. An IP-based abuse defense log is maintained for bot prevention only — see the Privacy Policy for full details. Anonymity at the technical layer does not exempt users from legal responsibility under Swiss law.

9. No warranty

Emberline is provided as-is without warranty of any kind. Use is at your own risk.

10. Governing law

These terms are governed exclusively by Swiss law. Disputes are subject to the exclusive jurisdiction of Swiss courts.

11. Contact

Legal notices and law enforcement requests: contactall@emberline.ch.


Privacy policy  ·  Back to Emberline

`); app.get('/terms', (req, res) => res.send(TERMS_HTML)); // ───────────────────────────────────────────────────────────────────────────── // Build version — commit hash footer // ───────────────────────────────────────────────────────────────────────────── // Resolves which source version is currently running so the client can show // a commit hash footer linked to the GitHub tree. Three fallbacks: // 1. BUILD_VERSION file written by the pre-deploy script (production path) // 2. git rev-parse HEAD if a .git directory exists (local dev path) // 3. the literal string "dev" (self-hosted zip downloads, CI sandboxes) // In the "dev" case the footer renders without the hash — there is no // specific commit to verify against, so showing "build dev" would be noise. const BUILD_VERSION = (() => { const buildFile = path.join(__dirname, 'BUILD_VERSION'); try { const v = fs.readFileSync(buildFile, 'utf8').trim(); if (v && /^[0-9a-f]{7,40}$/.test(v)) return v; } catch {} // file missing — try git try { const v = execSync('git rev-parse HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, }).toString().trim(); if (/^[0-9a-f]{40}$/.test(v)) return v; } catch {} // not a git repo, or git not installed return 'dev'; })(); const BUILD_VERSION_SHORT = BUILD_VERSION === 'dev' ? 'dev' : BUILD_VERSION.slice(0, 7); // Footer HTML fragment: either a linked short-hash or empty (hidden with its separator). // Kept as a pre-rendered fragment so index.html stays clean — one placeholder, one replace. const GITHUB_REPO_URL = 'https://github.com/ProjectEmberline/emberline'; const BUILD_FOOTER_FRAGMENT = BUILD_VERSION === 'dev' ? '' : `build ${BUILD_VERSION_SHORT}  ·  `; // Index template — inject placeholder at startup. // This route MUST come before express.static so the static handler doesn't // serve the raw template with unreplaced placeholders. const INDEX_SOURCE = (() => { try { return allowStyleBlocks(fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8') .replace(/__BUILD_FOOTER__/g, BUILD_FOOTER_FRAGMENT)); } catch (err) { console.error('[index] failed to read index.html template:', err.message); return 'Emberline

Server misconfigured.

'; } })(); app.get('/', (req, res) => { res.type('text/html; charset=utf-8'); res.send(INDEX_SOURCE); }); app.get('/index.html', (req, res) => { res.type('text/html; charset=utf-8'); res.send(INDEX_SOURCE); }); // ───────────────────────────────────────────────────────────────────────────── // Static files — PUBLIC_DIR only. Dotfiles are denied explicitly. // ───────────────────────────────────────────────────────────────────────────── app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false, // '/' is served by the templated route above setHeaders: (res, filePath) => { if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript'); if (filePath.endsWith('.css')) res.setHeader('Content-Type', 'text/css'); if (filePath.endsWith('.html')) res.setHeader('Content-Type', 'text/html'); } })); // ───────────────────────────────────────────────────────────────────────────── // Errors — replaces Express's default handler, which logs every error's stack // and, outside production, sends it back. // Client errors are answered and never logged; server bugs log the stack only. // ───────────────────────────────────────────────────────────────────────────── app.use((err, req, res, next) => { if (res.headersSent) return next(err); const status = err.status >= 400 && err.status < 500 ? err.status : 500; if (status === 500) console.error('[http] error:', err.stack || err); res.status(status).json({ error: status === 500 ? 'server error' : 'bad request' }); }); // ───────────────────────────────────────────────────────────────────────────── // Start // ───────────────────────────────────────────────────────────────────────────── server.listen(PORT, () => { console.log(`Emberline listening on http://localhost:${PORT}`); console.log(`Public → ${PUBLIC_DIR}`); console.log(`Reports → ${REPORTS_LOG}`); console.log(`Abuse → ${ABUSE_LOG}`); console.log(`Proxy → trusting ${TRUST_PROXY} hop(s) of X-Forwarded-For`); console.log(`BUILD → ${BUILD_VERSION_SHORT}${BUILD_VERSION === 'dev' ? '' : ` (${BUILD_VERSION})`}`); });