parent
21d05e6643
commit
95f2dc650d
@ -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 |
||||
<literal>''${dataDir}/paperless-ng-manage</literal>. |
||||
''; |
||||
}; |
||||
|
||||
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 |
||||
<literal>''${dataDir}/paperless-ng-manage createsuperuser</literal>. |
||||
|
||||
The default superuser name is <literal>admin</literal>. To change it, set |
||||
option <option>extraConfig.PAPERLESS_ADMIN_USER</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: |
||||
<literal>extraConfig.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";</literal>. |
||||
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 <link xlink:href="https://paperless-ng.readthedocs.io/en/latest/configuration.html">the documentation</link> |
||||
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; |
||||
}; |
||||
}; |
||||
}; |
||||
} |
@ -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'" |
||||
) |
||||
''; |
||||
}) |
@ -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 ]; |
||||
}; |
||||
} |
Loading…
Reference in new issue