diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index be555522c69a..ab322d9c4aa3 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -132,6 +132,8 @@ - [Howdy](https://github.com/boltgolt/howdy), a Windows Hello™ style facial authentication program for Linux. +- [SuiteNumérique Drive](https://github.com/suitenumerique/drive), a collaborative file sharing and document management platform that scales. Built with Django and React. Open source alternative to Sharepoint or Google Drive. + - [linux-enable-ir-emitter](https://github.com/EmixamPP/linux-enable-ir-emitter), a tool used to set up IR cameras, used with Howdy. - [udp-over-tcp](https://github.com/mullvad/udp-over-tcp), a tunnel for proxying UDP traffic over a TCP stream. Available as `services.udp-over-tcp`. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 6995f3ba1ed2..c57b627e875c 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1697,6 +1697,7 @@ ./services/web-apps/komga.nix ./services/web-apps/lanraragi.nix ./services/web-apps/lasuite-docs.nix + ./services/web-apps/lasuite-drive.nix ./services/web-apps/lasuite-meet.nix ./services/web-apps/lauti.nix ./services/web-apps/lemmy.nix diff --git a/nixos/modules/services/web-apps/lasuite-drive.nix b/nixos/modules/services/web-apps/lasuite-drive.nix new file mode 100644 index 000000000000..bd7d17f105ca --- /dev/null +++ b/nixos/modules/services/web-apps/lasuite-drive.nix @@ -0,0 +1,542 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: +let + inherit (lib) + concatMapStringsSep + concatStringsSep + escapeShellArg + getExe + hasSuffix + mapAttrs + match + mkEnableOption + mkIf + mkPackageOption + mkOption + types + optional + optionalString + ; + + cfg = config.services.lasuite-drive; + + 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-drive"; + StateDirectory = "lasuite-drive"; + WorkingDirectory = "/var/lib/lasuite-drive"; + + User = "lasuite-drive"; + DynamicUser = true; + SupplementaryGroups = mkIf cfg.redis.createLocally [ + config.services.redis.servers.lasuite-drive.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; + MemoryDenyWriteExecute = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + UMask = "0077"; + EnvironmentFile = cfg.environmentFiles; + }; + + proxySuffix = if match "unix:.*" cfg.bind != null then ":" else ""; + + # Convert environment variables to be used as systemd-run arguments + envArgs = lib.concatStringsSep " " ( + lib.mapAttrsToList (name: value: "-E ${escapeShellArg "${name}=${value}"}") pythonEnvironment + ); + + # Easier usage of django manage.py stuff + manage = pkgs.writeShellScriptBin "lasuite-drive-manage" '' + exec ${lib.getExe' config.systemd.package "systemd-run"} \ + -p User=${commonServiceConfig.User} \ + -p DynamicUser=yes \ + -p StateDirectory=${commonServiceConfig.StateDirectory} \ + ${optionalString cfg.redis.createLocally "-p SupplementaryGroups=${config.services.redis.servers.lasuite-drive.group} \\"} + ${concatMapStringsSep "\n" (envFile: "-p EnvironmentFile=${envFile} \\") cfg.environmentFiles} + --working-directory=${commonServiceConfig.WorkingDirectory} \ + --quiet --collect --pipe --pty \ + ${envArgs} ${lib.getExe cfg.package} "$@" + ''; +in +{ + options.services.lasuite-drive = { + enable = mkEnableOption "SuiteNumérique Drive"; + + package = mkPackageOption pkgs "lasuite-drive" { }; + + bind = mkOption { + type = types.str; + default = "unix:/run/lasuite-drive/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())' + ``` + + :::{.note} + If not specified, a secret key is automatically generated and stored in the state directory. + ::: + ''; + }; + + 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 drive. + ''; + }; + }; + + redis = { + createLocally = mkOption { + type = types.bool; + default = false; + description = '' + Configure local Redis cache server for drive. + ''; + }; + }; + + gunicorn = { + extraArgs = mkOption { + type = types.listOf types.str; + default = [ + "--name=drive" + "--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 drive 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 = "drive.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-drive/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-drive"; + description = "Path to the data directory"; + readOnly = true; + }; + + DJANGO_ALLOWED_HOSTS = mkOption { + type = types.listOf 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 [ ] + ''; + apply = list: concatStringsSep "," list; + description = "Comma-separated list of hosts that are able to connect to the server"; + }; + + DB_NAME = mkOption { + type = types.str; + default = "lasuite-drive"; + description = "Name of the database"; + }; + + DB_USER = mkOption { + type = types.str; + default = "lasuite-drive"; + 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-drive.unixSocket}?db=1" + 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-drive.unixSocket}?db=2" + else + null; + description = "URL of the redis backend for celery"; + }; + }; + }; + default = { }; + example = '' + { + AWS_S3_ENDPOINT_URL = "https://s3.us-west.amazonaws.com"; + } + ''; + description = '' + Configuration options of drive. + + See + + `REDIS_URL` and `CELERY_BROKER_URL` are set if `services.lasuite-drive.redis.createLocally` is true. + `DB_HOST` is set if `services.lasuite-drive.postgresql.createLocally` is true. + ''; + }; + + environmentFiles = mkOption { + type = types.listOf types.path; + default = [ ]; + description = '' + Path to environment files. + + This can be useful to pass secrets to drive via tools like `agenix` or `sops`. + ''; + }; + }; + + config = mkIf cfg.enable { + warnings = mkIf (cfg.enableNginx && !(hasSuffix "/" cfg.s3Url)) [ + '' + services.lasuite-drive.s3Url should end with a trailing slash (/). + This could break the HTTP requests by nginx to the S3 backend. + '' + ]; + + environment.systemPackages = [ manage ]; + + systemd.services.lasuite-drive = { + description = "Drive from SuiteNumérique"; + after = [ + "network-online.target" + ] + ++ (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-drive.service"); + wants = + (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-drive.service"); + wantedBy = [ "multi-user.target" ]; + + preStart = '' + if [ ! -f .version ]; then + touch .version + fi + + ${optionalString (cfg.secretKeyPath == null) '' + if [[ ! -f /var/lib/lasuite-drive/django_secret_key ]]; then + ( + umask 0377 + tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge /var/lib/lasuite-drive/django_secret_key + ) + fi + ''} + if [ "${cfg.package.version}" != "$(cat .version)" ]; then + ${getExe cfg.package} migrate + echo -n "${cfg.package.version}" > .version + fi + ''; + + environment = pythonEnvironment; + + serviceConfig = { + BindReadOnlyPaths = "${cfg.package}/share/static:/var/lib/lasuite-drive/static"; + + ExecStart = utils.escapeSystemdExecArgs ( + [ + (lib.getExe' cfg.package "gunicorn") + "--bind=${cfg.bind}" + ] + ++ cfg.gunicorn.extraArgs + ++ [ "drive.wsgi:application" ] + ); + } + // commonServiceConfig; + }; + + systemd.services.lasuite-drive-celery = { + description = "Docs Celery broker from SuiteNumérique"; + after = [ + "network-online.target" + ] + ++ (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-drive.service"); + wants = + (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-drive.service"); + wantedBy = [ "multi-user.target" ]; + + environment = pythonEnvironment; + + serviceConfig = { + ExecStart = utils.escapeSystemdExecArgs ( + [ (lib.getExe' cfg.package "celery") ] + ++ cfg.celery.extraArgs + ++ [ + "--app=drive.celery_app" + "worker" + ] + ); + } + // commonServiceConfig; + }; + + systemd.services.lasuite-drive-beat = { + description = "Docs Celery beat from SuiteNumérique"; + after = [ + "network-online.target" + ] + ++ (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-drive.service"); + wants = + (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-drive.service"); + wantedBy = [ "multi-user.target" ]; + + environment = pythonEnvironment; + + serviceConfig = { + ExecStart = utils.escapeSystemdExecArgs ( + [ (lib.getExe' cfg.package "celery") ] + ++ cfg.celery.extraArgs + ++ [ + "--app=drive.celery_app" + "beat" + ] + ); + } + // commonServiceConfig; + }; + + services.postgresql = mkIf cfg.postgresql.createLocally { + enable = true; + ensureDatabases = [ "lasuite-drive" ]; + ensureUsers = [ + { + name = "lasuite-drive"; + ensureDBOwnership = true; + } + ]; + }; + + services.redis.servers.lasuite-drive = mkIf cfg.redis.createLocally { enable = true; }; + + services.nginx = mkIf cfg.enableNginx { + enable = true; + + virtualHosts.${cfg.domain} = { + extraConfig = '' + error_page 401 /401.html; + error_page 403 /403.html; + error_page 404 /404.html; + ''; + + root = cfg.package.frontend; + + locations."/" = { + tryFiles = "$uri $uri.html index.html $uri/ =404"; + }; + + locations."~ '^/explorer/items/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$'" = + { + tryFiles = "$uri /explorer/items/[id].html"; + }; + + locations."~ '^/explorer/items/files/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$'" = + { + tryFiles = "$uri /explorer/items/files/[id].html"; + }; + + locations."~ '^/wopi/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$'" = { + tryFiles = "$uri /wopi/[id].html"; + }; + + locations."/static/" = { + alias = "${cfg.package}/share/static/"; + }; + + locations."/api" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + }; + + locations."/admin" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + }; + + locations."/media-auth" = { + proxyPass = "http://${cfg.bind}${proxySuffix}/api/v1.0/items/media-auth/"; + recommendedProxySettings = true; + extraConfig = '' + 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; + 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-Disposition "attachment"; + ''; + }; + + locations."/media/preview/" = { + proxyPass = cfg.s3Url; + 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; + ''; + }; + }; + }; + }; + + meta = { + buildDocsInSandbox = false; + maintainers = [ lib.maintainers.soyouzpanda ]; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 0e2233ade92d..f9ecf72845ed 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -872,6 +872,7 @@ in languagetool = runTest ./languagetool.nix; lanraragi = runTest ./lanraragi.nix; lasuite-docs = runTest ./web-apps/lasuite-docs.nix; + lasuite-drive = runTest ./web-apps/lasuite-drive.nix; lasuite-meet = runTest ./web-apps/lasuite-meet.nix; latestKernel.login = runTest { imports = [ ./login.nix ]; diff --git a/nixos/tests/web-apps/lasuite-drive.nix b/nixos/tests/web-apps/lasuite-drive.nix new file mode 100644 index 000000000000..416c49c832d9 --- /dev/null +++ b/nixos/tests/web-apps/lasuite-drive.nix @@ -0,0 +1,243 @@ +{ lib, ... }: +let + domain = "drive.local"; + oidcAddr = "127.0.0.1:8080"; + s3Addr = "127.0.0.1:9000"; + + garageAccessKey = "GKaaaaaaaaaaaaaaaaaaaaaaaa"; + garageSecretKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +in + +{ + name = "lasuite-drive"; + meta.maintainers = with lib.maintainers; [ + soyouzpanda + ]; + + containers.machine = + { pkgs, ... }: + { + networking.hosts."127.0.0.1" = [ domain ]; + + environment.systemPackages = with pkgs; [ + awscli2 + jq + ]; + + services.lasuite-drive = { + enable = true; + enableNginx = true; + redis.createLocally = true; + postgresql.createLocally = true; + + inherit domain; + s3Url = "http://${s3Addr}/lasuite-drive/"; + + settings = { + DJANGO_SECRET_KEY_FILE = pkgs.writeText "django-secret-file" '' + 8540db59c03943d48c3ed1a0f96ce3b560e0f45274f120f7ee4dace3cc366a6b + ''; + + OIDC_OP_JWKS_ENDPOINT = "http://${oidcAddr}/dex/keys"; + OIDC_OP_AUTHORIZATION_ENDPOINT = "http://${oidcAddr}/dex/auth/mock"; + OIDC_OP_TOKEN_ENDPOINT = "http://${oidcAddr}/dex/token"; + OIDC_OP_USER_ENDPOINT = "http://${oidcAddr}/dex/userinfo"; + OIDC_RP_CLIENT_ID = "lasuite-drive"; + OIDC_RP_SIGN_ALGO = "RS256"; + OIDC_RP_SCOPES = "openid email"; + OIDC_RP_CLIENT_SECRET = "lasuitedriveclientsecret"; + + LOGIN_REDIRECT_URL = "http://${domain}"; + LOGIN_REDIRECT_URL_FAILURE = "http://${domain}"; + LOGOUT_REDIRECT_URL = "http://${domain}"; + + AWS_S3_ENDPOINT_URL = "http://${s3Addr}"; + AWS_S3_ACCESS_KEY_ID = garageAccessKey; + AWS_S3_SECRET_ACCESS_KEY = garageSecretKey; + AWS_STORAGE_BUCKET_NAME = "lasuite-drive"; + AWS_S3_REGION_NAME = "garage"; + 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://${oidcAddr}/dex"; + storage = { + type = "postgres"; + config.host = "/var/run/postgresql"; + }; + web.http = "127.0.0.1:8080"; + oauth2.skipApprovalScreen = true; + staticClients = [ + { + id = "lasuite-drive"; + name = "Drive"; + redirectURIs = [ "http://${domain}/api/v1.0/callback/" ]; + secretFile = "/etc/dex/lasuite-drive"; + } + ]; + connectors = [ + { + type = "mockPassword"; + id = "mock"; + name = "Example"; + config = { + username = "admin"; + password = "password"; + }; + } + ]; + }; + }; + + services.garage = { + enable = true; + package = pkgs.garage_2; + settings = { + rpc_bind_addr = "127.0.0.1:3901"; + rpc_public_addr = "127.0.0.1:3901"; + rpc_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + replication_factor = 1; + + s3_api = { + s3_region = "garage"; + api_bind_addr = s3Addr; + }; + }; + }; + environment.etc."dex/lasuite-drive" = { + mode = "0400"; + user = "dex"; + text = "lasuitedriveclientsecret"; + }; + + services.postgresql = { + enable = true; + ensureDatabases = [ "dex" ]; + ensureUsers = [ + { + name = "dex"; + ensureDBOwnership = true; + } + ]; + }; + }; + + testScript = '' + import json + + with subtest("Wait for units to start"): + machine.wait_for_unit("dex.service") + machine.wait_for_unit("garage.service") + machine.wait_for_unit("lasuite-drive.service") + machine.wait_for_unit("lasuite-drive-celery.service") + machine.wait_for_unit("lasuite-drive-beat.service") + + with subtest("Create S3 bucket"): + machine.wait_for_open_port(3901) + garage_node_id = machine.succeed("garage status | tail -n1 | awk '{ print $1 }'") + machine.succeed(f"garage layout assign -c 100MB -z garage {garage_node_id}") + machine.succeed("garage layout apply --version 1") + machine.succeed("garage key import ${garageAccessKey} ${garageSecretKey} --yes") + machine.succeed("garage bucket create lasuite-drive") + machine.succeed("garage bucket allow --read --write --owner lasuite-drive --key ${garageAccessKey}") + machine.succeed("AWS_SECRET_ACCESS_KEY=${garageSecretKey} " + "AWS_ACCESS_KEY_ID=${garageAccessKey} " + "aws --endpoint http://${s3Addr} " + "s3api put-bucket-cors " + "--bucket lasuite-drive --cors-configuration " + """'{"CORSRules":[{"AllowedHeaders":["*"],"AllowedMethods":["PUT"],"AllowedOrigins":["http://${domain}"]}]}'""") + + 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 " + "'${oidcAddr}/dex/auth/mock?client_id=lasuite-drive&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("curl -fs " + f"'${oidcAddr}/dex/auth/mock?client_id=lasuite-drive&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("curl -fs " + f"'${oidcAddr}/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("Upload a document"): + csrf_token = machine.succeed("grep csrftoken cjar | cut -f 7 | tr -d '\n'") + upload = json.loads( + machine.succeed("curl -fs -c cjar -b cjar " + "'http://${domain}/api/v1.0/items/' " + "-X POST " + f"-H 'X-CSRFToken: {csrf_token}' " + "-H 'Content-Type: application/json' " + "-H 'Referer: http://${domain}/explorer/items/my-files' " + "--data-raw '{\"type\":\"file\",\"filename\":\"text.txt\"}'" + ) + ) + + print(f"Created file with id {upload['id']}") + + machine.succeed("curl -fs " + f"'{upload['policy']}' " + "-X PUT " + "-H 'Origin: http://${domain}' " + "-H 'X-amz-acl: private' " + "-H 'Content-Type: text/plain' " + "-H 'Content-Length: 8' " + "--data-raw 'sometext'") + + print("Uploaded file") + + csrf_token = machine.succeed("grep csrftoken cjar | cut -f 7 | tr -d '\n'") + machine.succeed("curl -fs -c cjar -b cjar " + f"'http://${domain}/api/v1.0/items/{upload['id']}/upload-ended/' " + "-X POST " + f"-H 'X-CSRFToken: {csrf_token}' " + "-H 'Referer: http://${domain}/explorer/items/my-files'") + + with subtest("Download a document"): + csrf_token = machine.succeed("grep csrftoken cjar | cut -f 7 | tr -d '\n'") + items = json.loads( + machine.succeed("curl -fs -c cjar -b cjar " + f"'http://${domain}/api/v1.0/items/' " + "-X GET " + f"-H 'X-CSRFToken: {csrf_token}' " + "-H 'Referer: http://${domain}/explorer/items/my-files'" + ) + ) + + assert items["count"] == 1 + + url = items["results"][0]["url_permalink"] + + data = machine.succeed("curl -fs -c cjar -b cjar -L " + f"'{url}' " + "-X GET " + "-H 'Referer: http://${domain}/explorer/items/my-files'") + + assert data == "sometext" + ''; +} diff --git a/pkgs/by-name/la/lasuite-drive/frontend.nix b/pkgs/by-name/la/lasuite-drive/frontend.nix new file mode 100644 index 000000000000..5ed9033ec82e --- /dev/null +++ b/pkgs/by-name/la/lasuite-drive/frontend.nix @@ -0,0 +1,48 @@ +{ + src, + version, + meta, + stdenv, + fetchYarnDeps, + nodejs, + fixup-yarn-lock, + yarn, + yarnConfigHook, + yarnBuildHook, +}: + +stdenv.mkDerivation (finalAttrs: { + pname = "lasuite-drive-frontend"; + inherit src version; + + sourceRoot = "${finalAttrs.src.name}/src/frontend"; + + offlineCache = fetchYarnDeps { + yarnLock = "${finalAttrs.src}/src/frontend/yarn.lock"; + hash = "sha256-yUKJp6yUTxpvkaA+YuQC3r1t4LBvuYMv1xesLewbK/U="; + }; + + nativeBuildInputs = [ + nodejs + fixup-yarn-lock + yarn + yarnConfigHook + yarnBuildHook + ]; + + strictDeps = true; + + installPhase = '' + runHook preInstall + + cp -r apps/drive/out/ $out + + runHook postInstall + ''; + + __structuredAttrs = true; + + meta = meta // { + description = "A collaborative file sharing and document management platform that scales. Built with Django and React. Opensource alternative to Sharepoint or Google Drive"; + }; +}) diff --git a/pkgs/by-name/la/lasuite-drive/mail.nix b/pkgs/by-name/la/lasuite-drive/mail.nix new file mode 100644 index 000000000000..e30e55cbaa34 --- /dev/null +++ b/pkgs/by-name/la/lasuite-drive/mail.nix @@ -0,0 +1,42 @@ +{ + src, + version, + meta, + stdenv, + fetchYarnDeps, + nodejs, + yarnConfigHook, + yarnBuildHook, +}: +stdenv.mkDerivation (finalAttrs: { + pname = "lasuite-drive-mail"; + inherit src version; + + sourceRoot = "${finalAttrs.src.name}/src/mail"; + + postPatch = '' + substituteInPlace bin/html-to-plain-text bin/mjml-to-html \ + --replace-fail \ + '../backend/core/templates/mail' \ + '${placeholder "out"}' + ''; + + offlineCache = fetchYarnDeps { + yarnLock = "${finalAttrs.src}/src/mail/yarn.lock"; + hash = "sha256-UPIb9QJk+zC8wYeBeDnmlGLhHDhsEOoT+qquFM1XyqU="; + }; + + nativeBuildInputs = [ + nodejs + yarnConfigHook + yarnBuildHook + ]; + + dontInstall = true; + + __structuredAttrs = true; + + meta = meta // { + description = "HTML mail templates for LaSuite Drive"; + }; +}) diff --git a/pkgs/by-name/la/lasuite-drive/package.nix b/pkgs/by-name/la/lasuite-drive/package.nix new file mode 100644 index 000000000000..61d77e62b6a8 --- /dev/null +++ b/pkgs/by-name/la/lasuite-drive/package.nix @@ -0,0 +1,156 @@ +{ + callPackage, + lib, + python3, + stdenv, + fetchFromGitHub, + nixosTests, +}: +let + version = "0.18.0"; + + src = fetchFromGitHub { + owner = "suitenumerique"; + repo = "drive"; + tag = "v${version}"; + hash = "sha256-JoOHbwZR4salfLB9Gg7kfRMDcDA/Srn8qwUqLAZtsz8="; + }; + + meta = { + homepage = "https://github.com/suitenumerique/drive"; + changelog = "https://github.com/suitenumerique/drive/blob/${src.tag}/CHANGELOG.md"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ soyouzpanda ]; + platforms = lib.platforms.linux; + }; + + mail = callPackage ./mail.nix { inherit src version meta; }; + frontend = callPackage ./frontend.nix { inherit src version meta; }; + + python = python3.override { + self = python; + packageOverrides = (self: super: { django = super.django_5; }); + }; + +in +python.pkgs.buildPythonApplication (finalAttrs: { + pname = "lasuite-drive"; + pyproject = true; + inherit version src; + + sourceRoot = "${finalAttrs.src.name}/src/backend"; + + patches = [ + # Support configuration throught environment variables for SECURE_* + ./secure_settings.patch + # Fix some build fields on pyproject + ./pyproject_build.patch + ]; + + build-system = with python.pkgs; [ uv-build ]; + + dependencies = + with python.pkgs; + [ + boto3 + brotli + celery + defusedxml + dj-database-url + django + django-configurations + django-cors-headers + django-countries + django-debug-toolbar + django-extensions + django-filter + django-lasuite + django-ltree + django-parler + django-pydantic-field + django-redis + django-storages + django-timezone-field + djangorestframework + djangorestframework-api-key + dockerflow + drf-spectacular + drf-spectacular-sidecar + drf-standardized-errors + easy-thumbnails + factory-boy + gunicorn + jsonschema + markdown + mozilla-django-oidc + nested-multipart-parser + posthog + psycopg + pydantic + pyjwt + python-magic + redis + requests + sentry-sdk + url-normalize + whitenoise + ] + ++ celery.optional-dependencies.redis + ++ django-storages.optional-dependencies.s3; + + pythonRelaxDeps = true; + + postPatch = '' + # Put assets inside a data directory + # so uv will copy the assets directory + # entirely + mkdir data + mv assets data + '' + + (lib.optionalString stdenv.hostPlatform.isDarwin '' + substituteInPlace impress/settings.py \ + --replace-fail \ + "gethostname()" \ + "gethostname() + '.local'" + ''); + __darwinAllowLocalNetworking = true; + + postBuild = '' + export DATA_DIR=$(pwd)/data + ${python.pythonOnBuildForHost.interpreter} manage.py collectstatic --no-input --clear + ''; + + postInstall = + let + pythonPath = python.pkgs.makePythonPath finalAttrs.passthru.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/drive \ + --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}" + + mkdir -p $out/${python.sitePackages}/core/templates + ln -sv ${mail}/ $out/${python.sitePackages}/core/templates/mail + ''; + + passthru = { + inherit mail frontend; + tests = { + login-upload-and-download-file = nixosTests.lasuite-drive; + }; + }; + + __structuredAttrs = true; + + meta = meta // { + description = "A collaborative file sharing and document management platform that scales. Built with Django and React. Opensource alternative to Sharepoint or Google Drive"; + mainProgram = "drive"; + }; +}) diff --git a/pkgs/by-name/la/lasuite-drive/pyproject_build.patch b/pkgs/by-name/la/lasuite-drive/pyproject_build.patch new file mode 100644 index 000000000000..062b1e6b4b59 --- /dev/null +++ b/pkgs/by-name/la/lasuite-drive/pyproject_build.patch @@ -0,0 +1,24 @@ +diff --git a/pyproject.toml b/pyproject.toml +index ba8f5362..ddc40c83 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -2,7 +2,7 @@ + # drive package + # + [build-system] +-requires = ["uv_build>=0.11.7,<0.12.0"] ++requires = ["uv_build"] + build-backend = "uv_build" + + [project] +@@ -102,6 +102,9 @@ constraint-dependencies = [ + + [tool.uv.build-backend] + module-root = "" ++module-name = ["core", "demo", "drive", "e2e", "wopi"] ++data = ["data"] ++namespace = true + source-exclude = [ + "**/tests/**", + "**/test_*.py", + diff --git a/pkgs/by-name/la/lasuite-drive/secure_settings.patch b/pkgs/by-name/la/lasuite-drive/secure_settings.patch new file mode 100644 index 000000000000..9eb2b43f345c --- /dev/null +++ b/pkgs/by-name/la/lasuite-drive/secure_settings.patch @@ -0,0 +1,36 @@ +diff --git a/drive/settings.py b/drive/settings.py +index 82f4d31..3d66c23 100755 +--- a/drive/settings.py ++++ b/drive/settings.py +@@ -775,19 +775,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" diff --git a/pkgs/development/python-modules/django-ltree/default.nix b/pkgs/development/python-modules/django-ltree/default.nix new file mode 100644 index 000000000000..dcc0f5e59489 --- /dev/null +++ b/pkgs/development/python-modules/django-ltree/default.nix @@ -0,0 +1,40 @@ +{ + lib, + buildPythonPackage, + fetchFromGitHub, + setuptools, + django, +}: + +buildPythonPackage (finalAttrs: { + pname = "django-ltree"; + version = "0.6.0"; + pyproject = true; + + src = fetchFromGitHub { + owner = "mariocesar"; + repo = "django-ltree"; + tag = "v${finalAttrs.version}"; + hash = "sha256-XN2znH9bNU8jaY2HC8qmSR6VqShcEFGAtNtb/5aLgic="; + }; + + build-system = [ + setuptools + ]; + + dependencies = [ + django + ]; + + pythonImportsCheck = [ + "django_ltree" + ]; + + meta = { + description = "An ltree extension implementation to support hierarchical tree-like data using the native Postgres extension ltree in django models"; + homepage = "https://github.com/mariocesar/django-ltree"; + changelog = "https://github.com/mariocesar/django-ltree/releases/tag/${finalAttrs.src.tag}"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ soyouzpanda ]; + }; +}) diff --git a/pkgs/development/python-modules/djangorestframework-api-key/default.nix b/pkgs/development/python-modules/djangorestframework-api-key/default.nix new file mode 100644 index 000000000000..35f3880322e5 --- /dev/null +++ b/pkgs/development/python-modules/djangorestframework-api-key/default.nix @@ -0,0 +1,73 @@ +{ + lib, + buildPythonPackage, + fetchFromGitHub, + dj-database-url, + django, + django-test-migrations, + djangorestframework, + packaging, + pytest-cov-stub, + pytest-django, + pytest-dotenv, + pytestCheckHook, + setuptools, + setuptools-scm, +}: + +buildPythonPackage (finalAttrs: { + pname = "djangorestframework-api-key"; + version = "3.1.0"; + pyproject = true; + + src = fetchFromGitHub { + owner = "florimondmanca"; + repo = "djangorestframework-api-key"; + tag = "v${finalAttrs.version}"; + hash = "sha256-TyYSO3OQslipl2T5BtsTABaTJD4HMCX61TOZXNR+lXE="; + }; + + # Use python-dotenv instead of django-dotenv + # as django-dotenv has not been maintained for + # years. + patchPhase = '' + runHook prePatch + + substituteInPlace tests/conftest.py test_project/manage.py \ + --replace-fail 'read_dotenv' 'load_dotenv' + + runHook postPatch + ''; + + build-system = [ + setuptools + setuptools-scm + ]; + + dependencies = [ + django + djangorestframework + packaging + ]; + + nativeCheckInputs = [ + dj-database-url + django-test-migrations + pytest-cov-stub + pytest-django + pytest-dotenv + pytestCheckHook + ]; + + pythonImportsCheck = [ + "rest_framework_api_key" + ]; + + meta = { + description = "API key permissions for Django REST Framework"; + homepage = "https://github.com/florimondmanca/djangorestframework-api-key"; + changelog = "https://github.com/florimondmanca/djangorestframework-api-key/blob/${finalAttrs.src.tag}/CHANGELOG.md"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ soyouzpanda ]; + }; +}) diff --git a/pkgs/top-level/python-packages.nix b/pkgs/top-level/python-packages.nix index 712df11f6f61..4863de7d32e3 100644 --- a/pkgs/top-level/python-packages.nix +++ b/pkgs/top-level/python-packages.nix @@ -4282,6 +4282,8 @@ self: super: with self; { callPackage ../development/python-modules/django-login-required-middleware { }; + django-ltree = callPackage ../development/python-modules/django-ltree { }; + django-mailbox = callPackage ../development/python-modules/django-mailbox { }; django-mailman3 = callPackage ../development/python-modules/django-mailman3 { }; @@ -4507,6 +4509,10 @@ self: super: with self; { djangorestframework = callPackage ../development/python-modules/djangorestframework { }; + djangorestframework-api-key = + callPackage ../development/python-modules/djangorestframework-api-key + { }; + djangorestframework-camel-case = callPackage ../development/python-modules/djangorestframework-camel-case { };