CCE

Wallabag

Contents

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

Deploying Wallabag to The Wobserver

I tried to make the module configurable so that others could use it, but your mileage may vary. I have no intention of upstreaming this to nixpkgs because I don't really understand PHPFPM well enough to feel comfy being responsible for it, but it's good enough for the CCE.

nix:tangle ~/nix/nixos/wallabag.nix
{ config, pkgs, ... }:

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

  # Wallabag reads its secret from $STATE_DIRECTORY/secret.txt.
  # sops-nix will place the decrypted file there.
  sops.secrets.wallabag_secret = {
    owner = "wallabag";
    path = "/var/lib/wallabag/secret.txt";
  };

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

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

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

Make sure to write your cookie secret to /var/lib/wallabag/secret.txt!

Another Attempt for a Wallabag NixOS module

References

The Wallabag module I was using was kind of fiddly and didn't work after I upgraded to 24.05, I stumbled across this one that has some improvements:

  • it runs a small patch on Wallabag's source to be able to keep the source directory immutable and storing ephemeral data in the systemd managed /var/lib, /var/cache, etc paths.

  • it loads some secrets from a config file on the file system rather than embedding the secret in to the nix store

  • i've gone and added options to the module so that parameters can be overwritten, some basic settings can be provided, etc. See below for usage example.

  • i stripped out some DRY utilities that the upstream author had created to manage all their PHPFPM applications. In theory i could port e.g. tt-rss and my other phpfpm applications to use these helpers, but i'd rather not 😊

nix:tangle ~/nix/nixos/wallabag-mod.nix
{ config, lib, pkgs, ... }:
let
  wallabag = pkgs.wallabag.overrideAttrs (attrs: {
    patches = builtins.filter (patch: builtins.baseNameOf patch != "wallabag-data.patch") attrs.patches ++ [
      # Out of the box, Wallabag wants to write to various subdirectories of the project directory.
      # Let’s replace references to such paths with designated systemd locations
      # so that the project source can remain immutable.
      ../files/wallabag-data.patch
    ];
  });

  # Based on https://github.com/wallabag/wallabag/blob/2.6.6/app/config/parameters.yml.dist
  settings = {
    database_driver = "${cfg.database_type}";
    database_host = null;
    database_port = 5432;
    database_name = "wallabag";
    database_user = "wallabag";
    database_password = null;
    database_path = null;
    database_table_prefix = "wallabag_";
    database_socket = "/run/postgresql/.s.PGSQL.${toString config.services.postgresql.port}";
    database_charset = "utf8";

    domain_name = "";
    server_name = "Wallabag";

    # Needs an explicit command since Symfony version used by Wallabag does not yet support the `native` transport
    # and the `sendmail` transport does not respect `sendmail_path` configured in `php.ini`.
    mailer_dsn = "sendmail://default?command=/run/wrappers/bin/sendmail%%20-t%%20-i";

    locale = "en";

    # A secret key that's used to generate certain security-related tokens.
    "env(SECRET_FILE)" = "%env(string:STATE_DIRECTORY)%/secret.txt";
    secret = "%env(file:resolve:SECRET_FILE)%";

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

    # fosuser stuff
    fosuser_registration = false;
    fosuser_confirmation = false;

    # 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;

    from_email = "";

    # RabbitMQ 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;

    # Redis processing
    rabbitmq_host = "";
    rabbitmq_port = 0;
    rabbitmq_user = "";
    rabbitmq_password = "";
    rabbitmq_prefetch_count = 0;

    # sentry logging
    sentry_dsn = null;
  } // cfg.parameters;

  php = cfg.php.package.withExtensions ({ enabled, all }: enabled ++ (with all; [
    imagick
    tidy
  ]));

  commonServiceConfig = {
    CacheDirectory = "wallabag";
    # Stores sessions.
    CacheDirectoryMode = "700";
    ConfigurationDirectory = "wallabag";
    LogsDirectory = "wallabag";
    StateDirectory = "wallabag";
    # Stores site-credentials-secret-key.txt.
    StateDirectoryMode = "700";
  };

  cfg = config.services.wallabag;
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;
    };

    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 {
    environment.etc."wallabag/parameters.yml" = {
      source = pkgs.writeTextFile {
        name = "wallabag-config";
        text = builtins.toJSON {
          parameters = settings;
        };
      };
    };

    services.nginx = lib.mkIf cfg.virtualHost.enable {
      enable = true;

      virtualHosts = {
        "bag.fontkeming.fail" = {
          root = "${wallabag}/web";

          extraConfig = ''
          add_header X-Frame-Options SAMEORIGIN;
          add_header X-Content-Type-Options nosniff;
          add_header X-XSS-Protection "1; mode=block";
        '';

          locations."/" = {
            extraConfig = ''
            try_files $uri /app.php$is_args$args;
          '';
          };

          locations."/assets".root = "${wallabag}/app/web";

          locations."~ ^/app\\.php(/|$)" = {
            extraConfig = ''
            fastcgi_pass unix:${config.services.phpfpm.pools.wallabag.socket};
            include ${config.services.nginx.package}/conf/fastcgi.conf;
            fastcgi_param PATH_INFO $fastcgi_path_info;
            fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
            fastcgi_param SCRIPT_FILENAME ${wallabag}/web/$fastcgi_script_name;
            fastcgi_param DOCUMENT_ROOT ${wallabag}/web;
            fastcgi_read_timeout 120;
            internal;
          '';
          };

          locations."~ /(?!app)\\.php$" = {
            extraConfig = ''
            return 404;
          '';
          };
        };
      };
    };

    services.phpfpm.pools.wallabag = {
      user = config.users.users.wallabag.name;
      phpPackage = php;
      settings = {
        "catch_workers_output" = true;

        "listen.owner" = config.services.nginx.user;
        "listen.group" = "root";
        "pm" = "dynamic";
        "pm.max_children" = 5;
        "pm.start_servers" = 2;
        "pm.min_spare_servers" = 1;
        "pm.max_spare_servers" = 3;
        "php_admin_value[error_log]" = "/var/log/wallabag/error.log";
        "php_admin_value[access_log]" = "/var/log/wallabag/access.log";
        "php_admin_flag[log_errors]" = true;
      };
      phpOptions = ''
      ; Set up $_ENV superglobal.
      ; http://php.net/request-order
      variables_order = "EGPCS"
      # Wallabag will crash on start-up.
      # https://github.com/wallabag/wallabag/issues/6042
      # error_reporting = E_ALL & ~E_USER_DEPRECATED & ~E_DEPRECATED
      memory_limit = 256M
    '';
      settings = {
        # Accept settings from the systemd service.
        clear_env = false;
      };
    };

    users.users.wallabag = {
      isSystemUser = true;
      group = "wallabag";
    };
    users.groups.wallabag = {};
    services.redis.servers.wallabag = {
      enable = true;
      user = "wallabag";
    };
    services.rabbitmq.enable = false;

    systemd.services.phpfpm-wallabag.serviceConfig = commonServiceConfig;

    systemd.services.wallabag-install = {
      description = "Wallabag install service";
      wantedBy = [ "multi-user.target" ];
      before = [ "phpfpm-wallabag.service" ];
      after = [ "postgresql.service" ];
      path = with pkgs; [ coreutils php phpPackages.composer ];

      serviceConfig = {
        User = "wallabag";
        Type = "oneshot";
      } // commonServiceConfig;

      script = ''
      if [ ! -f "$STATE_DIRECTORY/installed" ]; then
        php ${wallabag}/bin/console --env=prod wallabag:install
        touch "$STATE_DIRECTORY/installed"
      else
        php ${wallabag}/bin/console --env=prod doctrine:migrations:migrate --no-interaction
      fi
      php ${wallabag}/bin/console --env=prod cache:clear
    '';
    };
  };
}
diff:tangle ~/nix/files/wallabag-data.patch:comments none
diff --git a/app/AppKernel.php b/app/AppKernel.php
index 61b734e06..0902c20fc 100644
--- a/app/AppKernel.php
+++ b/app/AppKernel.php
@@ -64,12 +64,12 @@ class AppKernel extends Kernel
 
     public function getCacheDir()
     {
-        return dirname(__DIR__) . '/var/cache/' . $this->getEnvironment();
+        return getenv('CACHE_DIRECTORY') . '/' . $this->getEnvironment();
     }
 
     public function getLogDir()
     {
-        return dirname(__DIR__) . '/var/logs';
+        return getenv('LOGS_DIRECTORY');
     }
 
     public function registerContainerConfiguration(LoaderInterface $loader)
