1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.pixelfed;
7 user = cfg.user;
8 group = cfg.group;
9 pixelfed = cfg.package.override { inherit (cfg) dataDir runtimeDir; };
10 # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L185-L190
11 extraPrograms = with pkgs; [ jpegoptim optipng pngquant gifsicle ffmpeg ];
12 # Ensure PHP extensions: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L135-L147
13 phpPackage = cfg.phpPackage.buildEnv {
14 extensions = { enabled, all }:
15 enabled
16 ++ (with all; [ bcmath ctype curl mbstring gd intl zip redis imagick ]);
17 };
18 configFile =
19 pkgs.writeText "pixelfed-env" (lib.generators.toKeyValue { } cfg.settings);
20 # Management script
21 pixelfed-manage = pkgs.writeShellScriptBin "pixelfed-manage" ''
22 cd ${pixelfed}
23 sudo=exec
24 if [[ "$USER" != ${user} ]]; then
25 sudo='exec /run/wrappers/bin/sudo -u ${user}'
26 fi
27 $sudo ${phpPackage}/bin/php artisan "$@"
28 '';
29 dbSocket = {
30 "pgsql" = "/run/postgresql";
31 "mysql" = "/run/mysqld/mysqld.sock";
32 }.${cfg.database.type};
33 dbService = {
34 "pgsql" = "postgresql.service";
35 "mysql" = "mysql.service";
36 }.${cfg.database.type};
37 redisService = "redis-pixelfed.service";
38in {
39 options.services = {
40 pixelfed = {
41 enable = mkEnableOption "a Pixelfed instance";
42 package = mkPackageOption pkgs "pixelfed" { };
43 phpPackage = mkPackageOption pkgs "php81" { };
44
45 user = mkOption {
46 type = types.str;
47 default = "pixelfed";
48 description = ''
49 User account under which pixelfed runs.
50
51 ::: {.note}
52 If left as the default value this user will automatically be created
53 on system activation, otherwise you are responsible for
54 ensuring the user exists before the pixelfed application starts.
55 :::
56 '';
57 };
58
59 group = mkOption {
60 type = types.str;
61 default = "pixelfed";
62 description = ''
63 Group account under which pixelfed runs.
64
65 ::: {.note}
66 If left as the default value this group will automatically be created
67 on system activation, otherwise you are responsible for
68 ensuring the group exists before the pixelfed application starts.
69 :::
70 '';
71 };
72
73 domain = mkOption {
74 type = types.str;
75 description = ''
76 FQDN for the Pixelfed instance.
77 '';
78 };
79
80 secretFile = mkOption {
81 type = types.path;
82 description = ''
83 A secret file to be sourced for the .env settings.
84 Place `APP_KEY` and other settings that should not end up in the Nix store here.
85 '';
86 };
87
88 settings = mkOption {
89 type = with types; (attrsOf (oneOf [ bool int str ]));
90 description = ''
91 .env settings for Pixelfed.
92 Secrets should use `secretFile` option instead.
93 '';
94 };
95
96 nginx = mkOption {
97 type = types.nullOr (types.submodule
98 (import ../web-servers/nginx/vhost-options.nix {
99 inherit config lib;
100 }));
101 default = null;
102 example = lib.literalExpression ''
103 {
104 serverAliases = [
105 "pics.''${config.networking.domain}"
106 ];
107 enableACME = true;
108 forceHttps = true;
109 }
110 '';
111 description = ''
112 With this option, you can customize an nginx virtual host which already has sensible defaults for Dolibarr.
113 Set to {} if you do not need any customization to the virtual host.
114 If enabled, then by default, the {option}`serverName` is
115 `''${domain}`,
116 If this is set to null (the default), no nginx virtualHost will be configured.
117 '';
118 };
119
120 redis.createLocally = mkEnableOption "a local Redis database using UNIX socket authentication"
121 // {
122 default = true;
123 };
124
125 database = {
126 createLocally = mkEnableOption "a local database using UNIX socket authentication" // {
127 default = true;
128 };
129 automaticMigrations = mkEnableOption "automatic migrations for database schema and data" // {
130 default = true;
131 };
132
133 type = mkOption {
134 type = types.enum [ "mysql" "pgsql" ];
135 example = "pgsql";
136 default = "mysql";
137 description = ''
138 Database engine to use.
139 Note that PGSQL is not well supported: https://github.com/pixelfed/pixelfed/issues/2727
140 '';
141 };
142
143 name = mkOption {
144 type = types.str;
145 default = "pixelfed";
146 description = "Database name.";
147 };
148 };
149
150 maxUploadSize = mkOption {
151 type = types.str;
152 default = "8M";
153 description = ''
154 Max upload size with units.
155 '';
156 };
157
158 poolConfig = mkOption {
159 type = with types; attrsOf (oneOf [ int str bool ]);
160 default = { };
161
162 description = ''
163 Options for Pixelfed's PHP-FPM pool.
164 '';
165 };
166
167 dataDir = mkOption {
168 type = types.str;
169 default = "/var/lib/pixelfed";
170 description = ''
171 State directory of the `pixelfed` user which holds
172 the application's state and data.
173 '';
174 };
175
176 runtimeDir = mkOption {
177 type = types.str;
178 default = "/run/pixelfed";
179 description = ''
180 Ruutime directory of the `pixelfed` user which holds
181 the application's caches and temporary files.
182 '';
183 };
184
185 schedulerInterval = mkOption {
186 type = types.str;
187 default = "1d";
188 description = "How often the Pixelfed cron task should run";
189 };
190 };
191 };
192
193 config = mkIf cfg.enable {
194 users.users.pixelfed = mkIf (cfg.user == "pixelfed") {
195 isSystemUser = true;
196 group = cfg.group;
197 extraGroups = lib.optional cfg.redis.createLocally "redis-pixelfed";
198 };
199 users.groups.pixelfed = mkIf (cfg.group == "pixelfed") { };
200
201 services.redis.servers.pixelfed.enable = lib.mkIf cfg.redis.createLocally true;
202 services.pixelfed.settings = mkMerge [
203 ({
204 APP_ENV = mkDefault "production";
205 APP_DEBUG = mkDefault false;
206 # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L312-L316
207 APP_URL = mkDefault "https://${cfg.domain}";
208 ADMIN_DOMAIN = mkDefault cfg.domain;
209 APP_DOMAIN = mkDefault cfg.domain;
210 SESSION_DOMAIN = mkDefault cfg.domain;
211 SESSION_SECURE_COOKIE = mkDefault true;
212 OPEN_REGISTRATION = mkDefault false;
213 # ActivityPub: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L360-L364
214 ACTIVITY_PUB = mkDefault true;
215 AP_REMOTE_FOLLOW = mkDefault true;
216 AP_INBOX = mkDefault true;
217 AP_OUTBOX = mkDefault true;
218 AP_SHAREDINBOX = mkDefault true;
219 # Image optimization: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L367-L404
220 PF_OPTIMIZE_IMAGES = mkDefault true;
221 IMAGE_DRIVER = mkDefault "imagick";
222 # Mobile APIs
223 OAUTH_ENABLED = mkDefault true;
224 # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L351
225 EXP_EMC = mkDefault true;
226 # Defer to systemd
227 LOG_CHANNEL = mkDefault "stderr";
228 # TODO: find out the correct syntax?
229 # TRUST_PROXIES = mkDefault "127.0.0.1/8, ::1/128";
230 })
231 (mkIf (cfg.redis.createLocally) {
232 BROADCAST_DRIVER = mkDefault "redis";
233 CACHE_DRIVER = mkDefault "redis";
234 QUEUE_DRIVER = mkDefault "redis";
235 SESSION_DRIVER = mkDefault "redis";
236 WEBSOCKET_REPLICATION_MODE = mkDefault "redis";
237 # Support phpredis and predis configuration-style.
238 REDIS_SCHEME = "unix";
239 REDIS_HOST = config.services.redis.servers.pixelfed.unixSocket;
240 REDIS_PATH = config.services.redis.servers.pixelfed.unixSocket;
241 })
242 (mkIf (cfg.database.createLocally) {
243 DB_CONNECTION = cfg.database.type;
244 DB_SOCKET = dbSocket;
245 DB_DATABASE = cfg.database.name;
246 DB_USERNAME = user;
247 # No TCP/IP connection.
248 DB_PORT = 0;
249 })
250 ];
251
252 environment.systemPackages = [ pixelfed-manage ];
253
254 services.mysql =
255 mkIf (cfg.database.createLocally && cfg.database.type == "mysql") {
256 enable = mkDefault true;
257 package = mkDefault pkgs.mariadb;
258 ensureDatabases = [ cfg.database.name ];
259 ensureUsers = [{
260 name = user;
261 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
262 }];
263 };
264
265 services.postgresql =
266 mkIf (cfg.database.createLocally && cfg.database.type == "pgsql") {
267 enable = mkDefault true;
268 ensureDatabases = [ cfg.database.name ];
269 ensureUsers = [{
270 name = user;
271 }];
272 };
273
274 # Make each individual option overridable with lib.mkDefault.
275 services.pixelfed.poolConfig = lib.mapAttrs' (n: v: lib.nameValuePair n (lib.mkDefault v)) {
276 "pm" = "dynamic";
277 "php_admin_value[error_log]" = "stderr";
278 "php_admin_flag[log_errors]" = true;
279 "catch_workers_output" = true;
280 "pm.max_children" = "32";
281 "pm.start_servers" = "2";
282 "pm.min_spare_servers" = "2";
283 "pm.max_spare_servers" = "4";
284 "pm.max_requests" = "500";
285 };
286
287 services.phpfpm.pools.pixelfed = {
288 inherit user group;
289 inherit phpPackage;
290
291 phpOptions = ''
292 post_max_size = ${toString cfg.maxUploadSize}
293 upload_max_filesize = ${toString cfg.maxUploadSize}
294 max_execution_time = 600;
295 '';
296
297 settings = {
298 "listen.owner" = user;
299 "listen.group" = group;
300 "listen.mode" = "0660";
301 "catch_workers_output" = "yes";
302 } // cfg.poolConfig;
303 };
304
305 systemd.services.phpfpm-pixelfed.after = [ "pixelfed-data-setup.service" ];
306 systemd.services.phpfpm-pixelfed.requires =
307 [ "pixelfed-horizon.service" "pixelfed-data-setup.service" ]
308 ++ lib.optional cfg.database.createLocally dbService
309 ++ lib.optional cfg.redis.createLocally redisService;
310 # Ensure image optimizations programs are available.
311 systemd.services.phpfpm-pixelfed.path = extraPrograms;
312
313 systemd.services.pixelfed-horizon = {
314 description = "Pixelfed task queueing via Laravel Horizon framework";
315 after = [ "network.target" "pixelfed-data-setup.service" ];
316 requires = [ "pixelfed-data-setup.service" ]
317 ++ (lib.optional cfg.database.createLocally dbService)
318 ++ (lib.optional cfg.redis.createLocally redisService);
319 wantedBy = [ "multi-user.target" ];
320 # Ensure image optimizations programs are available.
321 path = extraPrograms;
322
323 serviceConfig = {
324 Type = "simple";
325 ExecStart = "${pixelfed-manage}/bin/pixelfed-manage horizon";
326 StateDirectory =
327 lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
328 User = user;
329 Group = group;
330 Restart = "on-failure";
331 };
332 };
333
334 systemd.timers.pixelfed-cron = {
335 description = "Pixelfed periodic tasks timer";
336 after = [ "pixelfed-data-setup.service" ];
337 requires = [ "phpfpm-pixelfed.service" ];
338 wantedBy = [ "timers.target" ];
339
340 timerConfig = {
341 OnBootSec = cfg.schedulerInterval;
342 OnUnitActiveSec = cfg.schedulerInterval;
343 };
344 };
345
346 systemd.services.pixelfed-cron = {
347 description = "Pixelfed periodic tasks";
348 # Ensure image optimizations programs are available.
349 path = extraPrograms;
350
351 serviceConfig = {
352 ExecStart = "${pixelfed-manage}/bin/pixelfed-manage schedule:run";
353 User = user;
354 Group = group;
355 StateDirectory =
356 lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
357 };
358 };
359
360 systemd.services.pixelfed-data-setup = {
361 description =
362 "Pixelfed setup: migrations, environment file update, cache reload, data changes";
363 wantedBy = [ "multi-user.target" ];
364 after = lib.optional cfg.database.createLocally dbService;
365 requires = lib.optional cfg.database.createLocally dbService;
366 path = with pkgs; [ bash pixelfed-manage rsync ] ++ extraPrograms;
367
368 serviceConfig = {
369 Type = "oneshot";
370 User = user;
371 Group = group;
372 StateDirectory =
373 lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
374 LoadCredential = "env-secrets:${cfg.secretFile}";
375 UMask = "077";
376 };
377
378 script = ''
379 # Before running any PHP program, cleanup the code cache.
380 # It's necessary if you upgrade the application otherwise you might
381 # try to import non-existent modules.
382 rm -f ${cfg.runtimeDir}/app.php
383 rm -rf ${cfg.runtimeDir}/cache/*
384
385 # Concatenate non-secret .env and secret .env
386 rm -f ${cfg.dataDir}/.env
387 cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
388 echo -e '\n' >> ${cfg.dataDir}/.env
389 cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env
390
391 # Link the static storage (package provided) to the runtime storage
392 # Necessary for cities.json and static images.
393 mkdir -p ${cfg.dataDir}/storage
394 rsync -av --no-perms ${pixelfed}/storage-static/ ${cfg.dataDir}/storage
395 chmod -R +w ${cfg.dataDir}/storage
396
397 chmod g+x ${cfg.dataDir}/storage ${cfg.dataDir}/storage/app
398 chmod -R g+rX ${cfg.dataDir}/storage/app/public
399
400 # Link the app.php in the runtime folder.
401 # We cannot link the cache folder only because bootstrap folder needs to be writeable.
402 ln -sf ${pixelfed}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php
403
404 # https://laravel.com/docs/10.x/filesystem#the-public-disk
405 # Creating the public/storage → storage/app/public link
406 # is unnecessary as it's part of the installPhase of pixelfed.
407
408 # Install Horizon
409 # FIXME: require write access to public/ — should be done as part of install — pixelfed-manage horizon:publish
410
411 # Perform the first migration.
412 [[ ! -f ${cfg.dataDir}/.initial-migration ]] && pixelfed-manage migrate --force && touch ${cfg.dataDir}/.initial-migration
413
414 ${lib.optionalString cfg.database.automaticMigrations ''
415 # Force migrate the database.
416 pixelfed-manage migrate --force
417 ''}
418
419 # Import location data
420 pixelfed-manage import:cities
421
422 ${lib.optionalString cfg.settings.ACTIVITY_PUB ''
423 # ActivityPub federation bookkeeping
424 [[ ! -f ${cfg.dataDir}/.instance-actor-created ]] && pixelfed-manage instance:actor && touch ${cfg.dataDir}/.instance-actor-created
425 ''}
426
427 ${lib.optionalString cfg.settings.OAUTH_ENABLED ''
428 # Generate Passport encryption keys
429 [[ ! -f ${cfg.dataDir}/.passport-keys-generated ]] && pixelfed-manage passport:keys && touch ${cfg.dataDir}/.passport-keys-generated
430 ''}
431
432 pixelfed-manage route:cache
433 pixelfed-manage view:cache
434 pixelfed-manage config:cache
435 '';
436 };
437
438 systemd.tmpfiles.rules = [
439 # Cache must live across multiple systemd units runtimes.
440 "d ${cfg.runtimeDir}/ 0700 ${user} ${group} - -"
441 "d ${cfg.runtimeDir}/cache 0700 ${user} ${group} - -"
442 ];
443
444 # Enable NGINX to access our phpfpm-socket.
445 users.users."${config.services.nginx.user}".extraGroups = [ cfg.group ];
446 services.nginx = mkIf (cfg.nginx != null) {
447 enable = true;
448 virtualHosts."${cfg.domain}" = mkMerge [
449 cfg.nginx
450 {
451 root = lib.mkForce "${pixelfed}/public/";
452 locations."/".tryFiles = "$uri $uri/ /index.php?$query_string";
453 locations."/favicon.ico".extraConfig = ''
454 access_log off; log_not_found off;
455 '';
456 locations."/robots.txt".extraConfig = ''
457 access_log off; log_not_found off;
458 '';
459 locations."~ \\.php$".extraConfig = ''
460 fastcgi_split_path_info ^(.+\.php)(/.+)$;
461 fastcgi_pass unix:${config.services.phpfpm.pools.pixelfed.socket};
462 fastcgi_index index.php;
463 '';
464 locations."~ /\\.(?!well-known).*".extraConfig = ''
465 deny all;
466 '';
467 extraConfig = ''
468 add_header X-Frame-Options "SAMEORIGIN";
469 add_header X-XSS-Protection "1; mode=block";
470 add_header X-Content-Type-Options "nosniff";
471 index index.html index.htm index.php;
472 error_page 404 /index.php;
473 client_max_body_size ${toString cfg.maxUploadSize};
474 '';
475 }
476 ];
477 };
478 };
479}