From d2cf4b948f42b350fce8317e2148bef482c1a69e Mon Sep 17 00:00:00 2001 From: soyouzpanda Date: Fri, 25 Apr 2025 18:08:29 +0200 Subject: [PATCH 1/5] lasuite-docs: init at 3.3.0 --- .../lasuite-docs/environment_variables.patch | 109 +++++++++++++++++ pkgs/by-name/la/lasuite-docs/package.nix | 114 ++++++++++++++++++ .../la/lasuite-docs/secure_settings.patch | 36 ++++++ 3 files changed, 259 insertions(+) create mode 100644 pkgs/by-name/la/lasuite-docs/environment_variables.patch create mode 100644 pkgs/by-name/la/lasuite-docs/package.nix create mode 100644 pkgs/by-name/la/lasuite-docs/secure_settings.patch diff --git a/pkgs/by-name/la/lasuite-docs/environment_variables.patch b/pkgs/by-name/la/lasuite-docs/environment_variables.patch new file mode 100644 index 000000000000..618a809a5a7b --- /dev/null +++ b/pkgs/by-name/la/lasuite-docs/environment_variables.patch @@ -0,0 +1,109 @@ +From dd7d54e64bbdb853ff60162908f142cb34034cdd Mon Sep 17 00:00:00 2001 +From: soyouzpanda +Date: Mon, 28 Apr 2025 18:18:39 +0200 +Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8(backend)=20support=20`=5FFILE`=20?= + =?UTF-8?q?environment=20variables=20for=20secrets?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Allow configuration variables that handles secrets, like +`DJANGO_SECRET_KEY` to be able to read from a file which is given +through an environment file. + +For example, if `DJANGO_SECRET_KEY_FILE` is set to +`/var/lib/docs/django-secret-key`, the value of `DJANGO_SECRET_KEY` will +be the content of `/var/lib/docs/django-secret-key`. +--- + src/backend/impress/settings.py | 19 ++++++++++--------- + 1 files changed, 10 insertions(+), 9 deletions(-) + +diff --git a/impress/settings.py b/impress/settings.py +index 571d7052..23c75a98 100755 +--- a/impress/settings.py ++++ b/impress/settings.py +@@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _ + + import sentry_sdk + from configurations import Configuration, values ++from lasuite.configuration.values import SecretFileValue + from sentry_sdk.integrations.django import DjangoIntegration + from sentry_sdk.integrations.logging import ignore_logger + +@@ -65,7 +66,7 @@ class Base(Configuration): + + # Security + ALLOWED_HOSTS = values.ListValue([]) +- SECRET_KEY = values.Value(None) ++ SECRET_KEY = SecretFileValue(None) + SERVER_TO_SERVER_API_TOKENS = values.ListValue([]) + + # Application definition +@@ -84,7 +85,7 @@ class Base(Configuration): + "impress", environ_name="DB_NAME", environ_prefix=None + ), + "USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None), +- "PASSWORD": values.Value( ++ "PASSWORD": SecretFileValue( + "pass", environ_name="DB_PASSWORD", environ_prefix=None + ), + "HOST": values.Value( +@@ -122,10 +123,10 @@ class Base(Configuration): + AWS_S3_ENDPOINT_URL = values.Value( + environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None + ) +- AWS_S3_ACCESS_KEY_ID = values.Value( ++ AWS_S3_ACCESS_KEY_ID = SecretFileValue( + environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None + ) +- AWS_S3_SECRET_ACCESS_KEY = values.Value( ++ AWS_S3_SECRET_ACCESS_KEY = SecretFileValue( + environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None + ) + AWS_S3_REGION_NAME = values.Value( +@@ -384,7 +385,7 @@ class Base(Configuration): + EMAIL_BRAND_NAME = values.Value(None) + EMAIL_HOST = values.Value(None) + EMAIL_HOST_USER = values.Value(None) +- EMAIL_HOST_PASSWORD = values.Value(None) ++ EMAIL_HOST_PASSWORD = SecretFileValue(None) + EMAIL_LOGO_IMG = values.Value(None) + EMAIL_PORT = values.PositiveIntegerValue(None) + EMAIL_USE_TLS = values.BooleanValue(False) +@@ -407,7 +408,7 @@ class Base(Configuration): + COLLABORATION_API_URL = values.Value( + None, environ_name="COLLABORATION_API_URL", environ_prefix=None + ) +- COLLABORATION_SERVER_SECRET = values.Value( ++ COLLABORATION_SERVER_SECRET = SecretFileValue( + None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None + ) + COLLABORATION_WS_URL = values.Value( +@@ -477,7 +478,7 @@ class Base(Configuration): + OIDC_RP_CLIENT_ID = values.Value( + "impress", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None + ) +- OIDC_RP_CLIENT_SECRET = values.Value( ++ OIDC_RP_CLIENT_SECRET = SecretFileValue( + None, + environ_name="OIDC_RP_CLIENT_SECRET", + environ_prefix=None, +@@ -592,7 +593,7 @@ class Base(Configuration): + AI_FEATURE_ENABLED = values.BooleanValue( + default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None + ) +- AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None) ++ AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None) + AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) + AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) + AI_ALLOW_REACH_FROM = values.Value( +@@ -613,7 +614,7 @@ class Base(Configuration): + } + + # Y provider microservice +- Y_PROVIDER_API_KEY = values.Value( ++ Y_PROVIDER_API_KEY = SecretFileValue( + environ_name="Y_PROVIDER_API_KEY", + environ_prefix=None, + ) + diff --git a/pkgs/by-name/la/lasuite-docs/package.nix b/pkgs/by-name/la/lasuite-docs/package.nix new file mode 100644 index 000000000000..03300b635979 --- /dev/null +++ b/pkgs/by-name/la/lasuite-docs/package.nix @@ -0,0 +1,114 @@ +{ + lib, + python3, + fetchFromGitHub, +}: +let + python = python3.override { + self = python3; + packageOverrides = self: super: { + django = super.django_5_2; + }; + }; +in + +python.pkgs.buildPythonApplication rec { + pname = "lasuite-docs"; + version = "3.3.0"; + pyproject = true; + + src = fetchFromGitHub { + owner = "suitenumerique"; + repo = "docs"; + tag = "v${version}"; + hash = "sha256-SLTNkK578YhsDtVBS4vH0E/rXx+rXZIyXMhqwr95QEA="; + }; + + sourceRoot = "source/src/backend"; + + patches = [ + # Support for $ENVIRONMENT_VARIABLE_FILE to be able to pass secret files + # See: https://github.com/suitenumerique/docs/pull/912 + ./environment_variables.patch + # Support configuration throught environment variables for SECURE_* + ./secure_settings.patch + ]; + + build-system = with python.pkgs; [ setuptools ]; + + dependencies = with python.pkgs; [ + beautifulsoup4 + boto3 + celery + django + django-configurations + django-cors-headers + django-countries + django-extensions + django-filter + django-lasuite + django-parler + django-redis + django-storages + django-timezone-field + django-treebeard + djangorestframework + drf-spectacular + drf-spectacular-sidecar + dockerflow + easy-thumbnails + factory-boy + gunicorn + jsonschema + lxml + markdown + mozilla-django-oidc + nested-multipart-parser + openai + psycopg + pycrdt + pyjwt + pyopenssl + python-magic + redis + requests + sentry-sdk + whitenoise + ]; + + pythonRelaxDeps = true; + + postBuild = '' + export DATA_DIR=$(pwd)/data + ${python.pythonOnBuildForHost.interpreter} manage.py collectstatic --no-input --clear + ''; + + postInstall = + let + pythonPath = python.pkgs.makePythonPath dependencies; + in + '' + mkdir -p $out/{bin,share} + + cp ./manage.py $out/bin/.manage.py + cp -r data/static $out/share + chmod +x $out/bin/.manage.py + + makeWrapper $out/bin/.manage.py $out/bin/docs \ + --prefix PYTHONPATH : "${pythonPath}" + makeWrapper ${lib.getExe python.pkgs.celery} $out/bin/celery \ + --prefix PYTHONPATH : "${pythonPath}:$out/${python.sitePackages}" + makeWrapper ${lib.getExe python.pkgs.gunicorn} $out/bin/gunicorn \ + --prefix PYTHONPATH : "${pythonPath}:$out/${python.sitePackages}" + ''; + + meta = { + description = "A collaborative note taking, wiki and documentation platform that scales. Built with Django and React. Opensource alternative to Notion or Outline"; + homepage = "https://github.com/suitenumerique/docs"; + changelog = "https://github.com/suitenumerique/docs/blob/${src.tag}/CHANGELOG.md"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ soyouzpanda ]; + mainProgram = "docs"; + platforms = lib.platforms.all; + }; +} diff --git a/pkgs/by-name/la/lasuite-docs/secure_settings.patch b/pkgs/by-name/la/lasuite-docs/secure_settings.patch new file mode 100644 index 000000000000..f679c96b0fc5 --- /dev/null +++ b/pkgs/by-name/la/lasuite-docs/secure_settings.patch @@ -0,0 +1,36 @@ +diff --git a/impress/settings.py b/impress/settings.py +index 9d825095..518aca7f 100755 +--- a/impress/settings.py ++++ b/impress/settings.py +@@ -822,19 +822,24 @@ class Production(Base): + # + # In other cases, you should comment the following line to avoid security issues. + # SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +- SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +- SECURE_HSTS_SECONDS = 60 +- SECURE_HSTS_PRELOAD = True +- SECURE_HSTS_INCLUDE_SUBDOMAINS = True +- SECURE_SSL_REDIRECT = True ++ SECURE_PROXY_SSL_HEADER = values.TupleValue(("HTTP_X_FORWARDED_PROTO", "https"), ++ environ_name="SECURE_PROXY_SSL_HEADER") ++ SECURE_HSTS_SECONDS = values.IntegerValue( ++ 60, environ_name="SECURE_HSTS_SECONDS") ++ SECURE_HSTS_PRELOAD = values.BooleanValue( ++ True, environ_name="SECURE_HSTS_PRELOAD") ++ SECURE_HSTS_INCLUDE_SUBDOMAINS = values.BooleanValue( ++ True, environ_name="SECURE_HSTS_INCLUDE_SUBDOMAINS") ++ SECURE_SSL_REDIRECT = values.BooleanValue( ++ True, environ_name="SECURE_SSL_REDIRECT") + SECURE_REDIRECT_EXEMPT = [ + "^__lbheartbeat__", + "^__heartbeat__", + ] + + # Modern browsers require to have the `secure` attribute on cookies with `Samesite=none` +- CSRF_COOKIE_SECURE = True +- SESSION_COOKIE_SECURE = True ++ CSRF_COOKIE_SECURE = values.BooleanValue(True, environ_name="CSRF_COOKIE_SECURE") ++ SESSION_COOKIE_SECURE = values.BooleanValue(True, environ_name="SESSION_COOKIE_SECURE") + + # Privacy + SECURE_REFERRER_POLICY = "same-origin" From d2c305e28877c28c64287aab6bd344fad95aa931 Mon Sep 17 00:00:00 2001 From: soyouzpanda Date: Fri, 25 Apr 2025 18:10:20 +0200 Subject: [PATCH 2/5] lasuite-docs-frontend: init at 3.3.0 --- .../la/lasuite-docs-frontend/package.nix | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 pkgs/by-name/la/lasuite-docs-frontend/package.nix diff --git a/pkgs/by-name/la/lasuite-docs-frontend/package.nix b/pkgs/by-name/la/lasuite-docs-frontend/package.nix new file mode 100644 index 000000000000..af557cd9e2f7 --- /dev/null +++ b/pkgs/by-name/la/lasuite-docs-frontend/package.nix @@ -0,0 +1,86 @@ +{ + lib, + fetchFromGitHub, + stdenv, + fetchYarnDeps, + nodejs, + fixup-yarn-lock, + yarn, +}: + +stdenv.mkDerivation rec { + pname = "lasuite-docs-frontend"; + version = "3.3.0"; + + src = fetchFromGitHub { + owner = "suitenumerique"; + repo = "docs"; + tag = "v${version}"; + hash = "sha256-SLTNkK578YhsDtVBS4vH0E/rXx+rXZIyXMhqwr95QEA="; + }; + + sourceRoot = "source/src/frontend"; + + offlineCache = fetchYarnDeps { + yarnLock = "${src}/src/frontend/yarn.lock"; + hash = "sha256-ei4xj+W2j5O675cpMAG4yCB3cPLeYwMhqKTacPWFjoo="; + }; + + nativeBuildInputs = [ + nodejs + fixup-yarn-lock + yarn + ]; + + configurePhase = '' + runHook preConfigure + + export HOME=$(mktemp -d) + yarn config --offline set yarn-offline-mirror "$offlineCache" + fixup-yarn-lock yarn.lock + + # Fixup what fixup-yarn-lock does not fix. Result in error if not fixed. + substituteInPlace yarn.lock \ + --replace-fail '"@fastify/otel@https://codeload.github.com/getsentry/fastify-otel/tar.gz/ae3088d65e286bdc94ac5d722573537d6a6671bb"' '"@fastify/otel@^0.8.0"' + + yarn install \ + --frozen-lockfile \ + --force \ + --production=false \ + --ignore-engines \ + --ignore-platform \ + --ignore-scripts \ + --no-progress \ + --non-interactive \ + --offline + + patchShebangs node_modules + + runHook postConfigure + ''; + + buildPhase = '' + runHook preBuild + + yarn --offline app:build + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + cp -r apps/impress/out/ $out + + runHook postInstall + ''; + + meta = { + description = "A collaborative note taking, wiki and documentation platform that scales. Built with Django and React. Opensource alternative to Notion or Outline"; + homepage = "https://github.com/suitenumerique/docs"; + changelog = "https://github.com/suitenumerique/docs/blob/${src.tag}/CHANGELOG.md"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ soyouzpanda ]; + platforms = lib.platforms.all; + }; +} From 5d737e24b07f81ce92190649d2f68f8a70d47ed7 Mon Sep 17 00:00:00 2001 From: soyouzpanda Date: Fri, 25 Apr 2025 18:11:05 +0200 Subject: [PATCH 3/5] lasuite-docs-collaboration-server: init at 3.3.0 --- .../environment_variables.patch | 43 ++++++++ .../package.nix | 100 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 pkgs/by-name/la/lasuite-docs-collaboration-server/environment_variables.patch create mode 100644 pkgs/by-name/la/lasuite-docs-collaboration-server/package.nix diff --git a/pkgs/by-name/la/lasuite-docs-collaboration-server/environment_variables.patch b/pkgs/by-name/la/lasuite-docs-collaboration-server/environment_variables.patch new file mode 100644 index 000000000000..e84fcb2ccc34 --- /dev/null +++ b/pkgs/by-name/la/lasuite-docs-collaboration-server/environment_variables.patch @@ -0,0 +1,43 @@ +From ab1de49ad9c23e73cddc4dd82a9fede4f56d28d0 Mon Sep 17 00:00:00 2001 +From: soyouzpanda +Date: Tue, 29 Apr 2025 17:09:51 +0200 +Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8(frontend)=20support=20`=5FFILE`?= + =?UTF-8?q?=20envuronment=20variables=20for=20secrets?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Allow configuration variables that handles secrets to be read from a +file given in an environment variable. +--- + src/frontend/servers/y-provider/src/env.ts | 13 +++++++++---- + 1 files changed, 9 insertions(+), 4 deletions(-) + +diff --git a/servers/y-provider/src/env.ts b/servers/y-provider/src/env.ts +index fe281930..e0e02cf5 100644 +--- a/servers/y-provider/src/env.ts ++++ b/servers/y-provider/src/env.ts +@@ -1,11 +1,16 @@ ++import { readFileSync } from 'fs'; ++ + export const COLLABORATION_LOGGING = + process.env.COLLABORATION_LOGGING || 'false'; + export const COLLABORATION_SERVER_ORIGIN = + process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000'; +-export const COLLABORATION_SERVER_SECRET = +- process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key'; +-export const Y_PROVIDER_API_KEY = +- process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key'; ++export const COLLABORATION_SERVER_SECRET = process.env ++ .COLLABORATION_SERVER_SECRET_FILE ++ ? readFileSync(process.env.COLLABORATION_SERVER_SECRET_FILE, 'utf-8') ++ : process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key'; ++export const Y_PROVIDER_API_KEY = process.env.Y_PROVIDER_API_KEY_FILE ++ ? readFileSync(process.env.Y_PROVIDER_API_KEY_FILE, 'utf-8') ++ : process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key'; + export const PORT = Number(process.env.PORT || 4444); + export const SENTRY_DSN = process.env.SENTRY_DSN || ''; + export const COLLABORATION_BACKEND_BASE_URL = +-- +2.47.2 + diff --git a/pkgs/by-name/la/lasuite-docs-collaboration-server/package.nix b/pkgs/by-name/la/lasuite-docs-collaboration-server/package.nix new file mode 100644 index 000000000000..1173bc7e8247 --- /dev/null +++ b/pkgs/by-name/la/lasuite-docs-collaboration-server/package.nix @@ -0,0 +1,100 @@ +{ + lib, + fetchFromGitHub, + stdenv, + fetchYarnDeps, + nodejs, + fixup-yarn-lock, + yarn, + makeWrapper, +}: + +stdenv.mkDerivation rec { + pname = "lasuite-docs-collaboration-server"; + version = "3.3.0"; + + src = fetchFromGitHub { + owner = "suitenumerique"; + repo = "docs"; + tag = "v${version}"; + hash = "sha256-SLTNkK578YhsDtVBS4vH0E/rXx+rXZIyXMhqwr95QEA="; + }; + + sourceRoot = "source/src/frontend"; + + patches = [ + # Support for $ENVIRONMENT_VARIABLE_FILE to be able to pass secret file + # See: https://github.com/suitenumerique/docs/pull/912 + ./environment_variables.patch + ]; + + offlineCache = fetchYarnDeps { + yarnLock = "${src}/src/frontend/yarn.lock"; + hash = "sha256-ei4xj+W2j5O675cpMAG4yCB3cPLeYwMhqKTacPWFjoo="; + }; + + nativeBuildInputs = [ + nodejs + fixup-yarn-lock + yarn + makeWrapper + ]; + + configurePhase = '' + runHook preConfigure + + export HOME=$(mktemp -d) + yarn config --offline set yarn-offline-mirror "$offlineCache" + fixup-yarn-lock yarn.lock + + # Fixup what fixup-yarn-lock does not fix. Result in error if not fixed. + substituteInPlace yarn.lock \ + --replace-fail '"@fastify/otel@https://codeload.github.com/getsentry/fastify-otel/tar.gz/ae3088d65e286bdc94ac5d722573537d6a6671bb"' '"@fastify/otel@^0.8.0"' + + yarn install \ + --frozen-lockfile \ + --force \ + --production=false \ + --ignore-engines \ + --ignore-platform \ + --ignore-scripts \ + --no-progress \ + --non-interactive \ + --offline + + patchShebangs node_modules + + runHook postConfigure + ''; + + buildPhase = '' + runHook preBuild + + yarn --offline COLLABORATION_SERVER run build + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/{lib,bin} + cp -r {apps,node_modules,packages,servers} $out/lib + + makeWrapper ${lib.getExe nodejs} "$out/bin/docs-collaboration-server" \ + --add-flags "$out/lib/servers/y-provider/dist/start-server.js" \ + --set NODE_PATH "$out/lib/node_modules" + + runHook postInstall + ''; + + meta = { + description = "A collaborative note taking, wiki and documentation platform that scales. Built with Django and React. Opensource alternative to Notion or Outline"; + homepage = "https://github.com/suitenumerique/docs"; + changelog = "https://github.com/suitenumerique/docs/blob/${src.tag}/CHANGELOG.md"; + mainProgram = "docs-collaboration-server"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ soyouzpanda ]; + platforms = lib.platforms.all; + }; +} From f3120f071074397fe4b45414c2733f756887d027 Mon Sep 17 00:00:00 2001 From: soyouzpanda Date: Fri, 25 Apr 2025 18:15:21 +0200 Subject: [PATCH 4/5] nixos/lasuite-docs: init --- .../manual/release-notes/rl-2511.section.md | 2 + nixos/modules/module-list.nix | 1 + .../services/web-apps/lasuite-docs.nix | 514 ++++++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 nixos/modules/services/web-apps/lasuite-docs.nix diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index 65bac7327ed9..d86c9b80de3a 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -12,6 +12,8 @@ - [gtklock](https://github.com/jovanlanik/gtklock), a GTK-based lockscreen for Wayland. Available as [programs.gtklock](#opt-programs.gtklock.enable). +- [SuiteNumérique Docs](https://github.com/suitenumerique/docs), a collaborative note taking, wiki and documentation web platform and alternative to Notion or Outline. Available as [services.lasuite-docs](#opt-services.lasuite-docs.enable). + ## Backward Incompatibilities {#sec-release-25.11-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index ff65ce2125f1..eefc4480e586 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1579,6 +1579,7 @@ ./services/web-apps/kimai.nix ./services/web-apps/komga.nix ./services/web-apps/lanraragi.nix + ./services/web-apps/lasuite-docs.nix ./services/web-apps/lemmy.nix ./services/web-apps/limesurvey.nix ./services/web-apps/mainsail.nix diff --git a/nixos/modules/services/web-apps/lasuite-docs.nix b/nixos/modules/services/web-apps/lasuite-docs.nix new file mode 100644 index 000000000000..eb1c44c2e87b --- /dev/null +++ b/nixos/modules/services/web-apps/lasuite-docs.nix @@ -0,0 +1,514 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: +let + inherit (lib) + getExe + mapAttrs + mkEnableOption + mkIf + mkPackageOption + mkOption + types + optional + optionalString + ; + + cfg = config.services.lasuite-docs; + + pythonEnvironment = mapAttrs ( + _: value: + if value == null then + "None" + else if value == true then + "True" + else if value == false then + "False" + else + toString value + ) cfg.settings; + + commonServiceConfig = { + RuntimeDirectory = "lasuite-docs"; + StateDirectory = "lasuite-docs"; + WorkingDirectory = "/var/lib/lasuite-docs"; + + User = "lasuite-docs"; + DynamicUser = true; + SupplementaryGroups = mkIf cfg.redis.createLocally [ + config.services.redis.servers.lasuite-docs.group + ]; + # hardening + AmbientCapabilities = ""; + CapabilityBoundingSet = [ "" ]; + DevicePolicy = "closed"; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + UMask = "0077"; + }; +in +{ + options.services.lasuite-docs = { + enable = mkEnableOption "SuiteNumérique Docs"; + + backendPackage = mkPackageOption pkgs "lasuite-docs" { }; + + frontendPackage = mkPackageOption pkgs "lasuite-docs-frontend" { }; + + bind = mkOption { + type = types.str; + default = "unix:/run/lasuite-docs/gunicorn.sock"; + example = "127.0.0.1:8000"; + description = '' + The path, host/port or file descriptior to bind the gunicorn socket to. + + See for possible options. + ''; + }; + + enableNginx = mkEnableOption "enable and configure Nginx for reverse proxying" // { + default = true; + }; + + secretKeyPath = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to the Django secret key. + + The key can be generated using: + ``` + python3 -c 'import secrets; print(secrets.token_hex())' + ``` + + If not set, the secret key will be automatically generated. + ''; + }; + + s3Url = mkOption { + type = types.str; + description = '' + URL of the S3 bucket. + ''; + }; + + postgresql = { + createLocally = mkOption { + type = types.bool; + default = false; + description = '' + Configure local PostgreSQL database server for docs. + ''; + }; + }; + + redis = { + createLocally = mkOption { + type = types.bool; + default = false; + description = '' + Configure local Redis cache server for docs. + ''; + }; + }; + + collaborationServer = { + package = mkPackageOption pkgs "lasuite-docs-collaboration-server" { }; + + port = mkOption { + type = types.port; + default = 4444; + description = '' + Port used by the collaboration server to listen. + ''; + }; + + settings = mkOption { + type = types.submodule { + freeformType = types.attrsOf ( + types.oneOf [ + types.str + types.bool + ] + ); + + options = { + PORT = mkOption { + type = types.str; + default = toString cfg.collaborationServer.port; + readOnly = true; + description = "Port used by collaboration server to listen to"; + }; + + COLLABORATION_BACKEND_BASE_URL = mkOption { + type = types.str; + default = "https://${cfg.domain}"; + defaultText = lib.literalExpression "https://\${cfg.domain}"; + description = "URL to the backend server base"; + }; + }; + }; + default = { }; + example = '' + { + COLLABORATION_LOGGING = true; + } + ''; + description = '' + Configuration options of collaboration server. + + See https://github.com/suitenumerique/docs/blob/v${cfg.collaborationServer.package.version}/docs/env.md + ''; + }; + }; + + gunicorn = { + extraArgs = mkOption { + type = types.listOf types.str; + default = [ + "--name=impress" + "--workers=3" + ]; + description = '' + Extra arguments to pass to the gunicorn process. + ''; + }; + }; + + celery = { + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Extra arguments to pass to the celery process. + ''; + }; + }; + + domain = mkOption { + type = types.str; + description = '' + Domain name of the docs instance. + ''; + }; + + settings = mkOption { + type = types.submodule { + freeformType = types.attrsOf ( + types.nullOr ( + types.oneOf [ + types.str + types.bool + types.path + types.int + ] + ) + ); + + options = { + DJANGO_CONFIGURATION = mkOption { + type = types.str; + internal = true; + default = "Production"; + description = "The configuration that Django will use"; + }; + + DJANGO_SETTINGS_MODULE = mkOption { + type = types.str; + internal = true; + default = "impress.settings"; + description = "The configuration module that Django will use"; + }; + + DJANGO_SECRET_KEY_FILE = mkOption { + type = types.path; + default = + if cfg.secretKeyPath == null then "/var/lib/lasuite-docs/django_secret_key" else cfg.secretKeyPath; + description = "The path to the file containing Django's secret key"; + }; + + DATA_DIR = mkOption { + type = types.path; + default = "/var/lib/lasuite-docs"; + description = "Path to the data directory"; + }; + + DJANGO_ALLOWED_HOSTS = mkOption { + type = types.str; + default = if cfg.enableNginx then "localhost,127.0.0.1,${cfg.domain}" else ""; + defaultText = lib.literalExpression '' + if cfg.enableNginx then "localhost,127.0.0.1,$${cfg.domain}" else "" + ''; + description = "Comma-separated list of hosts that are able to connect to the server"; + }; + + DB_NAME = mkOption { + type = types.str; + default = "lasuite-docs"; + description = "Name of the database"; + }; + + DB_USER = mkOption { + type = types.str; + default = "lasuite-docs"; + description = "User of the database"; + }; + + DB_HOST = mkOption { + type = types.nullOr types.str; + default = if cfg.postgresql.createLocally then "/run/postgresql" else null; + description = "Host of the database"; + }; + + REDIS_URL = mkOption { + type = types.nullOr types.str; + default = + if cfg.redis.createLocally then + "unix://${config.services.redis.servers.lasuite-docs.unixSocket}?db=0" + else + null; + description = "URL of the redis backend"; + }; + + CELERY_BROKER_URL = mkOption { + type = types.nullOr types.str; + default = + if cfg.redis.createLocally then + "redis+socket://${config.services.redis.servers.lasuite-docs.unixSocket}?db=1" + else + null; + description = "URL of the redis backend for celery"; + }; + }; + }; + default = { }; + example = '' + { + DJANGO_ALLOWED_HOSTS = "*"; + } + ''; + description = '' + Configuration options of docs. + + See https://github.com/suitenumerique/docs/blob/v${cfg.backendPackage.version}/docs/env.md + + `REDIS_URL` and `CELERY_BROKER_URL` are set if `services.lasuite-docs.redis.createLocally` is true. + `DB_HOST` is set if `services.lasuite-docs.postgresql.createLocally` is true. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to environment file. + + This can be useful to pass secrets to docs via tools like `agenix` or `sops`. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.lasuite-docs = { + description = "Docs from SuiteNumérique"; + after = + [ "network.target" ] + ++ (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wants = + (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wantedBy = [ "multi-user.target" ]; + + preStart = '' + ln -sfT ${cfg.backendPackage}/share/static /var/lib/lasuite-docs/static + + if [ ! -f .version ]; then + touch .version + fi + + if [ "${cfg.backendPackage.version}" != "$(cat .version)" ]; then + ${getExe cfg.backendPackage} migrate + echo -n "${cfg.backendPackage.version}" > .version + fi + ${optionalString (cfg.secretKeyPath == null) '' + if [[ ! -f /var/lib/lasuite-docs/django_secret_key ]]; then + ( + umask 0377 + tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge /var/lib/lasuite-docs/django_secret_key + ) + fi + ''} + ''; + + environment = pythonEnvironment; + + serviceConfig = { + ExecStart = utils.escapeSystemdExecArgs ( + [ + (lib.getExe' cfg.backendPackage "gunicorn") + "--bind=${cfg.bind}" + ] + ++ cfg.gunicorn.extraArgs + ++ [ "impress.wsgi:application" ] + ); + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; + MemoryDenyWriteExecute = true; + } // commonServiceConfig; + }; + + systemd.services.lasuite-docs-celery = { + description = "Docs Celery broker from SuiteNumérique"; + after = + [ "network.target" ] + ++ (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wants = + (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wantedBy = [ "multi-user.target" ]; + + environment = pythonEnvironment; + + serviceConfig = { + ExecStart = utils.escapeSystemdExecArgs ( + [ + (lib.getExe' cfg.backendPackage "celery") + ] + ++ cfg.celery.extraArgs + ++ [ + "--app=impress.celery_app" + "worker" + ] + ); + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; + MemoryDenyWriteExecute = true; + } // commonServiceConfig; + }; + + systemd.services.lasuite-docs-collaboration-server = { + description = "Docs Collaboration Server from SuiteNumérique"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = cfg.collaborationServer.settings; + + serviceConfig = { + ExecStart = getExe cfg.collaborationServer.package; + } // commonServiceConfig; + }; + + services.postgresql = mkIf cfg.postgresql.createLocally { + enable = true; + ensureDatabases = [ "lasuite-docs" ]; + ensureUsers = [ + { + name = "lasuite-docs"; + ensureDBOwnership = true; + } + ]; + }; + + services.redis.servers.lasuite-docs = mkIf cfg.redis.createLocally { enable = true; }; + + services.nginx = mkIf cfg.enableNginx { + enable = true; + + virtualHosts.${cfg.domain} = { + extraConfig = '' + error_page 401 /401; + error_page 403 /403; + error_page 404 /404; + ''; + + root = cfg.frontendPackage; + + locations."~ '^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$'" = { + tryFiles = "$uri /docs/[id]/index.html"; + }; + + locations."/api" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + }; + + locations."/admin" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + }; + + locations."/collaboration/ws/" = { + proxyPass = "http://localhost:${toString cfg.collaborationServer.port}"; + recommendedProxySettings = true; + proxyWebsockets = true; + }; + + locations."/collaboration/api/" = { + proxyPass = "http://localhost:${toString cfg.collaborationServer.port}"; + recommendedProxySettings = true; + }; + + locations."/media-auth" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + extraConfig = '' + rewrite $/(.*)^ /api/v1.0/documents/$1 break; + proxy_set_header X-Original-URL $request_uri; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + ''; + }; + + locations."/media/" = { + proxyPass = cfg.s3Url; + recommendedProxySettings = true; + extraConfig = '' + auth_request /media-auth; + auth_request_set $authHeader $upstream_http_authorization; + auth_request_set $authDate $upstream_http_x_amz_date; + auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256; + + proxy_set_header Authorization $authHeader; + proxy_set_header X-Amz-Date $authDate; + proxy_set_header X-Amz-Content-SHA256 $authContentSha256; + + add_header Content-Security-Policy "default-src 'none'" always; + ''; + }; + }; + }; + }; + + meta = { + buildDocsInSandbox = false; + maintainers = [ lib.maintainers.soyouzpanda ]; + }; +} From fd7cdab61f9f4e793379d57922e5956c360ed082 Mon Sep 17 00:00:00 2001 From: soyouzpanda Date: Mon, 5 May 2025 09:50:40 +0200 Subject: [PATCH 5/5] nixos/tests/lasuite-docs: init --- nixos/tests/all-tests.nix | 1 + nixos/tests/web-apps/lasuite-docs.nix | 168 +++++++++++++++++++++++ pkgs/by-name/la/lasuite-docs/package.nix | 5 + 3 files changed, 174 insertions(+) create mode 100644 nixos/tests/web-apps/lasuite-docs.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 5501ffb8b92b..e6e8101d3191 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -711,6 +711,7 @@ in languagetool = handleTest ./languagetool.nix { }; lanraragi = handleTest ./lanraragi.nix { }; latestKernel.login = handleTest ./login.nix { latestKernel = true; }; + lasuite-docs = runTest ./web-apps/lasuite-docs.nix; lavalink = runTest ./lavalink.nix; leaps = handleTest ./leaps.nix { }; lemmy = handleTest ./lemmy.nix { }; diff --git a/nixos/tests/web-apps/lasuite-docs.nix b/nixos/tests/web-apps/lasuite-docs.nix new file mode 100644 index 000000000000..49af73a7f604 --- /dev/null +++ b/nixos/tests/web-apps/lasuite-docs.nix @@ -0,0 +1,168 @@ +{ lib, ... }: +let + domain = "docs.local"; + oidcDomain = "127.0.0.1:8080"; + s3Domain = "127.0.0.1:9000"; + + minioAccessKey = "a8dff633d164068418a5"; + minioSecretKey = "d546ea5f9c9bfdcf83755a7c09f2f7fb"; +in + +{ + name = "lasuite-docs"; + meta.maintainers = with lib.maintainers; [ + soyouzpanda + ]; + + nodes.machine = + { pkgs, ... }: + { + virtualisation.diskSize = 4 * 1024; + virtualisation.memorySize = 4 * 1024; + + networking.hosts."127.0.0.1" = [ domain ]; + + environment.systemPackages = with pkgs; [ + jq + minio-client + ]; + + services.lasuite-docs = { + enable = true; + enableNginx = true; + redis.createLocally = true; + postgresql.createLocally = true; + + inherit domain; + s3Url = "http://${s3Domain}/lasuite-docs"; + + settings = { + DJANGO_SECRET_KEY_FILE = pkgs.writeText "django-secret-file" '' + 8540db59c03943d48c3ed1a0f96ce3b560e0f45274f120f7ee4dace3cc366a6b + ''; + + OIDC_OP_JWKS_ENDPOINT = "http://${oidcDomain}/dex/keys"; + OIDC_OP_AUTHORIZATION_ENDPOINT = "http://${oidcDomain}/dex/auth/mock"; + OIDC_OP_TOKEN_ENDPOINT = "http://${oidcDomain}/dex/token"; + OIDC_OP_USER_ENDPOINT = "http://${oidcDomain}/dex/userinfo"; + OIDC_RP_CLIENT_ID = "lasuite-docs"; + OIDC_RP_SIGN_ALGO = "RS256"; + OIDC_RP_SCOPES = "openid email"; + OIDC_RP_CLIENT_SECRET = "lasuitedocsclientsecret"; + + LOGIN_REDIRECT_URL = "http://${domain}"; + LOGIN_REDIRECT_URL_FAILURE = "http://${domain}"; + LOGOUT_REDIRECT_URL = "http://${domain}"; + + AWS_S3_ENDPOINT_URL = "http://${s3Domain}"; + AWS_S3_ACCESS_KEY_ID = minioAccessKey; + AWS_S3_SECRET_ACCESS_KEY = minioSecretKey; + AWS_STORAGE_BUCKET_NAME = "lasuite-docs"; + MEDIA_BASE_URL = "http://${domain}"; + + # Disable HTTPS feature in tests because we're running on a HTTP connection + DJANGO_SECURE_PROXY_SSL_HEADER = ""; + DJANGO_SECURE_SSL_REDIRECT = false; + DJANGO_CSRF_COOKIE_SECURE = false; + DJANGO_SESSION_COOKIE_SECURE = false; + DJANGO_CSRF_TRUSTED_ORIGINS = "http://*"; + }; + }; + + services.dex = { + enable = true; + settings = { + issuer = "http://${oidcDomain}/dex"; + storage = { + type = "postgres"; + config.host = "/var/run/postgresql"; + }; + web.http = "127.0.0.1:8080"; + oauth2.skipApprovalScreen = true; + staticClients = [ + { + id = "lasuite-docs"; + name = "Docs"; + redirectURIs = [ "http://${domain}/api/v1.0/callback/" ]; + secretFile = "/etc/dex/lasuite-docs"; + } + ]; + connectors = [ + { + type = "mockPassword"; + id = "mock"; + name = "Example"; + config = { + username = "admin"; + password = "password"; + }; + } + ]; + }; + }; + + services.minio = { + enable = true; + rootCredentialsFile = "/etc/minio/minio-root-credentials"; + }; + + environment.etc."dex/lasuite-docs" = { + mode = "0400"; + user = "dex"; + text = "lasuitedocsclientsecret"; + }; + + environment.etc."minio/minio-root-credentials" = { + mode = "0400"; + text = '' + MINIO_ROOT_USER=${minioAccessKey} + MINIO_ROOT_PASSWORD=${minioSecretKey} + ''; + }; + + services.postgresql = { + enable = true; + ensureDatabases = [ "dex" ]; + ensureUsers = [ + { + name = "dex"; + ensureDBOwnership = true; + } + ]; + }; + }; + + testScript = '' + with subtest("Wait for units to start"): + machine.wait_for_unit("dex.service") + machine.wait_for_unit("minio.service") + machine.wait_for_unit("lasuite-docs.service") + machine.wait_for_unit("lasuite-docs-celery.service") + machine.wait_for_unit("lasuite-docs-collaboration-server.service") + + with subtest("Create S3 bucket"): + machine.succeed("mc config host add minio http://${s3Domain} ${minioAccessKey} ${minioSecretKey} --api s3v4") + machine.succeed("mc mb lasuite-docs") + + with subtest("Wait for web servers to start"): + machine.wait_until_succeeds("curl -fs 'http://${domain}/api/v1.0/authenticate/'", timeout=120) + machine.wait_until_succeeds("curl -fs '${oidcDomain}/dex/auth/mock?client_id=lasuite-docs&response_type=code&redirect_uri=http://${domain}/api/v1.0/callback/&scope=openid'", timeout=120) + + with subtest("Login"): + state, nonce = machine.succeed("curl -fs -c cjar 'http://${domain}/api/v1.0/authenticate/' -w '%{redirect_url}' | sed -n 's/.*state=\\(.*\\)&nonce=\\(.*\\)/\\1 \\2/p'").strip().split(' ') + + oidc_state = machine.succeed(f"curl -fs '${oidcDomain}/dex/auth/mock?client_id=lasuite-docs&response_type=code&redirect_uri=http://${domain}/api/v1.0/callback/&scope=openid+email&state={state}&nonce={nonce}' | sed -n 's/.*state=\\(.*\\)\">.*/\\1/p'").strip() + + code = machine.succeed(f"curl -fs '${oidcDomain}/dex/auth/mock/login?back=&state={oidc_state}' -d 'login=admin&password=password' -w '%{{redirect_url}}' | sed -n 's/.*code=\\(.*\\)&.*/\\1/p'").strip() + print(f"Got approval code {code}") + + machine.succeed(f"curl -fs -c cjar -b cjar 'http://${domain}/api/v1.0/callback/?code={code}&state={state}'") + + with subtest("Create a document"): + csrf_token = machine.succeed("grep csrftoken cjar | cut -f 7 | tr -d '\n'") + + document_id = machine.succeed(f"curl -fs -c cjar -b cjar 'http://${domain}/api/v1.0/documents/' -X POST -H 'X-CSRFToken: {csrf_token}' -H 'Referer: http://${domain}' | jq .id -r").strip() + + print(f"Created document with id {document_id}") + ''; +} diff --git a/pkgs/by-name/la/lasuite-docs/package.nix b/pkgs/by-name/la/lasuite-docs/package.nix index 03300b635979..9ccb81cfe890 100644 --- a/pkgs/by-name/la/lasuite-docs/package.nix +++ b/pkgs/by-name/la/lasuite-docs/package.nix @@ -2,6 +2,7 @@ lib, python3, fetchFromGitHub, + nixosTests, }: let python = python3.override { @@ -102,6 +103,10 @@ python.pkgs.buildPythonApplication rec { --prefix PYTHONPATH : "${pythonPath}:$out/${python.sitePackages}" ''; + passthru.tests = { + login-and-create-doc = nixosTests.lasuite-docs; + }; + meta = { description = "A collaborative note taking, wiki and documentation platform that scales. Built with Django and React. Opensource alternative to Notion or Outline"; homepage = "https://github.com/suitenumerique/docs";