main
  1{
  2  config,
  3  lib,
  4  pkgs,
  5  ...
  6}:
  7
  8with lib;
  9
 10let
 11  cfg = config.services.wallabag;
 12
 13  defaultUser = "wallabag";
 14  defaultGroup = "wallabag";
 15
 16  # Generate parameters.yml for Symfony
 17  parametersYml = pkgs.writeText "wallabag-parameters.yml" ''
 18    parameters:
 19        database_driver: pdo_pgsql
 20        database_host: ${cfg.database.host}
 21        database_port: ${toString cfg.database.port}
 22        database_name: ${cfg.database.name}
 23        database_user: ${cfg.database.user}
 24        database_password: ~
 25        database_path: null
 26        database_table_prefix: wallabag_
 27        database_socket: null
 28        database_charset: utf8
 29
 30        domain_name: '${cfg.domainName}'
 31        server_name: '${cfg.serverName}'
 32
 33        mailer_dsn: '${cfg.mailerDsn}'
 34        locale: ${cfg.locale}
 35        secret: __WALLABAG_SECRET_PLACEHOLDER__
 36
 37        twofactor_sender: ${cfg.twofactorSender}
 38        fosuser_registration: ${boolToString cfg.registration}
 39        fosuser_confirmation: ${boolToString cfg.registrationConfirmation}
 40
 41        fos_oauth_server_access_token_lifetime: 3600
 42        fos_oauth_server_refresh_token_lifetime: 1209600
 43
 44        from_email: ${cfg.fromEmail}
 45        rss_limit: 50
 46
 47        rabbitmq_host: localhost
 48        rabbitmq_port: 5672
 49        rabbitmq_user: guest
 50        rabbitmq_password: guest
 51        rabbitmq_prefetch_count: 10
 52
 53        redis_scheme: tcp
 54        redis_host: localhost
 55        redis_port: 6379
 56        redis_path: null
 57        redis_password: null
 58
 59        sentry_dsn: null
 60  '';
 61
 62  # Setup script that initializes the data directory and runs migrations
 63  wallabag-setup = pkgs.writeShellScript "wallabag-setup" ''
 64    export PATH="${phpPackage}/bin:$PATH"
 65    set -euo pipefail
 66
 67    DATA_DIR="${cfg.dataDir}"
 68    PACKAGE="${cfg.package}"
 69
 70    # Ensure data directory structure
 71    mkdir -p "$DATA_DIR/app/config"
 72    mkdir -p "$DATA_DIR/var/cache/prod"
 73    mkdir -p "$DATA_DIR/var/logs"
 74    mkdir -p "$DATA_DIR/var/sessions"
 75    mkdir -p "$DATA_DIR/web/uploads"
 76    mkdir -p "$DATA_DIR/data/db"
 77    mkdir -p "$DATA_DIR/data/assets"
 78
 79    # Symlink all package top-level entries into data dir, skipping
 80    # directories we manage (app, var, web/uploads)
 81    for entry in "$PACKAGE"/*; do
 82      name=$(basename "$entry")
 83      case "$name" in app|var|web) continue ;; esac
 84      ln -sfn "$entry" "$DATA_DIR/$name"
 85    done
 86
 87    # Symlink app-level entries except config (which we manage)
 88    for entry in "$PACKAGE"/app/*; do
 89      name=$(basename "$entry")
 90      [ "$name" = "config" ] && continue
 91      ln -sfn "$entry" "$DATA_DIR/app/$name"
 92    done
 93
 94    # Symlink config files except parameters.yml (which we generate)
 95    for f in "$PACKAGE"/app/config/*; do
 96      fname=$(basename "$f")
 97      [ "$fname" = "parameters.yml" ] && continue
 98      [ "$fname" = "parameters.yml.dist" ] && continue
 99      ln -sfn "$f" "$DATA_DIR/app/config/$fname"
100    done
101
102    # Copy parameters.yml (install with write permission since we patch it)
103    install -m 0640 ${parametersYml} "$DATA_DIR/app/config/parameters.yml"
104
105    # Read database password from file if provided
106    DB_PASS=""
107    if [ -n "${toString cfg.database.passwordFile}" ] && [ -f "${toString cfg.database.passwordFile}" ]; then
108      DB_PASS=$(cat "${toString cfg.database.passwordFile}")
109    fi
110
111    # Read secret from file
112    WALLABAG_SECRET_VALUE=""
113    if [ -f "${toString cfg.secretKeyFile}" ]; then
114      WALLABAG_SECRET_VALUE=$(cat "${toString cfg.secretKeyFile}")
115    else
116      echo "ERROR: Secret key file not found: ${toString cfg.secretKeyFile}"
117      exit 1
118    fi
119
120    # Patch password into parameters.yml if using password auth
121    if [ -n "$DB_PASS" ]; then
122      ${pkgs.gnused}/bin/sed -i "s|database_password: ~|database_password: '$DB_PASS'|" \
123        "$DATA_DIR/app/config/parameters.yml"
124    fi
125
126    export WALLABAG_DATA="$DATA_DIR"
127    # Patch secret into parameters.yml
128    ${pkgs.gnused}/bin/sed -i "s|__WALLABAG_SECRET_PLACEHOLDER__|$WALLABAG_SECRET_VALUE|" \
129      "$DATA_DIR/app/config/parameters.yml"
130
131    # Clear cache on every start (required after upgrades)
132    rm -rf "$DATA_DIR/var/cache/prod/"*
133
134    # Run database migrations
135    ${cfg.package}/bin/console doctrine:migrations:migrate --no-interaction --env=prod || true
136
137    # Install craue settings if first run
138    ${cfg.package}/bin/console wallabag:install --env=prod --no-interaction 2>/dev/null || true
139  '';
140
141  phpPackage = pkgs.php83.withExtensions (
142    { enabled, all }:
143    (builtins.filter (e: e.pname or "" != "php-opcache") enabled)
144    ++ [
145      all.pdo_pgsql
146      all.pgsql
147      all.intl
148      all.gd
149      all.tidy
150    ]
151  );
152
153in
154{
155  options.services.wallabag = {
156    enable = mkEnableOption "wallabag read-it-later service";
157
158    package = mkPackageOption pkgs "wallabag" { };
159
160    user = mkOption {
161      type = types.str;
162      default = defaultUser;
163      description = "User to run wallabag as";
164    };
165
166    group = mkOption {
167      type = types.str;
168      default = defaultGroup;
169      description = "Group to run wallabag as";
170    };
171
172    dataDir = mkOption {
173      type = types.path;
174      default = "/var/lib/wallabag";
175      description = "Data directory for wallabag";
176    };
177
178    domainName = mkOption {
179      type = types.str;
180      example = "https://wallabag.example.com";
181      description = "The full URL of your wallabag instance";
182    };
183
184    serverName = mkOption {
185      type = types.str;
186      default = "wallabag";
187      description = "Display name for the wallabag instance";
188    };
189
190    locale = mkOption {
191      type = types.str;
192      default = "en";
193      description = "Default locale";
194    };
195
196    mailerDsn = mkOption {
197      type = types.str;
198      default = "smtp://127.0.0.1";
199      description = "Mailer DSN for sending emails";
200    };
201
202    fromEmail = mkOption {
203      type = types.str;
204      default = "no-reply@wallabag.org";
205      description = "From email address";
206    };
207
208    twofactorSender = mkOption {
209      type = types.str;
210      default = "no-reply@wallabag.org";
211      description = "Two-factor authentication sender email";
212    };
213
214    registration = mkOption {
215      type = types.bool;
216      default = false;
217      description = "Allow user registration";
218    };
219
220    registrationConfirmation = mkOption {
221      type = types.bool;
222      default = false;
223      description = "Require email confirmation for registration";
224    };
225
226    secretKeyFile = mkOption {
227      type = types.path;
228      description = "Path to file containing the Symfony secret key";
229    };
230
231    port = mkOption {
232      type = types.port;
233      default = 8084;
234      description = "Port for the wallabag PHP-FPM FastCGI server (used with a reverse proxy)";
235    };
236
237    database = {
238      createLocally = mkOption {
239        type = types.bool;
240        default = true;
241        description = "Whether to create the PostgreSQL database locally";
242      };
243
244      host = mkOption {
245        type = types.str;
246        default = "/run/postgresql";
247        description = "Database host (use socket path for local peer auth)";
248      };
249
250      port = mkOption {
251        type = types.port;
252        default = 5432;
253        description = "Database port";
254      };
255
256      name = mkOption {
257        type = types.str;
258        default = "wallabag";
259        description = "Database name";
260      };
261
262      user = mkOption {
263        type = types.str;
264        default = "wallabag";
265        description = "Database user";
266      };
267
268      passwordFile = mkOption {
269        type = types.nullOr types.path;
270        default = null;
271        description = "Path to file containing the database password (null for peer auth)";
272      };
273    };
274
275    poolConfig = mkOption {
276      type = types.attrsOf (
277        types.oneOf [
278          types.str
279          types.int
280          types.bool
281        ]
282      );
283      default = { };
284      description = "Additional PHP-FPM pool configuration";
285    };
286  };
287
288  config = mkIf cfg.enable {
289    services.postgresql = mkIf cfg.database.createLocally {
290      enable = true;
291      ensureDatabases = [ cfg.database.name ];
292      ensureUsers = [
293        {
294          name = cfg.database.user;
295          ensureDBOwnership = true;
296        }
297      ];
298    };
299
300    # PHP-FPM pool for wallabag
301    services.phpfpm.pools.wallabag = {
302      user = cfg.user;
303      group = cfg.group;
304      phpPackage = phpPackage;
305      phpOptions = ''
306        log_errors = on
307        post_max_size = 20M
308        upload_max_filesize = 20M
309        memory_limit = 256M
310      '';
311      phpEnv = {
312        WALLABAG_DATA = cfg.dataDir;
313      };
314      settings = {
315        "listen.mode" = mkDefault "0660";
316        "listen.owner" = mkDefault cfg.user;
317        "listen.group" = mkDefault config.services.caddy.group;
318        "pm" = mkDefault "dynamic";
319        "pm.max_children" = mkDefault 10;
320        "pm.start_servers" = mkDefault 2;
321        "pm.min_spare_servers" = mkDefault 1;
322        "pm.max_spare_servers" = mkDefault 4;
323        "pm.max_requests" = mkDefault 500;
324      }
325      // cfg.poolConfig;
326    };
327
328    # Caddy as lightweight reverse proxy (PHP-FPM → HTTP)
329    services.caddy = {
330      enable = true;
331      virtualHosts.":${toString cfg.port}" = {
332        extraConfig = ''
333          root * ${cfg.package}/web
334          php_fastcgi unix/${config.services.phpfpm.pools.wallabag.socket} {
335            split .php
336            index app.php
337            env WALLABAG_DATA ${cfg.dataDir}
338          }
339          file_server
340        '';
341      };
342    };
343
344    # Setup service that runs before PHP-FPM
345    systemd.services.wallabag-setup = {
346      description = "Wallabag setup (migrations, cache clear)";
347      after = [ "postgresql.service" ];
348      requires = mkIf cfg.database.createLocally [ "postgresql.service" ];
349      requiredBy = [ "phpfpm-wallabag.service" ];
350      before = [ "phpfpm-wallabag.service" ];
351      serviceConfig = {
352        Type = "oneshot";
353        ExecStart = wallabag-setup;
354        User = cfg.user;
355        Group = cfg.group;
356        RemainAfterExit = true;
357        ReadWritePaths = [ cfg.dataDir ];
358        Environment = "PATH=${phpPackage}/bin:${pkgs.coreutils}/bin";
359        WorkingDirectory = cfg.package;
360      };
361      restartTriggers = [
362        cfg.package
363        parametersYml
364      ];
365    };
366
367    # Create data directories
368    systemd.tmpfiles.settings."10-wallabag" =
369      let
370        defaultConfig = {
371          user = cfg.user;
372          group = cfg.group;
373          mode = "0750";
374        };
375      in
376      {
377        "${cfg.dataDir}".d = defaultConfig;
378        "${cfg.dataDir}/app".d = defaultConfig;
379        "${cfg.dataDir}/app/config".d = defaultConfig;
380        "${cfg.dataDir}/var".d = defaultConfig;
381        "${cfg.dataDir}/var/cache".d = defaultConfig;
382        "${cfg.dataDir}/var/logs".d = defaultConfig;
383        "${cfg.dataDir}/var/sessions".d = defaultConfig;
384        "${cfg.dataDir}/web".d = defaultConfig;
385        "${cfg.dataDir}/web/uploads".d = defaultConfig;
386        "${cfg.dataDir}/data".d = defaultConfig;
387        "${cfg.dataDir}/data/db".d = defaultConfig;
388        "${cfg.dataDir}/data/assets".d = defaultConfig;
389      };
390
391    users.users = mkIf (cfg.user == defaultUser) {
392      wallabag = {
393        inherit (cfg) group;
394        isSystemUser = true;
395        home = cfg.dataDir;
396      };
397    };
398
399    users.groups = mkIf (cfg.group == defaultGroup) {
400      wallabag = { };
401    };
402
403    # Open firewall port
404    networking.firewall.allowedTCPPorts = [ cfg.port ];
405  };
406}