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