diff --git a/app/config/config.yml b/app/config/config.yml
index 7f0a4ca6c..77b5175c8 100644
--- a/app/config/config.yml
+++ b/app/config/config.yml
@@ -1,5 +1,7 @@
 imports:
-    - { resource: parameters.yml }
+    # Unfortunately, we cannot use %env(string:CONFIGURATION_DIRECTORY)%. Hardcoding the path for simplicity.
+    # https://symfony.com/doc/current/service_container/import.html#importing-configuration-with-imports
+    - { resource: '/etc/wallabag/parameters.yml' }
     - { resource: security.yml }
     - { resource: services.yml }
     - { resource: wallabag.yml }
@@ -28,7 +30,7 @@ framework:
     session:
         # handler_id set to null will use default session handler from php.ini
         handler_id: session.handler.native_file
-        save_path: "%kernel.project_dir%/var/sessions/%kernel.environment%"
+        save_path: "%env(string:CACHE_DIRECTORY)%/sessions/%kernel.environment%"
         cookie_secure: auto
     fragments: ~
     http_method_override: true
diff --git a/app/config/wallabag.yml b/app/config/wallabag.yml
index bd57d6377..8e1cd0970 100644
--- a/app/config/wallabag.yml
+++ b/app/config/wallabag.yml
@@ -35,7 +35,7 @@ wallabag_core:
     fetching_error_message: |
         wallabag can't retrieve contents for this article. Please <a href="https://doc.wallabag.org/en/user/errors_during_fetching.html#how-can-i-help-to-fix-that">troubleshoot this issue</a>.
     api_limit_mass_actions: 10
-    encryption_key_path: "%kernel.project_dir%/data/site-credentials-secret-key.txt"
+    encryption_key_path: "%env(string:STATE_DIRECTORY)%/site-credentials-secret-key.txt"
     default_internal_settings:
         -
             name: share_public