at master 24 kB view raw
1{ 2 config, 3 options, 4 pkgs, 5 lib, 6 ... 7}: 8let 9 cfg = config.services.paperless; 10 opt = options.services.paperless; 11 12 defaultUser = "paperless"; 13 defaultFont = "${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf"; 14 15 # Don't start a redis instance if the user sets a custom redis connection 16 enableRedis = !(cfg.settings ? PAPERLESS_REDIS); 17 redisServer = config.services.redis.servers.paperless; 18 19 env = { 20 PAPERLESS_DATA_DIR = cfg.dataDir; 21 PAPERLESS_MEDIA_ROOT = cfg.mediaDir; 22 PAPERLESS_CONSUMPTION_DIR = cfg.consumptionDir; 23 PAPERLESS_THUMBNAIL_FONT_NAME = defaultFont; 24 GRANIAN_HOST = cfg.address; 25 GRANIAN_PORT = toString cfg.port; 26 GRANIAN_WORKERS_KILL_TIMEOUT = "60"; 27 } 28 // lib.optionalAttrs (config.time.timeZone != null) { 29 PAPERLESS_TIME_ZONE = config.time.timeZone; 30 } 31 // lib.optionalAttrs enableRedis { 32 PAPERLESS_REDIS = "unix://${redisServer.unixSocket}"; 33 } 34 // lib.optionalAttrs (cfg.settings.PAPERLESS_ENABLE_NLTK or true) { 35 PAPERLESS_NLTK_DIR = cfg.package.nltkDataDir; 36 } 37 // lib.optionalAttrs (cfg.openMPThreadingWorkaround) { 38 OMP_NUM_THREADS = "1"; 39 } 40 // (lib.mapAttrs ( 41 _: s: 42 if (lib.isAttrs s || lib.isList s) then 43 builtins.toJSON s 44 else if lib.isBool s then 45 lib.boolToString s 46 else 47 toString s 48 ) cfg.settings); 49 50 manage = pkgs.writeShellScriptBin "paperless-manage" '' 51 set -o allexport # Export the following env vars 52 ${lib.toShellVars env} 53 ${lib.optionalString (cfg.environmentFile != null) "source ${cfg.environmentFile}"} 54 55 cd '${cfg.dataDir}' 56 sudo=exec 57 if [[ "$USER" != ${cfg.user} ]]; then 58 ${ 59 if config.security.sudo.enable then 60 "sudo='exec ${config.security.wrapperDir}/sudo -u ${cfg.user} -E'" 61 else 62 ">&2 echo 'Aborting, paperless-manage must be run as user `${cfg.user}`!'; exit 2" 63 } 64 fi 65 $sudo ${lib.getExe cfg.package} "$@" 66 ''; 67 68 defaultServiceConfig = { 69 Slice = "system-paperless.slice"; 70 # Secure the services 71 ReadWritePaths = [ 72 cfg.consumptionDir 73 cfg.dataDir 74 cfg.mediaDir 75 ]; 76 CacheDirectory = "paperless"; 77 CapabilityBoundingSet = ""; 78 # ProtectClock adds DeviceAllow=char-rtc r 79 DeviceAllow = ""; 80 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; 81 LockPersonality = true; 82 MemoryDenyWriteExecute = true; 83 NoNewPrivileges = true; 84 PrivateDevices = true; 85 PrivateMounts = true; 86 PrivateNetwork = true; 87 PrivateTmp = true; 88 PrivateUsers = true; 89 ProtectClock = true; 90 # Breaks if the home dir of the user is in /home 91 # ProtectHome = true; 92 ProtectHostname = true; 93 ProtectSystem = "strict"; 94 ProtectControlGroups = true; 95 ProtectKernelLogs = true; 96 ProtectKernelModules = true; 97 ProtectKernelTunables = true; 98 ProtectProc = "invisible"; 99 ProcSubset = "pid"; 100 RestrictAddressFamilies = [ 101 "AF_UNIX" 102 "AF_INET" 103 "AF_INET6" 104 ]; 105 RestrictNamespaces = true; 106 RestrictRealtime = true; 107 RestrictSUIDSGID = true; 108 SupplementaryGroups = lib.optional enableRedis redisServer.user; 109 SystemCallArchitectures = "native"; 110 SystemCallFilter = [ 111 "@system-service" 112 "~@privileged @setuid @keyring" 113 ]; 114 UMask = "0066"; 115 }; 116in 117{ 118 meta.maintainers = with lib.maintainers; [ 119 leona 120 SuperSandro2000 121 erikarvstedt 122 atemu 123 theuni 124 ]; 125 126 imports = [ 127 (lib.mkRenamedOptionModule [ "services" "paperless-ng" ] [ "services" "paperless" ]) 128 (lib.mkRenamedOptionModule 129 [ "services" "paperless" "extraConfig" ] 130 [ "services" "paperless" "settings" ] 131 ) 132 ]; 133 134 options.services.paperless = { 135 enable = lib.mkOption { 136 type = lib.types.bool; 137 default = false; 138 description = '' 139 Whether to enable Paperless-ngx. 140 141 When started, the Paperless database is automatically created if it doesn't exist 142 and updated if the Paperless package has changed. 143 Both tasks are achieved by running a Django migration. 144 145 A script to manage the Paperless-ngx instance (by wrapping Django's manage.py) is available as `paperless-manage`. 146 ''; 147 }; 148 149 dataDir = lib.mkOption { 150 type = lib.types.str; 151 default = "/var/lib/paperless"; 152 description = "Directory to store the Paperless data."; 153 }; 154 155 mediaDir = lib.mkOption { 156 type = lib.types.str; 157 default = "${cfg.dataDir}/media"; 158 defaultText = lib.literalExpression ''"''${dataDir}/media"''; 159 description = "Directory to store the Paperless documents."; 160 }; 161 162 consumptionDir = lib.mkOption { 163 type = lib.types.str; 164 default = "${cfg.dataDir}/consume"; 165 defaultText = lib.literalExpression ''"''${dataDir}/consume"''; 166 description = "Directory from which new documents are imported."; 167 }; 168 169 consumptionDirIsPublic = lib.mkOption { 170 type = lib.types.bool; 171 default = false; 172 description = "Whether all users can write to the consumption dir."; 173 }; 174 175 passwordFile = lib.mkOption { 176 type = lib.types.nullOr lib.types.path; 177 default = null; 178 example = "/run/keys/paperless-password"; 179 description = '' 180 A file containing the superuser password. 181 182 A superuser is required to access the web interface. 183 If unset, you can create a superuser manually by running `paperless-manage createsuperuser`. 184 185 The default superuser name is `admin`. To change it, set 186 option {option}`settings.PAPERLESS_ADMIN_USER`. 187 WARNING: When changing the superuser name after the initial setup, the old superuser 188 will continue to exist. 189 190 To disable login for the web interface, set the following: 191 `settings.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";`. 192 WARNING: Only use this on a trusted system without internet access to Paperless. 193 ''; 194 }; 195 196 address = lib.mkOption { 197 type = lib.types.str; 198 default = "127.0.0.1"; 199 description = "Web interface address."; 200 }; 201 202 port = lib.mkOption { 203 type = lib.types.port; 204 default = 28981; 205 description = "Web interface port."; 206 }; 207 208 settings = lib.mkOption { 209 type = lib.types.submodule { 210 freeformType = 211 with lib.types; 212 attrsOf ( 213 let 214 typeList = [ 215 bool 216 float 217 int 218 str 219 path 220 package 221 ]; 222 in 223 oneOf ( 224 typeList 225 ++ [ 226 (listOf (oneOf typeList)) 227 (attrsOf (oneOf typeList)) 228 ] 229 ) 230 ); 231 }; 232 default = { }; 233 description = '' 234 Extra paperless config options. 235 236 See [the documentation](https://docs.paperless-ngx.com/configuration/) for available options. 237 238 Note that some settings such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values. 239 Settings declared as lists or attrsets will automatically be serialised into JSON strings for your convenience. 240 ''; 241 example = { 242 PAPERLESS_OCR_LANGUAGE = "deu+eng"; 243 PAPERLESS_CONSUMER_IGNORE_PATTERN = [ 244 ".DS_STORE/*" 245 "desktop.ini" 246 ]; 247 PAPERLESS_OCR_USER_ARGS = { 248 optimize = 1; 249 pdfa_image_compression = "lossless"; 250 }; 251 }; 252 }; 253 254 user = lib.mkOption { 255 type = lib.types.str; 256 default = defaultUser; 257 description = "User under which Paperless runs."; 258 }; 259 260 package = lib.mkPackageOption pkgs "paperless-ngx" { } // { 261 apply = 262 pkg: 263 pkg.override { 264 tesseract5 = pkg.tesseract5.override { 265 # always enable detection modules 266 # tesseract fails to build when eng is not present 267 enableLanguages = 268 if cfg.settings ? PAPERLESS_OCR_LANGUAGE then 269 lib.lists.unique ( 270 [ 271 "equ" 272 "osd" 273 "eng" 274 ] 275 ++ lib.splitString "+" cfg.settings.PAPERLESS_OCR_LANGUAGE 276 ) 277 else 278 null; 279 }; 280 }; 281 }; 282 283 openMPThreadingWorkaround = 284 lib.mkEnableOption '' 285 a workaround for document classifier timeouts. 286 287 Paperless uses OpenBLAS via scikit-learn for document classification. 288 289 The default is to use threading for OpenMP but this would cause the 290 document classifier to spin on one core seemingly indefinitely if there 291 are large amounts of classes per classification; causing it to 292 effectively never complete due to running into timeouts. 293 294 This sets `OMP_NUM_THREADS` to `1` in order to mitigate the issue. See 295 https://github.com/NixOS/nixpkgs/issues/240591 for more information 296 '' 297 // lib.mkOption { default = true; }; 298 299 environmentFile = lib.mkOption { 300 type = lib.types.nullOr lib.types.path; 301 default = null; 302 example = "/run/secrets/paperless"; 303 description = '' 304 Path to a file containing extra paperless config options in the systemd `EnvironmentFile` 305 format. Refer to the [documentation](https://docs.paperless-ngx.com/configuration/) for 306 config options. 307 308 This can be used to pass secrets to paperless without putting them in the Nix store. 309 310 To set a database password, point `environmentFile` at a file containing: 311 ``` 312 PAPERLESS_DBPASS=<pass> 313 ``` 314 ''; 315 }; 316 317 database = { 318 createLocally = lib.mkOption { 319 type = lib.types.bool; 320 default = false; 321 description = '' 322 Configure local PostgreSQL database server for Paperless. 323 ''; 324 }; 325 }; 326 327 configureNginx = lib.mkEnableOption "" // { 328 description = "Whether to configure nginx as a reverse proxy."; 329 }; 330 331 domain = lib.mkOption { 332 type = with lib.types; nullOr str; 333 default = null; 334 example = "paperless.example.com"; 335 description = "Domain under which paperless will be available."; 336 }; 337 338 exporter = { 339 enable = lib.mkEnableOption "regular automatic document exports"; 340 341 directory = lib.mkOption { 342 type = lib.types.str; 343 default = cfg.dataDir + "/export"; 344 defaultText = lib.literalExpression "\${config.services.paperless.dataDir}/export"; 345 description = "Directory to store export."; 346 }; 347 348 onCalendar = lib.mkOption { 349 type = lib.types.nullOr lib.types.str; 350 default = "01:30:00"; 351 description = '' 352 When to run the exporter. See {manpage}`systemd.time(7)`. 353 354 `null` disables the timer; allowing you to run the 355 `paperless-exporter` service through other means. 356 ''; 357 }; 358 359 settings = lib.mkOption { 360 type = with lib.types; attrsOf anything; 361 default = { 362 "no-progress-bar" = true; 363 "no-color" = true; 364 "compare-checksums" = true; 365 "delete" = true; 366 }; 367 description = "Settings to pass to the document exporter as CLI arguments."; 368 }; 369 }; 370 371 configureTika = lib.mkOption { 372 type = lib.types.bool; 373 default = false; 374 description = '' 375 Whether to configure Tika and Gotenberg to process Office and e-mail files with OCR. 376 ''; 377 }; 378 379 manage = lib.mkOption { 380 type = lib.types.package; 381 readOnly = true; 382 description = '' 383 The package derivation for the `paperless-manage` wrapper script. 384 Useful for other modules that need to add this specific script to a service's PATH. 385 ''; 386 }; 387 }; 388 389 config = lib.mkIf cfg.enable ( 390 lib.mkMerge [ 391 { 392 assertions = [ 393 { 394 assertion = cfg.configureNginx -> cfg.domain != null; 395 message = "${opt.configureNginx} requires ${opt.domain} to be configured."; 396 } 397 ]; 398 399 services.paperless.manage = manage; 400 environment.systemPackages = [ manage ]; 401 402 services.nginx = lib.mkIf cfg.configureNginx { 403 enable = true; 404 upstreams.paperless.servers."${cfg.address}:${toString cfg.port}" = { }; 405 virtualHosts.${cfg.domain} = { 406 forceSSL = lib.mkDefault true; 407 locations = { 408 "/".proxyPass = "http://paperless"; 409 "/static/" = { 410 root = config.services.paperless.package; 411 extraConfig = '' 412 rewrite ^/(.*)$ /lib/paperless-ngx/$1 break; 413 ''; 414 }; 415 "/ws/status" = { 416 proxyPass = "http://paperless"; 417 proxyWebsockets = true; 418 }; 419 }; 420 }; 421 }; 422 423 services.redis.servers.paperless.enable = lib.mkIf enableRedis true; 424 425 services.postgresql = lib.mkIf cfg.database.createLocally { 426 enable = true; 427 ensureDatabases = [ "paperless" ]; 428 ensureUsers = [ 429 { 430 name = config.services.paperless.user; 431 ensureDBOwnership = true; 432 } 433 ]; 434 }; 435 436 services.paperless.settings = lib.mkMerge [ 437 (lib.mkIf (cfg.domain != null) { 438 PAPERLESS_URL = "https://${cfg.domain}"; 439 }) 440 (lib.mkIf cfg.database.createLocally { 441 PAPERLESS_DBENGINE = "postgresql"; 442 PAPERLESS_DBHOST = "/run/postgresql"; 443 PAPERLESS_DBNAME = "paperless"; 444 PAPERLESS_DBUSER = "paperless"; 445 }) 446 (lib.mkIf cfg.configureTika { 447 PAPERLESS_GOTENBERG_ENABLED = true; 448 PAPERLESS_TIKA_ENABLED = true; 449 }) 450 ]; 451 452 systemd.slices.system-paperless = { 453 description = "Paperless Document Management System Slice"; 454 documentation = [ "https://docs.paperless-ngx.com" ]; 455 }; 456 457 systemd.tmpfiles.settings."10-paperless" = 458 let 459 defaultRule = { 460 inherit (cfg) user; 461 inherit (config.users.users.${cfg.user}) group; 462 }; 463 in 464 { 465 "${cfg.dataDir}".d = defaultRule; 466 "${cfg.mediaDir}".d = defaultRule; 467 "${cfg.consumptionDir}".d = if cfg.consumptionDirIsPublic then { mode = "777"; } else defaultRule; 468 }; 469 470 systemd.services.paperless-scheduler = { 471 description = "Paperless Celery Beat"; 472 wantedBy = [ "multi-user.target" ]; 473 wants = [ 474 "paperless-consumer.service" 475 "paperless-web.service" 476 "paperless-task-queue.service" 477 ]; 478 serviceConfig = defaultServiceConfig // { 479 User = cfg.user; 480 ExecStart = "${cfg.package}/bin/celery --app paperless beat --loglevel INFO"; 481 Restart = "on-failure"; 482 LoadCredential = lib.optionalString ( 483 cfg.passwordFile != null 484 ) "PAPERLESS_ADMIN_PASSWORD:${cfg.passwordFile}"; 485 PrivateNetwork = cfg.database.createLocally; # defaultServiceConfig enables this by default, needs to be disabled for remote DBs 486 }; 487 environment = env; 488 489 preStart = '' 490 # remove old papaerless-manage symlink 491 # TODO: drop with NixOS 25.11 492 [[ -L '${cfg.dataDir}/paperless-manage' ]] && rm '${cfg.dataDir}/paperless-manage' 493 494 # Auto-migrate on first run or if the package has changed 495 versionFile="${cfg.dataDir}/src-version" 496 version=$(cat "$versionFile" 2>/dev/null || echo 0) 497 498 if [[ $version != ${cfg.package.version} ]]; then 499 ${cfg.package}/bin/paperless-ngx migrate 500 501 # Parse old version string format for backwards compatibility 502 version=$(echo "$version" | grep -ohP '[^-]+$') 503 504 versionLessThan() { 505 target=$1 506 [[ $({ echo "$version"; echo "$target"; } | sort -V | head -1) != "$target" ]] 507 } 508 509 if versionLessThan 1.12.0; then 510 # Reindex documents as mentioned in https://github.com/paperless-ngx/paperless-ngx/releases/tag/v1.12.1 511 echo "Reindexing documents, to allow searching old comments. Required after the 1.12.x upgrade." 512 ${cfg.package}/bin/paperless-ngx document_index reindex 513 fi 514 515 echo ${cfg.package.version} > "$versionFile" 516 fi 517 518 if ${lib.boolToString (cfg.passwordFile != null)} || [[ -n $PAPERLESS_ADMIN_PASSWORD ]]; then 519 export PAPERLESS_ADMIN_USER="''${PAPERLESS_ADMIN_USER:-admin}" 520 if [[ -e $CREDENTIALS_DIRECTORY/PAPERLESS_ADMIN_PASSWORD ]]; then 521 PAPERLESS_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/PAPERLESS_ADMIN_PASSWORD") 522 export PAPERLESS_ADMIN_PASSWORD 523 fi 524 superuserState="$PAPERLESS_ADMIN_USER:$PAPERLESS_ADMIN_PASSWORD" 525 superuserStateFile="${cfg.dataDir}/superuser-state" 526 527 if [[ $(cat "$superuserStateFile" 2>/dev/null) != "$superuserState" ]]; then 528 ${cfg.package}/bin/paperless-ngx manage_superuser 529 echo "$superuserState" > "$superuserStateFile" 530 fi 531 fi 532 ''; 533 requires = lib.optional cfg.database.createLocally "postgresql.target"; 534 after = 535 lib.optional enableRedis "redis-paperless.service" 536 ++ lib.optional cfg.database.createLocally "postgresql.target"; 537 }; 538 539 systemd.services.paperless-task-queue = { 540 description = "Paperless Celery Workers"; 541 requires = lib.optional cfg.database.createLocally "postgresql.target"; 542 after = [ 543 "paperless-scheduler.service" 544 ] 545 ++ lib.optional cfg.database.createLocally "postgresql.target"; 546 serviceConfig = defaultServiceConfig // { 547 User = cfg.user; 548 ExecStart = "${cfg.package}/bin/celery --app paperless worker --loglevel INFO"; 549 Restart = "on-failure"; 550 # The `mbind` syscall is needed for running the classifier. 551 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "mbind" ]; 552 # Needs to talk to mail server for automated import rules 553 PrivateNetwork = false; 554 }; 555 environment = env; 556 }; 557 558 systemd.services.paperless-consumer = { 559 description = "Paperless document consumer"; 560 # Bind to `paperless-scheduler` so that the consumer never runs 561 # during migrations 562 bindsTo = [ "paperless-scheduler.service" ]; 563 requires = lib.optional cfg.database.createLocally "postgresql.target"; 564 after = [ 565 "paperless-scheduler.service" 566 ] 567 ++ lib.optional cfg.database.createLocally "postgresql.target"; 568 serviceConfig = defaultServiceConfig // { 569 User = cfg.user; 570 ExecStart = "${cfg.package}/bin/paperless-ngx document_consumer"; 571 Restart = "on-failure"; 572 PrivateNetwork = cfg.database.createLocally; # defaultServiceConfig enables this by default, needs to be disabled for remote DBs 573 }; 574 environment = env; 575 # Allow the consumer to access the private /tmp directory of the server. 576 # This is required to support consuming files via a local folder. 577 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service"; 578 }; 579 580 systemd.services.paperless-web = { 581 description = "Paperless web server"; 582 # Bind to `paperless-scheduler` so that the web server never runs 583 # during migrations 584 bindsTo = [ "paperless-scheduler.service" ]; 585 requires = lib.optional cfg.database.createLocally "postgresql.target"; 586 after = [ 587 "paperless-scheduler.service" 588 ] 589 ++ lib.optional cfg.database.createLocally "postgresql.target"; 590 # Setup PAPERLESS_SECRET_KEY. 591 # If this environment variable is left unset, paperless-ngx defaults 592 # to a well-known value, which is insecure. 593 script = 594 let 595 secretKeyFile = "${cfg.dataDir}/nixos-paperless-secret-key"; 596 in 597 '' 598 if [[ ! -f '${secretKeyFile}' ]]; then 599 ( 600 umask 0377 601 tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge '${secretKeyFile}' 602 ) 603 fi 604 PAPERLESS_SECRET_KEY="$(cat '${secretKeyFile}')" 605 export PAPERLESS_SECRET_KEY 606 if [[ ! $PAPERLESS_SECRET_KEY ]]; then 607 echo "PAPERLESS_SECRET_KEY is empty, refusing to start." 608 exit 1 609 fi 610 exec ${lib.getExe cfg.package.python.pkgs.granian} --interface asginl --ws "paperless.asgi:application" 611 ''; 612 serviceConfig = defaultServiceConfig // { 613 User = cfg.user; 614 Restart = "on-failure"; 615 616 LimitNOFILE = 65536; 617 # liblapack needs mbind 618 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "mbind" ]; 619 # Needs to serve web page 620 PrivateNetwork = false; 621 }; 622 environment = env // { 623 PYTHONPATH = "${cfg.package.python.pkgs.makePythonPath cfg.package.propagatedBuildInputs}:${cfg.package}/lib/paperless-ngx/src"; 624 }; 625 # Allow the web interface to access the private /tmp directory of the server. 626 # This is required to support uploading files via the web interface. 627 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service"; 628 }; 629 630 users = lib.optionalAttrs (cfg.user == defaultUser) { 631 users.${defaultUser} = { 632 group = defaultUser; 633 uid = config.ids.uids.paperless; 634 home = cfg.dataDir; 635 }; 636 637 groups.${defaultUser} = { 638 gid = config.ids.gids.paperless; 639 }; 640 }; 641 642 services.gotenberg = lib.mkIf cfg.configureTika { 643 enable = true; 644 # https://github.com/paperless-ngx/paperless-ngx/blob/v2.18.2/docker/compose/docker-compose.sqlite-tika.yml#L60-L65 645 chromium.disableJavascript = true; 646 extraArgs = [ "--chromium-allow-list=file:///tmp/.*" ]; 647 }; 648 649 services.tika = lib.mkIf cfg.configureTika { 650 enable = true; 651 enableOcr = true; 652 }; 653 } 654 655 (lib.mkIf cfg.exporter.enable { 656 systemd.tmpfiles.rules = [ 657 "d '${cfg.exporter.directory}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -" 658 ]; 659 660 services.paperless.exporter.settings = options.services.paperless.exporter.settings.default; 661 662 systemd.services.paperless-exporter = { 663 startAt = lib.defaultTo [ ] cfg.exporter.onCalendar; 664 serviceConfig = { 665 User = cfg.user; 666 WorkingDirectory = cfg.dataDir; 667 }; 668 unitConfig = 669 let 670 services = [ 671 "paperless-consumer.service" 672 "paperless-scheduler.service" 673 "paperless-task-queue.service" 674 "paperless-web.service" 675 ]; 676 in 677 { 678 # Shut down the paperless services while the exporter runs 679 Conflicts = services; 680 After = services; 681 # Bring them back up afterwards, regardless of pass/fail 682 OnFailure = services; 683 OnSuccess = services; 684 }; 685 enableStrictShellChecks = true; 686 path = [ manage ]; 687 script = '' 688 paperless-manage document_exporter ${cfg.exporter.directory} ${ 689 lib.cli.toGNUCommandLineShell { } cfg.exporter.settings 690 } 691 ''; 692 }; 693 }) 694 ] 695 ); 696}