The Complete Computing Environment

Wallabag

LifeTechEmacsTopicsArcology

wallabag is a self hostable application for saving web pages: Save and classify articles. Read them later. Freely.

Configurable NixOS Module for Wallabag

This module is based on dwarfmaster/home-nix's wallabag module. It's a bit verbose and difficult to wrap the head around, but basically what is happening is that it is generating a parameters.yml file to configure wallabag, synthesize some paths merging that configuration with the Wallabag source code. This built environment is used to create a PHP application under your dataDir and wiring up a php-fpm pool to that directory; finally there is a systemd script that ensures that migrations are run, assets are cached, etc.

I've extended this to be somewhat configurable. I would like to figure out how to make this work without having that assertion, or whether that is even necessary.

{
  config,
  lib,
  pkgs,
  ...
}:
let
  pool = config.services.phpfpm.pools.wallabag;
  cfg = config.services.wallabag;
  wallabag = cfg.package;
  parameters = {
    # these can be rewritten to read from ENV with
    # %env.database_driver% type of stuff, good for turning these in
    # to nixos options
    database_driver = cfg.database_type;
    database_host = "";
    database_port = config.services.postgresql.port;
    database_name = "wallabag";
    database_user = "wallabag";
    database_password = "";
    database_path = "";
    database_table_prefix = "wallabag_";
    database_socket = "/run/postgresql/.s.PGSQL.${toString config.services.postgresql.port}";
    database_charset = "utf8";

    domain_name = "https://${cfg.domain}";

    mailer_dsn = "null://";
    from_email = "";

    locale = "en_US";
    server_name = "Wallabag";
    secret = "";

    # A secret key that's used to generate certain security-related tokens

    # two factor stuff
    twofactor_auth = false;
    twofactor_sender = "";

    # Disable user registration
    # See https://github.com/wallabag/wallabag/issues/1873
    fosuser_registration = false;
    fosuser_confirmation = true;

    # how long the access token should live in seconds for the API
    fos_oauth_server_access_token_lifetime = 3600;
    # how long the refresh token should life in seconds for the API
    fos_oauth_server_refresh_token_lifetime = 1209600;

    rss_limit = 50;

    # RabbitMQ processing
    rabbitmq_host = "localhost";
    rabbitmq_port = config.services.rabbitmq.port;
    rabbitmq_user = "guest";
    rabbitmq_password = "guest";
    rabbitmq_prefetch_count = 10;

    # Redis processing
    redis_scheme = "unix";
    redis_host = ""; # Ignored for unix scheme
    redis_port = 0; # Ignored for unix scheme
    redis_path = config.services.redis.servers.wallabag.unixSocket;
    redis_password = null;

    # sentry logging
    sentry_dsn = "";
  } // cfg.parameters;
  parameters-json = pkgs.writeTextFile {
    name = "parameters.json";
    text = builtins.toJSON {inherit parameters;};
  };
  yaml_parameters =  pkgs.runCommand
    "parameters.yml" {preferLocalBuild = true;} ''
    mkdir -p $out/app/config
    ${pkgs.remarshal}/bin/json2yaml -i ${parameters-json} -o $out/app/config/parameters.yml
  '';
  appDir = pkgs.buildEnv {
    name = "wallabag-app-dir";
    ignoreCollisions = true;
    checkCollisionContents = false;
    paths = [ yaml_parameters "${wallabag}" ];
    pathsToLink = [
      "/app" "/src" "/translations"
    ];
  };
  dataDir = cfg.dataDir;
  php = cfg.php.package;
  exts = cfg.php.extensions.package;
  phpPkgs = cfg.php.packages.package;

  # See there for available commands:
  # https://doc.wallabag.org/en/admin/console_commands.html
  # A user can be made admin with the fos:user:promote --super <user> command
  console = pkgs.writeShellScriptBin "wallabag-console" ''
    export WALLABAG_DATA="${dataDir}"
    cd "${dataDir}"
    ${php}/bin/php ${wallabag}/bin/console --env=prod $@
  '';
