diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md index 909e16285827..80ab576b438c 100644 --- a/nixos/doc/manual/release-notes/rl-2505.section.md +++ b/nixos/doc/manual/release-notes/rl-2505.section.md @@ -134,6 +134,8 @@ - [cross-seed](https://www.cross-seed.org), a tool to set-up fully automatic cross-seeding of torrents. Available as [services.cross-seed](#opt-services.cross-seed.enable). +- [Froide-Govplan](https://github.com/okfde/froide-govplan), a web application government planer. Available as [services.froide-govplan](#opt-services.froide-govplan.enable). + - [agorakit](https://github.com/agorakit/agorakit), an organization tool for citizens' collectives. Available with [services.agorakit](options.html#opt-services.agorakit.enable). - [vivid](https://github.com/sharkdp/vivid), a generator for LS_COLOR. Available as [programs.vivid](#opt-programs.vivid.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 7d937f46e531..902503335169 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1516,6 +1516,7 @@ ./services/web-apps/flarum.nix ./services/web-apps/fluidd.nix ./services/web-apps/freshrss.nix + ./services/web-apps/froide-govplan.nix ./services/web-apps/galene.nix ./services/web-apps/gancio.nix ./services/web-apps/gerrit.nix diff --git a/nixos/modules/services/web-apps/froide-govplan.nix b/nixos/modules/services/web-apps/froide-govplan.nix new file mode 100644 index 000000000000..e5c7c0b19676 --- /dev/null +++ b/nixos/modules/services/web-apps/froide-govplan.nix @@ -0,0 +1,237 @@ +{ + config, + lib, + pkgs, + ... +}: +let + + cfg = config.services.froide-govplan; + pythonFmt = pkgs.formats.pythonVars { }; + settingsFile = pythonFmt.generate "extra_settings.py" cfg.settings; + + pkg = cfg.package.overridePythonAttrs (old: { + postInstall = + old.postInstall + + '' + ln -s ${settingsFile} $out/${pkg.python.sitePackages}/froide_govplan/project/extra_settings.py + ''; + }); + + froide-govplan = pkgs.writeShellApplication { + name = "froide-govplan"; + runtimeInputs = [ pkgs.coreutils ]; + text = '' + SUDO="exec" + if [[ "$USER" != govplan ]]; then + SUDO="exec /run/wrappers/bin/sudo -u govplan" + fi + $SUDO env ${lib.getExe pkg} "$@" + ''; + }; + + # Service hardening + defaultServiceConfig = { + # Secure the services + ReadWritePaths = [ cfg.dataDir ]; + CacheDirectory = "froide-govplan"; + CapabilityBoundingSet = ""; + # ProtectClock adds DeviceAllow=char-rtc r + DeviceAllow = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectHome = true; + ProtectHostname = true; + ProtectSystem = "strict"; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged @setuid @keyring" + ]; + UMask = "0066"; + }; + +in +{ + options.services.froide-govplan = { + + enable = lib.mkEnableOption "Gouvernment planer web app Govplan"; + + package = lib.mkPackageOption pkgs "froide-govplan" { }; + + hostName = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = "FQDN for the froide-govplan instance."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/froide-govplan"; + description = "Directory to store the Froide-Govplan server data."; + }; + + secretKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to a file containing the secret key. + ''; + }; + + settings = lib.mkOption { + description = '' + Configuration options to set in `extra_settings.py`. + ''; + + default = { }; + + type = lib.types.submodule { + freeformType = pythonFmt.type; + + options = { + ALLOWED_HOSTS = lib.mkOption { + type = with lib.types; listOf str; + default = [ "*" ]; + description = '' + A list of valid fully-qualified domain names (FQDNs) and/or IP + addresses that can be used to reach the Froide-Govplan service. + ''; + }; + }; + }; + }; + + }; + + config = lib.mkIf cfg.enable { + + services.froide-govplan = { + settings = { + STATIC_ROOT = "${cfg.dataDir}/static"; + DEBUG = false; + DATABASES.default = { + ENGINE = "django.contrib.gis.db.backends.postgis"; + NAME = "govplan"; + USER = "govplan"; + HOST = "/run/postgresql"; + }; + }; + }; + + services.postgresql = { + enable = true; + ensureDatabases = [ "govplan" ]; + ensureUsers = [ + { + name = "govplan"; + ensureDBOwnership = true; + } + ]; + extensions = ps: with ps; [ postgis ]; + }; + + services.nginx = { + enable = lib.mkDefault true; + virtualHosts."${cfg.hostName}".locations = { + "/".extraConfig = "proxy_pass http://unix:/run/froide-govplan/froide-govplan.socket;"; + "/static/".alias = "${cfg.dataDir}/static/"; + }; + proxyTimeout = lib.mkDefault "120s"; + }; + + systemd = { + services = { + + postgresql.serviceConfig.ExecStartPost = + let + sqlFile = pkgs.writeText "immich-pgvectors-setup.sql" '' + CREATE EXTENSION IF NOT EXISTS postgis; + ''; + in + [ + '' + ${lib.getExe' config.services.postgresql.package "psql"} -d govplan -f "${sqlFile}" + '' + ]; + + froide-govplan = { + description = "Gouvernment planer Govplan"; + serviceConfig = defaultServiceConfig // { + WorkingDirectory = cfg.dataDir; + StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/froide-govplan") "froide-govplan"; + User = "govplan"; + Group = "govplan"; + }; + after = [ + "postgresql.service" + "network.target" + "systemd-tmpfiles-setup.service" + ]; + wantedBy = [ "multi-user.target" ]; + environment = + { + PYTHONPATH = pkg.pythonPath; + GDAL_LIBRARY_PATH = "${pkgs.gdal}/lib/libgdal.so"; + GEOS_LIBRARY_PATH = "${pkgs.geos}/lib/libgeos_c.so"; + } + // lib.optionalAttrs (cfg.secretKeyFile != null) { + SECRET_KEY_FILE = cfg.secretKeyFile; + }; + preStart = '' + # Auto-migrate on first run or if the package has changed + versionFile="${cfg.dataDir}/src-version" + version=$(cat "$versionFile" 2>/dev/null || echo 0) + + if [[ $version != ${pkg.version} ]]; then + ${lib.getExe pkg} migrate --no-input + ${lib.getExe pkg} collectstatic --no-input --clear + echo ${pkg.version} > "$versionFile" + fi + ''; + script = '' + ${pkg.python.pkgs.uvicorn}/bin/uvicorn --uds /run/froide-govplan/froide-govplan.socket \ + --app-dir ${pkg}/${pkg.python.sitePackages}/froide_govplan \ + project.asgi:application + ''; + }; + }; + + }; + + systemd.tmpfiles.rules = [ "d /run/froide-govplan - govplan govplan - -" ]; + + environment.systemPackages = [ froide-govplan ]; + + users.users.govplan = { + home = "${cfg.dataDir}"; + isSystemUser = true; + group = "govplan"; + }; + users.groups.govplan = { }; + + }; + + meta.maintainers = with lib.maintainers; [ onny ]; + +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 6de87703f908..69faf9fbc9c2 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -450,6 +450,7 @@ in { freetube = discoverTests (import ./freetube.nix); freshrss = handleTest ./freshrss {}; frigate = runTest ./frigate.nix; + froide-govplan = runTest ./web-apps/froide-govplan.nix; frp = handleTest ./frp.nix {}; frr = handleTest ./frr.nix {}; fsck = handleTest ./fsck.nix {}; diff --git a/nixos/tests/web-apps/froide-govplan.nix b/nixos/tests/web-apps/froide-govplan.nix new file mode 100644 index 000000000000..05f42a52863e --- /dev/null +++ b/nixos/tests/web-apps/froide-govplan.nix @@ -0,0 +1,44 @@ +{ + lib, + pkgs, + ... +}: + +{ + name = "froide-govplan"; + meta.maintainers = with lib.maintainers; [ onny ]; + + nodes = { + machine = { + virtualisation.memorySize = 2048; + services.froide-govplan.enable = true; + }; + }; + + testScript = + let + changePassword = pkgs.writeText "change-password.py" '' + from django.contrib.auth.models import User + u = User.objects.get(username='govplan') + u.set_password('govplan') + u.save() + ''; + in + '' + start_all() + machine.wait_for_unit("froide-govplan.service") + machine.wait_for_file("/run/froide-govplan/froide-govplan.socket") + + with subtest("Home screen loads"): + machine.succeed( + "curl -sSfL http://[::1]:80 | grep 'django CMS'" + ) + + with subtest("Superuser can be created"): + machine.succeed( + "froide-govplan createsuperuser --noinput --username govplan --email govplan@example.com" + ) + # Django doesn't have a "clean" way of inputting the password from the command line + machine.succeed("cat '${changePassword}' | froide-govplan shell") + ''; +} diff --git a/pkgs/by-name/fr/froide-govplan/load_extra_settings.patch b/pkgs/by-name/fr/froide-govplan/load_extra_settings.patch new file mode 100644 index 000000000000..5f1defcdcd1a --- /dev/null +++ b/pkgs/by-name/fr/froide-govplan/load_extra_settings.patch @@ -0,0 +1,15 @@ +diff --git a/project/settings.py b/project/settings.py +index dd282ac..64ff265 100644 +--- a/project/settings.py ++++ b/project/settings.py +@@ -202,3 +202,10 @@ CMS_CONFIRM_VERSION4 = True + + GOVPLAN_NAME = "GovPlan" + GOVPLAN_ENABLE_FOIREQUEST = False ++ ++EXTRA_SETTINGS_PATH = os.path.join(PROJECT_DIR, 'extra_settings.py') ++ ++if os.path.exists(EXTRA_SETTINGS_PATH): ++ with open(EXTRA_SETTINGS_PATH) as f: ++ code = compile(f.read(), EXTRA_SETTINGS_PATH, 'exec') ++ exec(code) diff --git a/pkgs/by-name/fr/froide-govplan/package.nix b/pkgs/by-name/fr/froide-govplan/package.nix new file mode 100644 index 000000000000..cd2f605c7ca9 --- /dev/null +++ b/pkgs/by-name/fr/froide-govplan/package.nix @@ -0,0 +1,127 @@ +{ + lib, + python3Packages, + fetchFromGitHub, + makeBinaryWrapper, + froide, + gdal, + geos, + nixosTests, + fetchpatch, + froide-govplan, + gettext, +}: +let + python = python3Packages.python.override { + packageOverrides = self: super: { + django = super.django.override { withGdal = true; }; + }; + }; +in +python.pkgs.buildPythonApplication rec { + pname = "froide-govplan"; + version = "0-unstable-2025-01-27"; + pyproject = true; + + src = fetchFromGitHub { + owner = "okfde"; + repo = "froide-govplan"; + # No tagged release yet + # https://github.com/okfde/froide-govplan/issues/15 + rev = "f1763807614b8c54a9214359a2a1e442ca58cb6d"; + hash = "sha256-Y7/qjhu3y9E55ZDmDf5HUk9JVcUTvi9KnqavqwNixTM="; + }; + + patches = [ + # Add missing oauth2_provider app + # https://github.com/okfde/froide-govplan/pull/17 + (fetchpatch { + url = "https://github.com/okfde/froide-govplan/commit/bc388b693ebc7656fc7917511048a47b68e119fe.patch"; + hash = "sha256-StIyN3gFKd/fld14s03fsV6fmWinSRWINUyRRBzqVco="; + name = "add_oauth2_provider_app.patch"; + }) + # Enable automatic image cropping for filer + # https://github.com/okfde/froide-govplan/pull/19 + (fetchpatch { + url = "https://github.com/okfde/froide-govplan/commit/d5db02f3bbc501929a901e964147a0ed2f974f41.patch"; + hash = "sha256-jf6e84fd2kQIPER2HGmZNkbtl067311xdNJRU+iFVXM="; + name = "filer_enable_automatic_cropping.patch"; + }) + # Add support for reading SECRET_KEY_FILE + # https://github.com/okfde/froide-govplan/pull/21 + (fetchpatch { + url = "https://github.com/okfde/froide-govplan/commit/39dc381ed6c4afcbbaea616f0e8f44d5eeb96ec2.patch"; + hash = "sha256-PxBtB6VupK4QJiWKKlTmub+7tfi6nAHWe29lxpNgoVk="; + name = "add_secret_key_file_support.patch"; + }) + + # Patch settings.py to source additional settings from the NixOS module + ./load_extra_settings.patch + ]; + + build-system = [ python.pkgs.setuptools ]; + + nativeBuildInputs = [ + gettext + makeBinaryWrapper + ]; + + build-inputs = [ gdal ]; + + dependencies = with python.pkgs; [ + bleach + django-admin-sortable2 + django-cms + django-filer + django-mfa3 + django-mptt + django-oauth-toolkit + django-sekizai + django-tinymce + django-treebeard + djangocms-alias + # Downgrade to last working version + (toPythonModule ( + froide.overridePythonAttrs (prev: { + nativeBuildInputs = [ makeBinaryWrapper ]; + postBuild = ""; + doCheck = false; + pnpmDeps = null; + src = prev.src.override { + rev = "a78a4054f9f37b0a5109a6d8cfbbda742f86a8ca"; + hash = "sha256-gtOssbsVf3nG+pmLPgvh4685vHh2x+jlXiTjU+JhQa8="; + }; + }) + )) + psycopg + ]; + + preBuild = "${python.interpreter} -m django compilemessages"; + + postInstall = '' + cp manage.py $out/${python.sitePackages}/froide_govplan/ + cp -r project $out/${python.sitePackages}/froide_govplan/ + cp -r froide_govplan/locale $out/${python.sitePackages}/froide_govplan/ + makeWrapper $out/${python.sitePackages}/froide_govplan/manage.py $out/bin/froide-govplan \ + --prefix PYTHONPATH : "$PYTHONPATH" \ + --set GDAL_LIBRARY_PATH "${gdal}/lib/libgdal.so" \ + --set GEOS_LIBRARY_PATH "${geos}/lib/libgeos_c.so" + ''; + + passthru = { + tests = { + inherit (nixosTests) froide-govplan; + }; + python = python; + pythonPath = "${python.pkgs.makePythonPath dependencies}:${froide-govplan}/${python.sitePackages}"; + }; + + meta = { + description = "Government planner and basis of FragDenStaat.de Koalitionstracker"; + homepage = "https://github.com/okfde/froide-govplan"; + license = lib.licenses.mit; + maintainers = [ lib.maintainers.onny ]; + mainProgram = "froide-govplan"; + }; + +}