# Moved from hosts/pinix/localServices/xmpp-web.nix on 2026-09-06 # (docs/intent/xmpp-umzug-pinix-blunix.md), alongside setup-prosody.nix. # nginx no longer terminates TLS itself: Traefik already does that for # every other co-located blunix service (see traefik/prosody.nix), so # these vhosts are now plain HTTP behind it on loopback, and no longer # need their own certificate or public firewall opening. { config, lib, pkgs, ... }: let xmppWebVersion = "0.12.0"; launcherPalette = { red = "#ff0026"; strawberry = "#d91e4d"; pink = "#ff0094"; violet = "#e000ff"; orange = "#ff5a00"; }; xmppWebArchive = pkgs.fetchurl { url = "https://github.com/nioc/xmpp-web/releases/download/${xmppWebVersion}/xmpp-web-${xmppWebVersion}.tar.gz"; hash = "sha256-IL9MLf75yP7gqMaV6vzYVXlGqWwIlbdmEK5wH831Ybc="; }; xmppWebCustomCss = pkgs.writeText "xmpp-web-0ff-space.css" '' ${builtins.readFile ../../globalServices/branding/0ff-space-background.css} html { --palette-1: ${launcherPalette.red}; --palette-2: ${launcherPalette.strawberry}; --palette-3: ${launcherPalette.pink}; --palette-4: ${launcherPalette.violet}; --palette-5: ${launcherPalette.orange}; } body, #body, .hero.is-fullheight, .hero.is-fullheight-with-navbar { background-color: transparent !important; background-image: none !important; } /* Registered/Login.vue's

wraps both the logo (logoSrc falls back to a bundled default icon whenever window.config.logoUrl is falsy -- and both configs below set it to "", which *is* falsy in JS, so it never actually suppressed anything) and the "{{ appName }}" text ("0ff.space chat"). Hiding the whole heading removes both and brings the "Login" subtitle right below it up to the top of the box, directly above the fields. The four stacked classes are specific enough to this one element that this shouldn't catch any other heading. Guest/ Login.vue (plaza) has no equivalent heading, so this is a no-op there. */ .title.has-text-grey.is-flex.is-justify-content-center.is-align-items-center { display: none !important; } /* The "Login" subtitle right below the (now-hidden) heading is redundant with the bold "Login" submit button further down the same form -- it doesn't mean "you're not logged in", it's just the form's own label. */ form > p.subtitle.has-text-grey { display: none !important; } ''; # Two independent xmpp-web builds sharing the same upstream release, one # per frontend/vhost (plaza = anonymous guest lobby, chat = login with a # real 0ff.space account). Same tar extraction + local.js swap either # way, only autoJoinLobby (guest-only redirect-into-lobby behavior) # differs structurally enough to need the extra step. mkXmppWeb = { name, configJs, autoJoinLobby ? false }: let configFile = pkgs.writeText "xmpp-web-local-${name}.js" configJs; in pkgs.runCommand "xmpp-web-${xmppWebVersion}-${name}" { nativeBuildInputs = [ pkgs.gnutar pkgs.gzip ]; } ('' mkdir -p "$out" tar -xzf ${xmppWebArchive} --strip-components=1 -C "$out" cp ${configFile} "$out/local.js" cp ${xmppWebCustomCss} "$out/0ff-space.css" substituteInPlace "$out/index.html" \ --replace-fail '' '' # Replace Bulma's five semantic color families with the launcher # palette, including their common hover/active/focus variants. for css in "$out"/assets/*.css; do substituteInPlace "$css" \ --replace-warn '#2da192' '${launcherPalette.strawberry}' \ --replace-warn '#2a9789' '#c31945' \ --replace-warn '#278d80' '#ad153d' \ --replace-warn '#3e8ed0' '${launcherPalette.violet}' \ --replace-warn '#3488ce' '#cd00e8' \ --replace-warn '#3082c5' '#b900d1' \ --replace-warn '#48c78e' '${launcherPalette.pink}' \ --replace-warn '#3ec487' '#e60085' \ --replace-warn '#3abb81' '#cc0076' \ --replace-warn '#ffe08a' '${launcherPalette.orange}' \ --replace-warn '#ffdc7d' '#e85100' \ --replace-warn '#ffd970' '#d14900' \ --replace-warn '#f14668' '${launcherPalette.red}' \ --replace-warn '#f03a5f' '#e60022' \ --replace-warn '#ef2e55' '#cc001e' done '' + lib.optionalString autoJoinLobby '' substituteInPlace "$out/index.html" \ --replace-fail '' '' ''); plazaConfigJs = '' // eslint-disable-next-line no-unused-vars, no-var var config = { name: '0ff.space public chat', transports: { websocket: 'wss://plaza.${config.domain}/xmpp-websocket', }, hasGuestAccess: true, hasRegisteredAccess: false, anonymousHost: 'anon.${config.domain}', isTransportsUserAllowed: false, hasHttpAutoDiscovery: false, resource: '0ff.space Web Chat', defaultDomain: '${config.domain}', defaultMuc: 'muc.${config.domain}', isStylingDisabled: false, hasSendingEnterKey: true, connectTimeout: 5000, pinnedMucs: [ 'lobby@muc.${config.domain}', ], logoUrl: "", sso: { endpoint: false, jidHeader: 'jid', passwordHeader: 'password', }, guestDescription: '