in
{
  options.services.wallabag = with lib; {
    enable = mkEnableOption (mdDoc "Wallabag read-it-later service");

    package = mkOption {
      type = types.package;
      default = pkgs.wallabag;
    };

    php.package = mkOption {
      type = types.package;
      default = pkgs.php;
    };

    php.extensions.package = mkOption {
      type = types.attrsOf types.package;
      default = pkgs.php.extensions;
    };

    php.packages.package = mkOption {
      type = types.attrsOf types.package;
      default = pkgs.php.packages;
    };

    dataDir = mkOption {
      type = types.path;
      default = "/var/lib/wallabag";
      description = mdDoc ''
        Location which Wallabag will install itself and place cache files, etc within.
      '';
    };

    parameters = mkOption {
      type = types.attrsOf types.str;
      default = {};
      description = mdDoc "Parameters to override from the default. See <https://doc.wallabag.org/en/admin/parameters.html> for values.";
    };

    database_type = mkOption {
      type = types.enum [
        "pdo_sqlite3"
        "pdo_pgsql"
      ];
      default = if config.services.postgresql.enable
                then "pdo_pgsql"
                else "pdo_sqlite3";
      defaultText = ''
        if config.services.postgresql.enable
        then "pdo_pgsql"
        else "pdo_sqlite3"
      '';
      description = mdDoc ''
        The database engine name. Can be pdo_sqlite3 or pdo_pgsql.
      '';
    };

    domain = mkOption {
      type = types.str;
      description = "Bare domain name for Wallabag";
    };

    virtualHost.enable = mkEnableOption (mdDoc "Define nginx virtualhost for Wallabag");
  };
  config = 
    lib.mkIf cfg.enable
      {
        # Wallabag config yml files needs to be recreated at each update, and
        # var/cache needs to be cleared between restart
        # assertions = [
        #   {
        #     assertion = wallabag.version == "2.6.6";
        #     message = "Wallabag update to ${wallabag.version} needs manual intervention";
        #   }
        # ];

        # Install console manager
        environment.systemPackages = [console];

        # Inspired by https://doc.wallabag.org/ens/admin/installation/virtualhosts.html
        services.nginx.virtualHosts."${cfg.domain}" = lib.mkIf cfg.virtualHost.enable {
          # forceSSL = true;
          # enableACME = true;

          root = "${wallabag}/web";
          locations."/" = {
            priority = 10;
            tryFiles = "$uri /app.php$is_args$args";
          };
          locations."~ ^/app\\.php(/|$)" = {
            priority = 100;
            fastcgiParams = {
              SCRIPT_FILENAME = "$realpath_root$fastcgi_script_name";
              DOCUMENT_ROOT = "$realpath_root";
            };
            extraConfig = ''
              fastcgi_pass unix:${pool.socket};
              include ${config.services.nginx.package}/conf/fastcgi_params;
              include ${config.services.nginx.package}/conf/fastcgi.conf;
              internal;
            '';
          };
          locations."~ \\.php$" = {
            priority = 1000;
            return = "404";
          };
        };

        # PHP
        services.redis.servers.wallabag = {
          enable = true;
          user = "wallabag";
        };
        services.phpfpm.pools.wallabag = {
          user = "wallabag";
          group = "wallabag";
          phpPackage = php;
          phpEnv = {
            WALLABAG_DATA = dataDir;
            PATH = lib.makeBinPath [php];
          };
          settings = {
            "listen.owner" = config.services.nginx.user;
            "pm" = "dynamic";
            "pm.max_children" = 32;
            "pm.max_requests" = 500;
            "pm.start_servers" = 1;
            "pm.min_spare_servers" = 1;
            "pm.max_spare_servers" = 5;
            "php_admin_value[error_log]" = "stderr";
            "php_admin_flag[log_errors]" = true;
            "catch_workers_output" = true;
          };
          phpOptions = ''
            extension=${exts.pdo}/lib/php/extensions/pdo.so
            extension=${exts.pdo_pgsql}/lib/php/extensions/pdo_pgsql.so
            extension=${exts.session}/lib/php/extensions/session.so
            extension=${exts.ctype}/lib/php/extensions/ctype.so
            extension=${exts.dom}/lib/php/extensions/dom.so
            extension=${exts.simplexml}/lib/php/extensions/simplexml.so
            extension=${exts.gd}/lib/php/extensions/gd.so
            extension=${exts.mbstring}/lib/php/extensions/mbstring.so
            extension=${exts.xml}/lib/php/extensions/xml.so
            extension=${exts.tidy}/lib/php/extensions/tidy.so
            extension=${exts.iconv}/lib/php/extensions/iconv.so
            extension=${exts.curl}/lib/php/extensions/curl.so
            extension=${exts.gettext}/lib/php/extensions/gettext.so
            extension=${exts.tokenizer}/lib/php/extensions/tokenizer.so
            extension=${exts.bcmath}/lib/php/extensions/bcmath.so
            extension=${exts.intl}/lib/php/extensions/intl.so
            extension=${exts.opcache}/lib/php/extensions/opcache.so
          '';
        };

        # PostgreSQL Database
        services.postgresql = lib.mkIf (cfg.database_type == "pdo_pgsql"){
          ensureDatabases = ["wallabag"];
          # Wallabag does not support passwordless login into database,
          # so the database password for the user must be manually set
          ensureUsers = [
            {
              name = "wallabag";
              ensurePermissions."DATABASE wallabag" = "ALL PRIVILEGES";
            }
          ];
        };

        # Data directory
        systemd.tmpfiles.rules = let
          user = "wallabag";
        in ["d ${dataDir} 0700 ${user} ${user} - -"];
        systemd.services."wallabag-setup" = {
          description = "Wallabag install service";
          wantedBy = ["multi-user.target"];
          before = ["phpfpm-wallabag.service"];
          requiredBy = ["phpfpm-wallabag.service"];
          after = ["postgresql.service"];
          path = [pkgs.coreutils php phpPkgs.composer];

          serviceConfig = {
            User = "wallabag";
            Group = "wallabag";
            Type = "oneshot";
            RemainAfterExit = "yes";
            PermissionsStartOnly = true;
            Environment = "WALLABAG_DATA=${dataDir}";
          };

          script = ''
            echo "Setting up wallabag files in ${dataDir} ..."
            cd "${dataDir}"

            rm -rf var/cache/*
            rm -f app
            ln -sf ${appDir}/app app
            rm -f src
            ln -sf ${appDir}/src src
            rm -f translations
            ln -sf ${appDir}/translations translations

            ln -sf ${wallabag}/composer.{json,lock} .

            if [ ! -f installed ]; then
              echo "Installing wallabag"
              php ${wallabag}/bin/console --env=prod wallabag:install --no-interaction
              touch installed
            else
              php ${wallabag}/bin/console --env=prod doctrine:migrations:migrate --no-interaction
            fi
            php ${wallabag}/bin/console --env=prod cache:clear
          '';
        };

        # Misc settings
        services.rabbitmq.enable = false;
        users.users.wallabag = {
          isSystemUser = true;
          group = "wallabag";
        };
        users.groups.wallabag = {};
      };
}

Using this module in The Wobserver configuration

I tried to make that module configurable so that others could use it, but your mileage may vary. I have no intention of upstreaming this to nixpkgs because I think it needs quite some work, but it's good enough for the CCE.

{ pkgs, ... }:

{
  imports = [ ./wallabag-mod.nix ./wallabag-secrets.nix ];

  services.wallabag = {
    enable = true;
    dataDir = "/srv/wallabag";
    domain = "bag.fontkeming.fail";
    virtualHost.enable = true;

    parameters = {
      server_name = "rrix's Back-log Black-hole";
      twofactor_sender = "wallabag@fontkeming.fail";
      locale = "en_US";
      from_email = "wallabag@fontkeming.fail";
    };
  };

  services.nginx.virtualHosts."bag.fontkeming.fail".extraConfig = ''
    error_log /var/log/nginx/wallabag_error.log;
    access_log /var/log/nginx/wallabag_access.log;
  '';
}

Make sure to define services.wallabag.parameters.secret too. I won't show you mine unless the following NOEXPORT heading exports. ;)