diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 270e30704063..97d76e4984e9 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -551,6 +551,7 @@ ./services/misc/osrm.nix ./services/misc/packagekit.nix ./services/misc/paperless.nix + ./services/misc/paperless-ng.nix ./services/misc/parsoid.nix ./services/misc/plex.nix ./services/misc/plikd.nix diff --git a/nixos/modules/services/misc/paperless-ng.nix b/nixos/modules/services/misc/paperless-ng.nix new file mode 100644 index 000000000000..12d9a45d3a19 --- /dev/null +++ b/nixos/modules/services/misc/paperless-ng.nix @@ -0,0 +1,304 @@ +{ config, pkgs, lib, ... }: + +with lib; +let + cfg = config.services.paperless-ng; + + defaultUser = "paperless"; + + env = { + PAPERLESS_DATA_DIR = cfg.dataDir; + PAPERLESS_MEDIA_ROOT = cfg.mediaDir; + PAPERLESS_CONSUMPTION_DIR = cfg.consumptionDir; + GUNICORN_CMD_ARGS = "--bind=${cfg.address}:${toString cfg.port}"; + } // lib.mapAttrs (_: toString) cfg.extraConfig; + + manage = let + setupEnv = lib.concatStringsSep "\n" (mapAttrsToList (name: val: "export ${name}=\"${val}\"") env); + in pkgs.writeShellScript "manage" '' + ${setupEnv} + exec ${cfg.package}/bin/paperless-ng "$@" + ''; + + # Secure the services + defaultServiceConfig = { + TemporaryFileSystem = "/:ro"; + BindReadOnlyPaths = [ + "/nix/store" + "-/etc/resolv.conf" + "-/etc/nsswitch.conf" + "-/etc/hosts" + "-/etc/localtime" + ]; + BindPaths = [ + cfg.consumptionDir + cfg.dataDir + cfg.mediaDir + ]; + CapabilityBoundingSet = ""; + # ProtectClock adds DeviceAllow=char-rtc r + DeviceAllow = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + # Needs to connect to redis + # PrivateNetwork = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + # Breaks if the home dir of the user is in /home + # Also does not add much value in combination with the TemporaryFileSystem. + # ProtectHome = true; + ProtectHostname = true; + # Would re-mount paths ignored by temporary root + #ProtectSystem = "strict"; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged @resources @setuid @keyring" ]; + # Does not work well with the temporary root + #UMask = "0066"; + }; +in +{ + meta.maintainers = with maintainers; [ earvstedt Flakebi ]; + + options.services.paperless-ng = { + enable = mkOption { + type = lib.types.bool; + default = false; + description = '' + Enable Paperless-ng. + + When started, the Paperless database is automatically created if it doesn't + exist and updated if the Paperless package has changed. + Both tasks are achieved by running a Django migration. + + A script to manage the Paperless instance (by wrapping Django's manage.py) is linked to + ''${dataDir}/paperless-ng-manage. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/paperless"; + description = "Directory to store the Paperless data."; + }; + + mediaDir = mkOption { + type = types.str; + default = "${cfg.dataDir}/media"; + defaultText = "\${dataDir}/consume"; + description = "Directory to store the Paperless documents."; + }; + + consumptionDir = mkOption { + type = types.str; + default = "${cfg.dataDir}/consume"; + defaultText = "\${dataDir}/consume"; + description = "Directory from which new documents are imported."; + }; + + consumptionDirIsPublic = mkOption { + type = types.bool; + default = false; + description = "Whether all users can write to the consumption dir."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/paperless-ng-password"; + description = '' + A file containing the superuser password. + + A superuser is required to access the web interface. + If unset, you can create a superuser manually by running + ''${dataDir}/paperless-ng-manage createsuperuser. + + The default superuser name is admin. To change it, set + option . + WARNING: When changing the superuser name after the initial setup, the old superuser + will continue to exist. + + To disable login for the web interface, set the following: + extraConfig.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";. + WARNING: Only use this on a trusted system without internet access to Paperless. + ''; + }; + + address = mkOption { + type = types.str; + default = "localhost"; + description = "Web interface address."; + }; + + port = mkOption { + type = types.port; + default = 28981; + description = "Web interface port."; + }; + + extraConfig = mkOption { + type = types.attrs; + default = {}; + description = '' + Extra paperless-ng config options. + + See the documentation + for available options. + ''; + example = literalExample '' + { + PAPERLESS_OCR_LANGUAGE = "deu+eng"; + } + ''; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + description = "User under which Paperless runs."; + }; + + package = mkOption { + type = types.package; + default = pkgs.paperless-ng; + defaultText = "pkgs.paperless-ng"; + description = "The Paperless package to use."; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = config.services.paperless.enable -> + (config.services.paperless.dataDir != cfg.dataDir && config.services.paperless.port != cfg.port); + message = "Paperless-ng replaces Paperless, either disable Paperless or assign a new dataDir and port to one of them"; + } + ]; + + # Enable redis if no special url is set + services.redis.enable = mkIf (!hasAttr "PAPERLESS_REDIS" env) true; + + systemd.tmpfiles.rules = [ + "d '${cfg.dataDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -" + "d '${cfg.mediaDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -" + (if cfg.consumptionDirIsPublic then + "d '${cfg.consumptionDir}' 777 - - - -" + else + "d '${cfg.consumptionDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -" + ) + ]; + + systemd.services.paperless-ng-server = { + description = "Paperless document server"; + serviceConfig = defaultServiceConfig // { + User = cfg.user; + ExecStart = "${cfg.package}/bin/paperless-ng qcluster"; + Restart = "on-failure"; + }; + environment = env; + wantedBy = [ "multi-user.target" ]; + wants = [ "paperless-ng-consumer.service" "paperless-ng-web.service" ]; + + preStart = '' + ln -sf ${manage} ${cfg.dataDir}/paperless-ng-manage + + # Auto-migrate on first run or if the package has changed + versionFile="${cfg.dataDir}/src-version" + if [[ $(cat "$versionFile" 2>/dev/null) != ${cfg.package} ]]; then + ${cfg.package}/bin/paperless-ng migrate + echo ${cfg.package} > "$versionFile" + fi + '' + + optionalString (cfg.passwordFile != null) '' + export PAPERLESS_ADMIN_USER="''${PAPERLESS_ADMIN_USER:-admin}" + export PAPERLESS_ADMIN_PASSWORD=$(cat "${cfg.dataDir}/superuser-password") + superuserState="$PAPERLESS_ADMIN_USER:$PAPERLESS_ADMIN_PASSWORD" + superuserStateFile="${cfg.dataDir}/superuser-state" + + if [[ $(cat "$superuserStateFile" 2>/dev/null) != $superuserState ]]; then + ${cfg.package}/bin/paperless-ng manage_superuser + echo "$superuserState" > "$superuserStateFile" + fi + ''; + }; + + # Password copying can't be implemented as a privileged preStart script + # in 'paperless-ng-server' because 'defaultServiceConfig' limits the filesystem + # paths accessible by the service. + systemd.services.paperless-ng-copy-password = mkIf (cfg.passwordFile != null) { + requiredBy = [ "paperless-ng-server.service" ]; + before = [ "paperless-ng-server.service" ]; + serviceConfig = { + ExecStart = '' + ${pkgs.coreutils}/bin/install --mode 600 --owner '${cfg.user}' --compare \ + '${cfg.passwordFile}' '${cfg.dataDir}/superuser-password' + ''; + Type = "oneshot"; + }; + }; + + systemd.services.paperless-ng-consumer = { + description = "Paperless document consumer"; + serviceConfig = defaultServiceConfig // { + User = cfg.user; + ExecStart = "${cfg.package}/bin/paperless-ng document_consumer"; + Restart = "on-failure"; + }; + environment = env; + # Bind to `paperless-ng-server` so that the consumer never runs + # during migrations + bindsTo = [ "paperless-ng-server.service" ]; + after = [ "paperless-ng-server.service" ]; + }; + + systemd.services.paperless-ng-web = { + description = "Paperless web server"; + serviceConfig = defaultServiceConfig // { + User = cfg.user; + ExecStart = '' + ${pkgs.python3Packages.gunicorn}/bin/gunicorn \ + -c ${cfg.package}/lib/paperless-ng/gunicorn.conf.py paperless.asgi:application + ''; + Restart = "on-failure"; + + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + CapabilityBoundingSet = "CAP_NET_BIND_SERVICE"; + # gunicorn needs setuid + SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "@setuid" ]; + }; + environment = env // { + PATH = mkForce cfg.package.path; + PYTHONPATH = "${cfg.package.pythonPath}:${cfg.package}/lib/paperless-ng/src"; + }; + # Bind to `paperless-ng-server` so that the web server never runs + # during migrations + bindsTo = [ "paperless-ng-server.service" ]; + after = [ "paperless-ng-server.service" ]; + }; + + users = optionalAttrs (cfg.user == defaultUser) { + users.${defaultUser} = { + group = defaultUser; + uid = config.ids.uids.paperless; + home = cfg.dataDir; + }; + + groups.${defaultUser} = { + gid = config.ids.gids.paperless; + }; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 8369f60e7b2e..b41fc7a498d6 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -335,6 +335,7 @@ in pam-u2f = handleTest ./pam-u2f.nix {}; pantheon = handleTest ./pantheon.nix {}; paperless = handleTest ./paperless.nix {}; + paperless-ng = handleTest ./paperless-ng.nix {}; pdns-recursor = handleTest ./pdns-recursor.nix {}; peerflix = handleTest ./peerflix.nix {}; pgjwt = handleTest ./pgjwt.nix {}; diff --git a/nixos/tests/paperless-ng.nix b/nixos/tests/paperless-ng.nix new file mode 100644 index 000000000000..d8aafc2a08fd --- /dev/null +++ b/nixos/tests/paperless-ng.nix @@ -0,0 +1,36 @@ +import ./make-test-python.nix ({ lib, ... }: { + name = "paperless-ng"; + meta.maintainers = with lib.maintainers; [ earvstedt Flakebi ]; + + nodes.machine = { pkgs, ... }: { + environment.systemPackages = with pkgs; [ imagemagick jq ]; + services.paperless-ng = { + enable = true; + passwordFile = builtins.toFile "password" "admin"; + }; + virtualisation.memorySize = 1024; + }; + + testScript = '' + machine.wait_for_unit("paperless-ng-consumer.service") + + with subtest("Create test doc"): + machine.succeed( + "convert -size 400x40 xc:white -font 'DejaVu-Sans' -pointsize 20 -fill black " + "-annotate +5+20 'hello world 16-10-2005' /var/lib/paperless/consume/doc.png" + ) + + with subtest("Web interface gets ready"): + machine.wait_for_unit("paperless-ng-web.service") + # Wait until server accepts connections + machine.wait_until_succeeds("curl -fs localhost:28981") + + with subtest("Document is consumed"): + machine.wait_until_succeeds( + "(($(curl -u admin:admin -fs localhost:28981/api/documents/ | jq .count) == 1))" + ) + assert "2005-10-16" in machine.succeed( + "curl -u admin:admin -fs localhost:28981/api/documents/ | jq '.results | .[0] | .created'" + ) + ''; +}) diff --git a/pkgs/applications/office/paperless-ng/default.nix b/pkgs/applications/office/paperless-ng/default.nix new file mode 100644 index 000000000000..66a548545b3e --- /dev/null +++ b/pkgs/applications/office/paperless-ng/default.nix @@ -0,0 +1,197 @@ +{ lib +, fetchurl +, nixosTests +, python3 +, ghostscript +, imagemagick +, jbig2enc +, ocrmypdf +, optipng +, pngquant +, qpdf +, tesseract4 +, unpaper +, liberation_ttf +}: + +let + py = python3.override { + packageOverrides = self: super: { + django = super.django_3; + django-picklefield = super.django-picklefield.overrideAttrs (oldAttrs: { + # Checks do not pass with django 3 + doInstallCheck = false; + }); + # Avoid warning in django-q versions > 1.3.4 + # https://github.com/jonaswinkler/paperless-ng/issues/857 + # https://github.com/Koed00/django-q/issues/526 + django-q = super.django-q.overridePythonAttrs (oldAttrs: rec { + version = "1.3.4"; + src = super.fetchPypi { + inherit (oldAttrs) pname; + inherit version; + sha256 = "Uj1U3PG2YVLBtlj5FPAO07UYo0MqnezUiYc4yo274Q8="; + }; + }); + }; + }; + + path = lib.makeBinPath [ ghostscript imagemagick jbig2enc optipng pngquant qpdf tesseract4 unpaper ]; +in +py.pkgs.pythonPackages.buildPythonApplication rec { + pname = "paperless-ng"; + version = "1.4.5"; + + src = fetchurl { + url = "https://github.com/jonaswinkler/paperless-ng/releases/download/ng-${version}/${pname}-${version}.tar.xz"; + sha256 = "2PJb8j3oimlfiJ3gqjK6uTemzFdtAP2Mlm5RH09bx/E="; + }; + + format = "other"; + + # Make bind address configurable + # Fix tests with Pillow 8.3.1: https://github.com/jonaswinkler/paperless-ng/pull/1183 + prePatch = '' + substituteInPlace gunicorn.conf.py --replace "bind = '0.0.0.0:8000'" "" + substituteInPlace src/paperless_tesseract/parsers.py --replace "return x" "return round(x)" + ''; + + propagatedBuildInputs = with py.pkgs.pythonPackages; [ + aioredis + arrow + asgiref + async-timeout + attrs + autobahn + automat + blessed + certifi + cffi + channels-redis + channels + chardet + click + coloredlogs + concurrent-log-handler + constantly + cryptography + daphne + dateparser + django-cors-headers + django_extensions + django-filter + django-picklefield + django-q + django + djangorestframework + filelock + fuzzywuzzy + gunicorn + h11 + hiredis + httptools + humanfriendly + hyperlink + idna + imap-tools + img2pdf + incremental + inotify-simple + inotifyrecursive + joblib + langdetect + lxml + msgpack + numpy + ocrmypdf + pathvalidate + pdfminer + pikepdf + pillow + pluggy + portalocker + psycopg2 + pyasn1-modules + pyasn1 + pycparser + pyopenssl + python-dateutil + python-dotenv + python-gnupg + python-Levenshtein + python_magic + pytz + pyyaml + redis + regex + reportlab + requests + scikit-learn + scipy + service-identity + six + sortedcontainers + sqlparse + threadpoolctl + tika + tqdm + twisted.extras.tls + txaio + tzlocal + urllib3 + uvicorn + uvloop + watchdog + watchgod + wcwidth + websockets + whitenoise + whoosh + zope_interface + ]; + + doCheck = true; + checkInputs = with py.pkgs.pythonPackages; [ + pytest + pytest-cov + pytest-django + pytest-env + pytest-sugar + pytest-xdist + factory_boy + ]; + + # The tests require: + # - PATH with runtime binaries + # - A temporary HOME directory for gnupg + # - XDG_DATA_DIRS with test-specific fonts + checkPhase = '' + pushd src + PATH="${path}:$PATH" HOME=$(mktemp -d) XDG_DATA_DIRS="${liberation_ttf}/share:$XDG_DATA_DIRS" pytest + popd + ''; + + installPhase = '' + mkdir -p $out/lib + cp -r . $out/lib/paperless-ng + chmod +x $out/lib/paperless-ng/src/manage.py + makeWrapper $out/lib/paperless-ng/src/manage.py $out/bin/paperless-ng \ + --prefix PYTHONPATH : "$PYTHONPATH" \ + --prefix PATH : "${path}" + ''; + + passthru = { + # PYTHONPATH of all dependencies used by the package + pythonPath = python3.pkgs.makePythonPath propagatedBuildInputs; + inherit path; + + tests = { inherit (nixosTests) paperless-ng; }; + }; + + meta = with lib; { + description = "A supercharged version of paperless: scan, index, and archive all of your physical documents"; + homepage = "https://paperless-ng.readthedocs.io/en/latest/"; + license = licenses.gpl3Only; + maintainers = with maintainers; [ earvstedt Flakebi ]; + }; +} diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 9ece72bc0aa9..af68bc489dcb 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -7891,6 +7891,8 @@ with pkgs; paperless = callPackage ../applications/office/paperless { }; + paperless-ng = callPackage ../applications/office/paperless-ng { }; + paperwork = callPackage ../applications/office/paperwork/paperwork-gtk.nix { }; papertrail = callPackage ../tools/text/papertrail { };