{ config, lib, pkgs, osConfig, ... }: with lib; let # Database configuration assertions dbUrlConfigured = config.services.panoramax.database.url != null; individualDbConfigured = all (x: x != null) [ config.services.panoramax.database.host config.services.panoramax.database.port config.services.panoramax.database.username config.services.panoramax.database.password config.services.panoramax.database.name ]; envContent = '' # Panoramax Configuration FLASK_APP=geovisio ${ if dbUrlConfigured then "DB_URL=${config.services.panoramax.database.url}" else '' DB_HOST=${config.services.panoramax.database.host} DB_PORT=${toString config.services.panoramax.database.port} DB_USERNAME=${config.services.panoramax.database.username} DB_PASSWORD=${config.services.panoramax.database.password} DB_NAME=${config.services.panoramax.database.name} '' } ${optionalString (config.services.panoramax.storage.fsUrl != null) "FS_URL=${config.services.panoramax.storage.fsUrl}"} ${optionalString (config.services.panoramax.infrastructure.nbProxies != null) "INFRA_NB_PROXIES=${toString config.services.panoramax.infrastructure.nbProxies}"} ${optionalString (config.services.panoramax.flask.secretKey != null) "FLASK_SECRET_KEY=${config.services.panoramax.flask.secretKey}"} ${optionalString (config.services.panoramax.flask.sessionCookieDomain != null) "FLASK_SESSION_COOKIE_DOMAIN=${config.services.panoramax.flask.sessionCookieDomain}"} ${optionalString (config.services.panoramax.api.pictures.licenseSpdxId != null) "API_PICTURES_LICENSE_SPDX_ID=${config.services.panoramax.api.pictures.licenseSpdxId}"} ${optionalString (config.services.panoramax.api.pictures.licenseUrl != null) "API_PICTURES_LICENSE_URL=${config.services.panoramax.api.pictures.licenseUrl}"} ${optionalString (config.services.panoramax.port != null) "PORT=${toString config.services.panoramax.port}"} ${optionalString (config.services.panoramax.sgblur.enable) "SGBLUR_API_URL=${config.services.panoramax.sgblur.url}"} ${concatStringsSep "\n" (mapAttrsToList (name: value: "${name}=${value}") config.services.panoramax.extraEnvironment)} ''; envFile = pkgs.writeText "panoramax.env" envContent; in { options.services.panoramax = { enable = lib.mkEnableOption "panoramax"; package = lib.mkOption { type = lib.types.package; default = pkgs.panoramax; description = "The panoramax package to use"; }; subdomain = lib.mkOption { type = lib.types.str; description = "subdomain of base domain that panoramax will be hosted at"; default = "panoramax"; }; database = { createDB = mkOption { type = types.bool; default = true; description = "Whether to automatically create the database and user"; }; url = mkOption { type = types.nullOr types.str; default = null; description = '' Complete database URL connection string (e.g., "postgresql://user:password@host:port/dbname"). If provided, individual database options (host, port, username, password, name) are ignored. ''; }; port = mkOption { type = types.nullOr types.port; default = 5432; description = "Database port (ignored if database.url is set)"; }; host = mkOption { type = types.nullOr types.str; default = "localhost"; description = "Database host (ignored if database.url is set)"; }; username = mkOption { type = types.nullOr types.str; default = "panoramax"; description = "Database username (ignored if database.url is set)"; }; password = mkOption { type = types.nullOr types.str; default = null; description = "Database password (ignored if database.url is set)"; }; name = mkOption { type = types.str; default = "panoramax"; description = "Database name (ignored if database.url is set)"; }; }; sgblur = { enable = mkOption { type = types.bool; default = false; description = "Whether to enable sgblur integration for face and license plate blurring"; }; package = mkOption { type = types.package; default = pkgs.sgblur; description = "The sgblur package to use"; }; port = mkOption { type = types.port; default = 8080; description = "Port for the sgblur service"; }; host = mkOption { type = types.str; default = "127.0.0.1"; description = "Host to bind the sgblur service to"; }; url = mkOption { type = types.str; default = "http://127.0.0.1:8080"; description = "URL where sgblur service is accessible"; }; }; port = mkOption { type = types.nullOr types.port; default = 5000; description = "Port for the Panoramax service"; }; host = mkOption { type = types.str; default = "127.0.0.1"; description = "Host to bind the Panoramax service to"; }; urlScheme = mkOption { type = types.enum ["http" "https"]; default = "https"; description = "URL scheme for the application"; }; storage = { fsUrl = mkOption { type = types.nullOr types.str; default = "/var/lib/panoramax/storage"; description = "File system URL for storage"; }; }; infrastructure = { nbProxies = mkOption { type = types.nullOr types.int; default = 1; description = "Number of proxies in front of the application"; }; }; flask = { secretKey = mkOption { type = types.nullOr types.str; default = null; description = "Flask secret key for session security"; }; sessionCookieDomain = mkOption { type = types.nullOr types.str; default = null; description = "Flask session cookie domain"; }; }; api = { pictures = { licenseSpdxId = mkOption { type = types.nullOr types.str; default = null; description = "SPDX license identifier for API pictures"; }; licenseUrl = mkOption { type = types.nullOr types.str; default = null; description = "License URL for API pictures"; }; }; }; extraEnvironment = mkOption { type = types.attrsOf types.str; default = {}; description = "Additional environment variables"; example = { CUSTOM_SETTING = "value"; DEBUG = "true"; }; }; }; config = lib.mkIf config.services.panoramax.enable ( lib.mkMerge [ { environment.systemPackages = with pkgs; [ config.services.panoramax.package python3Packages.waitress ] ++ optionals config.services.panoramax.sgblur.enable [ config.services.panoramax.sgblur.package ]; systemd.services.panoramax = { description = "Panoramax Service"; after = ["network.target"]; wantedBy = ["multi-user.target"]; serviceConfig = { ExecStart = "${pkgs.python3Packages.waitress}/bin/waitress-serve --env-file=${envFile} --host=${config.services.panoramax.host} --port=${toString config.services.panoramax.port} --url-scheme=${config.services.panoramax.urlScheme} --call geovisio:create_app"; Restart = "always"; User = "panoramax"; Group = "panoramax"; WorkingDirectory = "/var/lib/panoramax"; Environment = "PYTHONPATH=${config.services.panoramax.package}/lib/python3.11/site-packages"; }; }; users.users.panoramax = { isSystemUser = true; group = "panoramax"; home = "/var/lib/panoramax"; createHome = true; }; users.groups.panoramax = {}; systemd.tmpfiles.rules = [ "d /var/lib/panoramax 0755 panoramax panoramax -" "d ${config.services.panoramax.storage.fsUrl} 0755 panoramax panoramax -" ]; assertions = [ { assertion = dbUrlConfigured || individualDbConfigured; message = '' Panoramax database configuration requires either: - A complete database URL (services.panoramax.database.url), OR - All individual database options (host, port, username, password, name) Currently configured: - database.url: ${ if dbUrlConfigured then "✓ configured" else "✗ not configured" } - individual options: ${ if individualDbConfigured then "✓ all configured" else "✗ some missing" } ''; } { assertion = !config.services.panoramax.database.createDB || config.services.panoramax.database.url == null || (lib.hasPrefix "/run/" config.services.panoramax.database.url || lib.hasPrefix "unix:" config.services.panoramax.database.url || lib.hasPrefix "/" config.services.panoramax.database.host); message = '' Panoramax createDB option can only be used with socket connections when a database URL is provided. Socket connections are identified by: - URLs starting with "unix:" - URLs starting with "/run/" - Host paths starting with "/" Current configuration: - createDB: ${lib.boolToString config.services.panoramax.database.createDB} - database.url: ${ if config.services.panoramax.database.url != null then config.services.panoramax.database.url else "not set" } - database.host: ${config.services.panoramax.database.host} ''; } ]; } ( lib.mkIf config.services.panoramax.sgblur.enable { systemd.services.sgblur = { description = "SGBlur AI-powered face and license plate blurring service"; after = ["network.target"]; wantedBy = ["multi-user.target"]; serviceConfig = { ExecStart = "${config.services.panoramax.sgblur.package}/bin/uvicorn sgblur.main:app --host ${config.services.panoramax.sgblur.host} --port ${toString config.services.panoramax.sgblur.port}"; Restart = "always"; User = "sgblur"; Group = "sgblur"; WorkingDirectory = "/var/lib/sgblur"; Environment = "PYTHONPATH=${config.services.panoramax.sgblur.package}/lib/python3.11/site-packages"; }; }; users.users.sgblur = { isSystemUser = true; group = "sgblur"; home = "/var/lib/sgblur"; createHome = true; }; users.groups.sgblur = {}; systemd.tmpfiles.rules = [ "d /var/lib/sgblur 0755 sgblur sgblur -" ]; # Update panoramax service dependencies when sgblur is enabled systemd.services.panoramax = { after = ["sgblur.service"]; wants = ["sgblur.service"]; }; } ) ( lib.mkIf config.services.panoramax.database.createDB { services.postgresql = { enable = true; ensureDatabases = [config.services.panoramax.database.name]; ensureUsers = [ { name = config.services.panoramax.database.username; ensureDBOwnership = true; ensureClauses.login = true; } ]; extensions = ps: with ps; [postgis]; settings = { shared_preload_libraries = ["postgis"]; }; }; systemd.services.postgresql.serviceConfig.ExecStartPost = let sqlFile = pkgs.writeText "panoramax-postgis-setup.sql" '' CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS postgis_topology; CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder; ALTER SCHEMA public OWNER TO ${config.services.panoramax.database.username}; GRANT ALL ON SCHEMA public TO ${config.services.panoramax.database.username}; ''; in [ '' ${lib.getExe' config.services.postgresql.package "psql"} -d "${config.services.panoramax.database.name}" -f "${sqlFile}" '' ]; systemd.services.panoramax = { after = ["postgresql.service"]; requires = ["postgresql.service"]; }; } ) ( lib.mkIf config.host.reverse_proxy.enable { host = { reverse_proxy.subdomains.${config.services.panoramax.subdomain} = { target = "http://localhost:${toString config.services.panoramax.port}"; websockets.enable = true; forwardHeaders.enable = true; extraConfig = '' # allow large file uploads for panoramic images client_max_body_size 100M; # set timeout for image processing proxy_read_timeout 300s; proxy_send_timeout 300s; send_timeout 300s; proxy_redirect off; ''; }; }; } ) ( lib.mkIf config.services.fail2ban { # TODO: configure options for fail2ban } ) ( lib.mkIf osConfig.host.impermanence.enable { # TODO: configure impermanence for panoramax data } ) ] ); }