{ config, lib, pkgs, ... }: let inherit (lib) concatStringsSep mapAttrsToList mkEnableOption mkIf mkOption optionalAttrs optionalString types; cfg = config.services.wafrn; toEnvString = value: if builtins.isBool value then (if value then "true" else "false") else if builtins.isInt value || builtins.isFloat value then toString value else value; quoteEnv = value: let s = toEnvString value; escaped = lib.replaceStrings [ "\\" "\"" "\n" "\r" ] [ "\\\\" "\\\"" "\\n" "" ] s; in "\"${escaped}\""; baseEnv = { ADMIN_USER = "admin"; ADMIN_EMAIL = "admin@example.com"; ADMIN_PASSWORD = "change-me"; JWT_SECRET = "change-me"; DOMAIN_NAME = "wafrn.example.com"; CACHE_DOMAIN = "cache.wafrn.example.com"; MEDIA_DOMAIN = "media.wafrn.example.com"; FRONTEND_MEDIA_URL = "https://media.wafrn.example.com"; FRONTEND_CACHE_URL = "https://cache.wafrn.example.com/api/cache?media="; FRONTEND_FQDN_URL = "https://wafrn.example.com"; FRONTEND_SHORT_TITLE = "Wafrn"; FRONTEND_LONG_TITLE = "Wafrn, the social media that respects you"; FRONTEND_DESCRIPTION = "Wafrn is a federated social media inspired by tumblr that connects with the fediverse and bluesky"; DONATION_URL = "https://wafrn.example.com/about"; REGISTRATION_LEVEL = "PUBLIC"; REGISTRATIONS_DISABLED_TEXT = "This instance is a private instance, and does not allow registrations"; REVIEW_REGISTRATIONS = true; REGISTRATION_MINIMUM_AGE = 18; BUBBLE_SERVERS_SHOW_TYPE = "PUBLIC"; BLOCKED_SERVERS_SHOW_TYPE = "LOGGEDIN"; AUTOFOLLOW_MAIN_ADMIN = true; DISABLE_REQUIRE_SEND_EMAIL = false; SMTP_HOST = "smtp.example.com"; SMTP_USER = "wafrn"; SMTP_PORT = 587; SMTP_PASSWORD = "change-me"; SMTP_FROM = "wafrn@example.com"; POSTGRES_USER = "root"; POSTGRES_PASSWORD = "root"; POSTGRES_DBNAME = "wafrn"; POSTGRES_DB = "wafrn"; ACME_EMAIL = "admin@example.com"; WEBPUSH_EMAIL = "mailto:info@wafrn.net"; WEBPUSH_PRIVATE = ""; WEBPUSH_PUBLIC = ""; ENABLE_BSKY = cfg.bluesky.enable; PDS_DOMAIN_NAME = cfg.bluesky.pdsDomain; PDS_JWT_SECRET = "change-me"; PDS_ADMIN_PASSWORD = "change-me"; PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = ""; ENABLE_RAW_OUTPUT = false; LOG_SQL_QUERIES = ""; UPLOAD_LIMIT = ""; POSTS_PER_PAGE = ""; LOG_LEVEL = ""; BLOCKLIST_URI = ""; FRONTEND_PATH = ""; BLOCKED_IPS = ""; IGNORE_BLOCK_HOSTS = ""; FRONTEND_LOGO = ""; FRONTEND_API_URL = ""; FRONTEND_CACHE_BACKUP_URLS = ""; FRONTEND_SHORTEN_POSTS = "3"; FRONTEND_DISABLE_PWA = false; FRONTEND_MAINTENANCE = false; }; effectiveEnv = baseEnv // cfg.environment; envFileContents = concatStringsSep "\n" (mapAttrsToList (name: value: "${name}=${quoteEnv value}") effectiveEnv) + "\n"; envTemplate = pkgs.writeText "wafrn.env.base" envFileContents; serviceEnvFile = "${cfg.stateDir}/.env"; composeFile = "${cfg.stateDir}/docker-compose.yml"; sourcePath = cfg.source; publishedPorts = lib.optionals (cfg.httpPort != null) [ "${toString cfg.httpPort}:80" ] ++ lib.optionals (cfg.httpsPort != null) [ "${toString cfg.httpsPort}:443" ]; firewallPorts = lib.optionals (cfg.httpPort != null) [ cfg.httpPort ] ++ lib.optionals (cfg.httpsPort != null) [ cfg.httpsPort ]; serviceCommon = { restart = "unless-stopped"; env_file = [ serviceEnvFile ]; }; backendBuild = { context = sourcePath; dockerfile = "packages/backend/Dockerfile"; }; backendService = { build = backendBuild; depends_on = { db = { condition = "service_started"; }; redis = { condition = "service_started"; }; frontend = { condition = "service_started"; }; migration = { condition = "service_completed_successfully"; }; }; environment = { NODE_ENV = "production"; USE_WORKERS = "true"; }; volumes = [ "${cfg.stateDir}/uploads:/app/packages/backend/uploads" "${cfg.stateDir}/cache:/app/packages/backend/cache" "${cfg.stateDir}/frontend:/app/packages/frontend:ro" ]; }; composeConfig = { services = { backend = serviceCommon // backendService; migration = serviceCommon // backendService // { restart = "no"; depends_on = { db = { condition = "service_started"; }; redis = { condition = "service_started"; }; frontend = { condition = "service_started"; }; }; command = "npm exec tsx migrate.ts init-container"; }; frontend = serviceCommon // { build = { context = sourcePath; dockerfile = "packages/frontend/Dockerfile"; }; restart = "unless-stopped"; ports = publishedPorts; extra_hosts = [ "host.docker.internal:host-gateway" ]; volumes = [ "${cfg.stateDir}/caddy:/data" "${cfg.stateDir}/frontend:/var/www/html/frontend" "${cfg.stateDir}/uploads:/var/www/html/uploads" "${sourcePath}/packages/caddy:/etc/caddy/config" ]; }; db = { image = "postgres:17"; restart = "unless-stopped"; shm_size = "2gb"; env_file = [ serviceEnvFile ]; environment = { POSTGRES_DB = "${effectiveEnv.POSTGRES_DBNAME}"; }; volumes = [ "${cfg.stateDir}/postgres:/var/lib/postgresql/data" ]; }; redis = { image = "redis:7.2.4"; restart = "unless-stopped"; volumes = [ "${cfg.stateDir}/redis:/data" ]; }; } // optionalAttrs cfg.adminer.enable { adminer = { image = "adminer"; restart = "unless-stopped"; }; } // optionalAttrs cfg.bluesky.enable { pds_worker = serviceCommon // { build = backendBuild; command = "npm exec tsx atproto.ts"; depends_on = { db = { condition = "service_started"; }; redis = { condition = "service_started"; }; migration = { condition = "service_completed_successfully"; }; }; environment = { NODE_ENV = "production"; USE_WORKERS = "true"; }; volumes = [ "${cfg.stateDir}/uploads:/app/packages/backend/uploads" "${cfg.stateDir}/cache:/app/packages/backend/cache" "${cfg.stateDir}/frontend:/app/packages/frontend:ro" ]; }; } // optionalAttrs (cfg.bluesky.enable && cfg.bluesky.useBundledPds) { pds = { image = "ghcr.io/bluesky-social/pds:0.4"; restart = "unless-stopped"; env_file = [ serviceEnvFile ]; environment = { PDS_DATA_DIRECTORY = "/pds"; PDS_BLOBSTORE_DISK_LOCATION = "/pds/blocks"; PDS_BLOB_UPLOAD_LIMIT = "157286400"; PDS_DID_PLC_URL = "https://plc.directory"; PDS_BSKY_APP_VIEW_URL = "https://api.bsky.app"; PDS_BSKY_APP_VIEW_DID = "did:web:api.bsky.app"; PDS_REPORT_SERVICE_URL = "https://mod.bsky.app"; PDS_REPORT_SERVICE_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; PDS_CRAWLERS = "https://bsky.network, https://atproto.africa"; PDS_EMAIL_SMTP_URL = "smtps://${effectiveEnv.SMTP_USER}:${effectiveEnv.SMTP_PASSWORD}@${toString effectiveEnv.SMTP_HOST}:${toString effectiveEnv.SMTP_PORT}"; PDS_EMAIL_FROM_ADDRESS = "${effectiveEnv.SMTP_FROM}"; LOG_ENABLED = "true"; }; volumes = [ "${cfg.stateDir}/pds:/pds" ]; }; }; networks = { default = { enable_ipv6 = true; }; }; }; composeTemplate = pkgs.writeText "wafrn-compose.yml" (lib.generators.toYAML { } composeConfig); composeCmd = "${pkgs.docker-compose}/bin/docker-compose"; in { options.services.wafrn = { enable = mkEnableOption "Wafrn social platform"; source = mkOption { type = types.str; example = "/srv/wafrn"; description = "Path to a Wafrn source checkout (used as Docker build context)."; }; stateDir = mkOption { type = types.path; default = "/var/lib/wafrn"; description = "Persistent state directory for databases, uploads, caddy data, and optional PDS data."; }; composeProjectName = mkOption { type = types.str; default = "wafrn"; description = "Docker Compose project name."; }; secretsFile = mkOption { type = types.nullOr types.path; default = null; example = "/run/secrets/wafrn.env"; description = "Optional dotenv file with secret overrides appended at runtime."; }; environment = mkOption { type = types.attrsOf (types.oneOf [ types.str types.int types.float types.bool ]); default = { }; description = "Non-secret environment overrides written to the generated .env file."; }; httpPort = mkOption { type = types.nullOr types.port; default = 80; description = "Host port mapped to Wafrn HTTP (container port 80). Set to null to disable publishing HTTP."; }; httpsPort = mkOption { type = types.nullOr types.port; default = 443; description = "Host port mapped to Wafrn HTTPS (container port 443). Set to null to disable publishing HTTPS."; }; autoBuild = mkOption { type = types.bool; default = true; description = "Whether to pass --build when starting containers."; }; openFirewall = mkOption { type = types.bool; default = true; description = "Open configured HTTP/HTTPS ports in the NixOS firewall."; }; adminer.enable = mkOption { type = types.bool; default = false; description = "Enable Adminer container."; }; bluesky = { enable = mkOption { type = types.bool; default = false; description = "Enable Bluesky integration and ATProto worker."; }; useBundledPds = mkOption { type = types.bool; default = false; description = "Run Wafrn's bundled Bluesky PDS container. Set false to use an external PDS."; }; pdsDomain = mkOption { type = types.str; default = "bsky.example.com"; description = "PDS domain used by Wafrn (can point to an external PDS)."; }; }; }; config = mkIf cfg.enable { assertions = [ { assertion = config.virtualisation.docker.enable; message = "services.wafrn requires virtualisation.docker.enable = true;"; } { assertion = cfg.httpPort != null || cfg.httpsPort != null; message = "services.wafrn requires at least one published port (httpPort or httpsPort)."; } ]; systemd.tmpfiles.rules = [ "d ${cfg.stateDir} 0750 root root -" "d ${cfg.stateDir}/postgres 0750 root root -" "d ${cfg.stateDir}/redis 0750 root root -" "d ${cfg.stateDir}/uploads 0750 root root -" "d ${cfg.stateDir}/cache 0750 root root -" "d ${cfg.stateDir}/caddy 0750 root root -" "d ${cfg.stateDir}/frontend 0750 root root -" ] ++ lib.optionals (cfg.bluesky.enable && cfg.bluesky.useBundledPds) [ "d ${cfg.stateDir}/pds 0750 root root -" ]; networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall firewallPorts; systemd.services.wafrn-prepare = { description = "Prepare Wafrn runtime files"; wantedBy = [ "multi-user.target" ]; before = [ "wafrn.service" ]; after = [ "docker.service" ]; path = [ pkgs.nodejs pkgs.coreutils ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' set -euo pipefail if [ ! -d "${cfg.source}" ]; then echo "wafrn-nix: source directory does not exist: ${cfg.source}" >&2 exit 1 fi if [ ! -f "${cfg.source}/package-lock.json" ]; then echo "wafrn-nix: package-lock.json missing, generating with npm" >&2 if ! (cd "${cfg.source}" && npm install --package-lock-only --ignore-scripts); then echo "wafrn-nix: failed to generate package-lock.json, continuing with existing source" >&2 fi fi install -m 0600 ${envTemplate} ${serviceEnvFile} ${optionalString (cfg.secretsFile != null) '' if [ ! -f "${cfg.secretsFile}" ]; then echo "wafrn-nix: secretsFile does not exist: ${cfg.secretsFile}" >&2 exit 1 fi cat "${cfg.secretsFile}" >> ${serviceEnvFile} ''} install -m 0644 ${composeTemplate} ${composeFile} ''; }; systemd.services.wafrn = { description = "Wafrn Docker Compose stack"; wantedBy = [ "multi-user.target" ]; requires = [ "docker.service" "wafrn-prepare.service" ]; after = [ "docker.service" "wafrn-prepare.service" ]; path = [ pkgs.docker-compose pkgs.coreutils ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; WorkingDirectory = cfg.stateDir; }; script = '' ${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} up -d ${optionalString cfg.autoBuild "--build"} ''; preStop = '' ${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} down ''; reload = '' ${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} up -d ${optionalString cfg.autoBuild "--build"} ''; }; }; }