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