Öffentlicher Chat auf 0ff.space. Wähle einen Nicknamen und schreibe ohne Account mit.

', } ''; # Registered-only client for real 0ff.space accounts (same LDAP-backed # auth Monal uses on the main vhost) -- no guest access, no forced # redirect into the lobby, just a normal client landing on the user's # own roster/joined rooms. chatConfigJs = '' // eslint-disable-next-line no-unused-vars, no-var var config = { name: '0ff.space chat', transports: { websocket: 'wss://chat.${config.domain}/xmpp-websocket', }, hasGuestAccess: false, hasRegisteredAccess: true, isTransportsUserAllowed: false, hasHttpAutoDiscovery: false, resource: '0ff.space Web Chat', defaultDomain: '${config.domain}', defaultMuc: 'muc.${config.domain}', isStylingDisabled: false, hasSendingEnterKey: true, connectTimeout: 5000, logoUrl: "", sso: { endpoint: false, jidHeader: 'jid', passwordHeader: 'password', }, } ''; xmppWebPlaza = mkXmppWeb { name = "plaza"; configJs = plazaConfigJs; autoJoinLobby = true; }; xmppWebChat = mkXmppWeb { name = "chat"; configJs = chatConfigJs; }; websocketLocation = { proxyPass = "http://127.0.0.1:5280/xmpp-websocket"; proxyWebsockets = true; extraConfig = '' proxy_read_timeout 900s; ''; }; rootLocation = { tryFiles = "$uri $uri/ /index.html"; }; # Both vhosts share one plain-HTTP listen socket; nginx dispatches by # Host header the same way it dispatched by SNI before, just without a # TLS handshake of its own -- Traefik (traefik/prosody.nix) already # terminated TLS before the request reaches here. listenOn8080 = [ { addr = "127.0.0.1"; port = 8080; } ]; in { services.nginx = { enable = true; recommendedProxySettings = true; virtualHosts."plaza.${config.domain}" = { listen = listenOn8080; root = xmppWebPlaza; locations."/" = rootLocation; locations."/xmpp-websocket" = websocketLocation; }; virtualHosts."chat.${config.domain}" = { listen = listenOn8080; root = xmppWebChat; locations."/" = rootLocation; locations."/xmpp-websocket" = websocketLocation; }; }; services.prosody.virtualHosts."anon.${config.domain}" = { enabled = true; domain = "anon.${config.domain}"; extraConfig = '' authentication = "anonymous" allow_anonymous_s2s = false modules_enabled = { "websocket"; } modules_disabled = { "bookmarks"; "cloud_notify"; "mam"; "pep"; "private"; "proxy65"; "register"; "roster"; "vcard_legacy"; } http_host = "plaza.${config.domain}" http_external_url = "https://plaza.${config.domain}/" http_paths = { websocket = "/xmpp-websocket"; } cross_domain_websocket = { "https://plaza.${config.domain}"; } ''; ssl = { cert = "/var/lib/prosody/certs/${config.domain}.crt.pem"; key = "/var/lib/prosody/certs/${config.domain}.key.pem"; }; }; }