1{ config, lib, pkgs, ... }:
2let
3 cfg = config.services.castopod;
4 fpm = config.services.phpfpm.pools.castopod;
5
6 user = "castopod";
7
8 # https://docs.castopod.org/getting-started/install.html#requirements
9 phpPackage = pkgs.php.withExtensions ({ enabled, all }: with all; [
10 intl
11 curl
12 mbstring
13 gd
14 exif
15 mysqlnd
16 ] ++ enabled);
17in
18{
19 meta.doc = ./castopod.md;
20 meta.maintainers = with lib.maintainers; [ alexoundos ];
21
22 options.services = {
23 castopod = {
24 enable = lib.mkEnableOption "Castopod, a hosting platform for podcasters";
25 package = lib.mkOption {
26 type = lib.types.package;
27 default = pkgs.castopod;
28 defaultText = lib.literalMD "pkgs.castopod";
29 description = "Which Castopod package to use.";
30 };
31 dataDir = lib.mkOption {
32 type = lib.types.path;
33 default = "/var/lib/castopod";
34 description = ''
35 The path where castopod stores all data. This path must be in sync
36 with the castopod package (where it is hardcoded during the build in
37 accordance with its own `dataDir` argument).
38 '';
39 };
40 database = {
41 createLocally = lib.mkOption {
42 type = lib.types.bool;
43 default = true;
44 description = ''
45 Create the database and database user locally.
46 '';
47 };
48 hostname = lib.mkOption {
49 type = lib.types.str;
50 default = "localhost";
51 description = "Database hostname.";
52 };
53 name = lib.mkOption {
54 type = lib.types.str;
55 default = "castopod";
56 description = "Database name.";
57 };
58 user = lib.mkOption {
59 type = lib.types.str;
60 default = user;
61 description = "Database user.";
62 };
63 passwordFile = lib.mkOption {
64 type = lib.types.nullOr lib.types.path;
65 default = null;
66 example = "/run/keys/castopod-dbpassword";
67 description = ''
68 A file containing the password corresponding to
69 [](#opt-services.castopod.database.user).
70
71 This file is loaded using systemd LoadCredentials.
72 '';
73 };
74 };
75 settings = lib.mkOption {
76 type = with lib.types; attrsOf (oneOf [ str int bool ]);
77 default = { };
78 example = {
79 "email.protocol" = "smtp";
80 "email.SMTPHost" = "localhost";
81 "email.SMTPUser" = "myuser";
82 "email.fromEmail" = "castopod@example.com";
83 };
84 description = ''
85 Environment variables used for Castopod.
86 See [](https://code.castopod.org/adaures/castopod/-/blob/main/.env.example)
87 for available environment variables.
88 '';
89 };
90 environmentFile = lib.mkOption {
91 type = lib.types.nullOr lib.types.path;
92 default = null;
93 example = "/run/keys/castopod-env";
94 description = ''
95 Environment file to inject e.g. secrets into the configuration.
96 See [](https://code.castopod.org/adaures/castopod/-/blob/main/.env.example)
97 for available environment variables.
98
99 This file is loaded using systemd LoadCredentials.
100 '';
101 };
102 configureNginx = lib.mkOption {
103 type = lib.types.bool;
104 default = true;
105 description = "Configure nginx as a reverse proxy for CastoPod.";
106 };
107 localDomain = lib.mkOption {
108 type = lib.types.str;
109 example = "castopod.example.org";
110 description = "The domain serving your CastoPod instance.";
111 };
112 poolSettings = lib.mkOption {
113 type = with lib.types; attrsOf (oneOf [ str int bool ]);
114 default = {
115 "pm" = "dynamic";
116 "pm.max_children" = "32";
117 "pm.start_servers" = "2";
118 "pm.min_spare_servers" = "2";
119 "pm.max_spare_servers" = "4";
120 "pm.max_requests" = "500";
121 };
122 description = ''
123 Options for Castopod's PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives.
124 '';
125 };
126 maxUploadSize = lib.mkOption {
127 type = lib.types.str;
128 default = "512M";
129 description = ''
130 Maximum supported size for a file upload in. Maximum HTTP body
131 size is set to this value for nginx and PHP (because castopod doesn't
132 support chunked uploads yet:
133 https://code.castopod.org/adaures/castopod/-/issues/330).
134
135 Note, that practical upload size limit is smaller. For example, with
136 512 MiB setting - around 500 MiB is possible.
137 '';
138 };
139 };
140 };
141
142 config = lib.mkIf cfg.enable {
143 services.castopod.settings =
144 let
145 sslEnabled = with config.services.nginx.virtualHosts.${cfg.localDomain}; addSSL || forceSSL || onlySSL || enableACME || useACMEHost != null;
146 baseURL = "http${lib.optionalString sslEnabled "s"}://${cfg.localDomain}";
147 in
148 lib.mapAttrs (_: lib.mkDefault) {
149 "app.forceGlobalSecureRequests" = sslEnabled;
150 "app.baseURL" = baseURL;
151
152 "media.baseURL" = baseURL;
153 "media.root" = "media";
154 "media.storage" = cfg.dataDir;
155
156 "admin.gateway" = "admin";
157 "auth.gateway" = "auth";
158
159 "database.default.hostname" = cfg.database.hostname;
160 "database.default.database" = cfg.database.name;
161 "database.default.username" = cfg.database.user;
162 "database.default.DBPrefix" = "cp_";
163
164 "cache.handler" = "file";
165 };
166
167 services.phpfpm.pools.castopod = {
168 inherit user;
169 group = config.services.nginx.group;
170 inherit phpPackage;
171 phpOptions = ''
172 # https://code.castopod.org/adaures/castopod/-/blob/develop/docker/production/common/uploads.template.ini
173 file_uploads = On
174 memory_limit = 512M
175 upload_max_filesize = ${cfg.maxUploadSize}
176 post_max_size = ${cfg.maxUploadSize}
177 max_execution_time = 300
178 max_input_time = 300
179 '';
180 settings = {
181 "listen.owner" = config.services.nginx.user;
182 "listen.group" = config.services.nginx.group;
183 } // cfg.poolSettings;
184 };
185
186 systemd.services.castopod-setup = {
187 after = lib.optional config.services.mysql.enable "mysql.service";
188 requires = lib.optional config.services.mysql.enable "mysql.service";
189 wantedBy = [ "multi-user.target" ];
190 path = [ pkgs.openssl phpPackage ];
191 script =
192 let
193 envFile = "${cfg.dataDir}/.env";
194 media = "${cfg.settings."media.storage"}/${cfg.settings."media.root"}";
195 in
196 ''
197 mkdir -p ${cfg.dataDir}/writable/{cache,logs,session,temp,uploads}
198
199 if [ ! -d ${lib.escapeShellArg media} ]; then
200 cp --no-preserve=mode,ownership -r ${cfg.package}/share/castopod/public/media ${lib.escapeShellArg media}
201 fi
202
203 if [ ! -f ${cfg.dataDir}/salt ]; then
204 openssl rand -base64 33 > ${cfg.dataDir}/salt
205 fi
206
207 cat <<'EOF' > ${envFile}
208 ${lib.generators.toKeyValue { } cfg.settings}
209 EOF
210
211 echo "analytics.salt=$(cat ${cfg.dataDir}/salt)" >> ${envFile}
212
213 ${if (cfg.database.passwordFile != null) then ''
214 echo "database.default.password=$(cat "$CREDENTIALS_DIRECTORY/dbpasswordfile)" >> ${envFile}
215 '' else ''
216 echo "database.default.password=" >> ${envFile}
217 ''}
218
219 ${lib.optionalString (cfg.environmentFile != null) ''
220 cat "$CREDENTIALS_DIRECTORY/envfile" >> ${envFile}
221 ''}
222
223 php ${cfg.package}/share/castopod/spark castopod:database-update
224 '';
225 serviceConfig = {
226 StateDirectory = "castopod";
227 LoadCredential = lib.optional (cfg.environmentFile != null)
228 "envfile:${cfg.environmentFile}"
229 ++ (lib.optional (cfg.database.passwordFile != null)
230 "dbpasswordfile:${cfg.database.passwordFile}");
231 WorkingDirectory = "${cfg.package}/share/castopod";
232 Type = "oneshot";
233 RemainAfterExit = true;
234 User = user;
235 Group = config.services.nginx.group;
236 ReadWritePaths = cfg.dataDir;
237 };
238 };
239
240 systemd.services.castopod-scheduled = {
241 after = [ "castopod-setup.service" ];
242 wantedBy = [ "multi-user.target" ];
243 path = [ phpPackage ];
244 script = ''
245 php ${cfg.package}/share/castopod/spark tasks:run
246 '';
247 serviceConfig = {
248 StateDirectory = "castopod";
249 WorkingDirectory = "${cfg.package}/share/castopod";
250 Type = "oneshot";
251 User = user;
252 Group = config.services.nginx.group;
253 ReadWritePaths = cfg.dataDir;
254 LogLevelMax = "notice"; # otherwise periodic tasks flood the journal
255 };
256 };
257
258 systemd.timers.castopod-scheduled = {
259 wantedBy = [ "timers.target" ];
260 timerConfig = {
261 OnCalendar = "*-*-* *:*:00";
262 Unit = "castopod-scheduled.service";
263 };
264 };
265
266 services.mysql = lib.mkIf cfg.database.createLocally {
267 enable = true;
268 package = lib.mkDefault pkgs.mariadb;
269 ensureDatabases = [ cfg.database.name ];
270 ensureUsers = [{
271 name = cfg.database.user;
272 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
273 }];
274 };
275
276 services.nginx = lib.mkIf cfg.configureNginx {
277 enable = true;
278 virtualHosts."${cfg.localDomain}" = {
279 root = lib.mkForce "${cfg.package}/share/castopod/public";
280
281 extraConfig = ''
282 try_files $uri $uri/ /index.php?$args;
283 index index.php index.html;
284 client_max_body_size ${cfg.maxUploadSize};
285 '';
286
287 locations."^~ /${cfg.settings."media.root"}/" = {
288 root = cfg.settings."media.storage";
289 extraConfig = ''
290 add_header Access-Control-Allow-Origin "*";
291 expires max;
292 access_log off;
293 '';
294 };
295
296 locations."~ \.php$" = {
297 fastcgiParams = {
298 SERVER_NAME = "$host";
299 };
300 extraConfig = ''
301 fastcgi_intercept_errors on;
302 fastcgi_index index.php;
303 fastcgi_pass unix:${fpm.socket};
304 try_files $uri =404;
305 fastcgi_read_timeout 3600;
306 fastcgi_send_timeout 3600;
307 '';
308 };
309 };
310 };
311
312 users.users.${user} = lib.mapAttrs (_: lib.mkDefault) {
313 description = "Castopod user";
314 isSystemUser = true;
315 group = config.services.nginx.group;
316 };
317 };
318}