1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8 inherit (lib)
9 getExe
10 literalExpression
11 mkEnableOption
12 mkIf
13 mkOption
14 mkPackageOption
15 optionalString
16 toUpper
17 types
18 ;
19
20 cfg = config.services.stash;
21
22 stashType = types.submodule {
23 options = {
24 path = mkOption {
25 type = types.path;
26 description = "location of your media files";
27 };
28 excludevideo = mkOption {
29 type = types.bool;
30 default = false;
31 description = "Whether to exclude video files from being scanned into Stash";
32 };
33 excludeimage = mkOption {
34 type = types.bool;
35 default = false;
36 description = "Whether to exclude image files from being scanned into Stash";
37 };
38 };
39 };
40 stashBoxType = types.submodule {
41 options = {
42 name = mkOption {
43 type = types.str;
44 description = "The name of the Stash Box";
45 };
46 endpoint = mkOption {
47 type = types.str;
48 description = "URL to the Stash Box graphql api";
49 };
50 apikey = mkOption {
51 type = types.str;
52 description = "Stash Box API key";
53 };
54 };
55 };
56
57 recentlyReleased = mode: {
58 __typename = "CustomFilter";
59 message = {
60 id = "recently_released_objects";
61 values.objects = mode;
62 };
63 mode = toUpper mode;
64 sortBy = "date";
65 direction = "DESC";
66 };
67 recentlyAdded = mode: {
68 __typename = "CustomFilter";
69 message = {
70 id = "recently_added_objects";
71 values.objects = mode;
72 };
73 mode = toUpper mode;
74 sortBy = "created_at";
75 direction = "DESC";
76 };
77 uiPresets = {
78 recentlyReleasedScenes = recentlyReleased "Scenes";
79 recentlyAddedScenes = recentlyAdded "Scenes";
80 recentlyReleasedGalleries = recentlyReleased "Galleries";
81 recentlyAddedGalleries = recentlyAdded "Galleries";
82 recentlyAddedImages = recentlyAdded "Images";
83 recentlyReleasedMovies = recentlyReleased "Movies";
84 recentlyAddedMovies = recentlyAdded "Movies";
85 recentlyAddedStudios = recentlyAdded "Studios";
86 recentlyAddedPerformers = recentlyAdded "Performers";
87 };
88
89 settingsFormat = pkgs.formats.yaml { };
90 settingsFile = settingsFormat.generate "config.yml" cfg.settings;
91 settingsType = types.submodule {
92 freeformType = settingsFormat.type;
93
94 options = {
95 host = mkOption {
96 type = types.str;
97 default = "localhost";
98 example = "::1";
99 description = "The ip address that Stash should bind to.";
100 };
101
102 port = mkOption {
103 type = types.port;
104 default = 9999;
105 example = 1234;
106 description = "The port that Stash should listen on.";
107 };
108
109 stash = mkOption {
110 type = types.listOf stashType;
111 description = ''
112 Add directories containing your adult videos and images.
113 Stash will use these directories to find videos and/or images during scanning.
114 '';
115 example = literalExpression ''
116 {
117 stash = [
118 {
119 Path = "/media/drive/videos";
120 ExcludeImage = true;
121 }
122 ];
123 }
124 '';
125 };
126 stash_boxes = mkOption {
127 type = types.listOf stashBoxType;
128 default = [ ];
129 description = ''Stash-box facilitates automated tagging of scenes and performers based on fingerprints and filenames'';
130 example = literalExpression ''
131 {
132 stash_boxes = [
133 {
134 name = "StashDB";
135 endpoint = "https://stashdb.org/graphql";
136 apikey = "aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbb.cccccccccccccc";
137 }
138 ];
139 }
140 '';
141 };
142 ui.frontPageContent = mkOption {
143 description = "Search filters to display on the front page.";
144 type = types.either (types.listOf types.attrs) (types.functionTo (types.listOf types.attrs));
145 default = presets: [
146 presets.recentlyReleasedScenes
147 presets.recentlyAddedStudios
148 presets.recentlyReleasedMovies
149 presets.recentlyAddedPerformers
150 presets.recentlyReleasedGalleries
151 ];
152 example = literalExpression ''
153 presets: [
154 # To get the savedFilterId, you can query `{ findSavedFilters(mode: <FilterMode>) { id name } }` on localhost:9999/graphql
155 {
156 __typename = "SavedFilter";
157 savedFilterId = 1;
158 }
159 # basic custom filter
160 {
161 __typename = "CustomFilter";
162 title = "Random Scenes";
163 mode = "SCENES";
164 sortBy = "random";
165 direction = "DESC";
166 }
167 presets.recentlyAddedImages
168 ]
169 '';
170 apply = type: if builtins.isFunction type then (type uiPresets) else type;
171 };
172 blobs_path = mkOption {
173 type = types.path;
174 default = "${cfg.dataDir}/blobs";
175 description = "Path to blobs";
176 };
177 cache = mkOption {
178 type = types.path;
179 default = "${cfg.dataDir}/cache";
180 description = "Path to cache";
181 };
182 database = mkOption {
183 type = types.path;
184 default = "${cfg.dataDir}/go.sqlite";
185 description = "Path to the SQLite database";
186 };
187 generated = mkOption {
188 type = types.path;
189 default = "${cfg.dataDir}/generated";
190 description = "Path to generated files";
191 };
192 plugins_path = mkOption {
193 type = types.path;
194 default = "${cfg.dataDir}/plugins";
195 description = "Path to scrapers";
196 };
197 scrapers_path = mkOption {
198 type = types.path;
199 default = "${cfg.dataDir}/scrapers";
200 description = "Path to scrapers";
201 };
202
203 blobs_storage = mkOption {
204 type = types.enum [
205 "FILESYSTEM"
206 "DATABASE"
207 ];
208 default = "FILESYSTEM";
209 description = "Where to store blobs";
210 };
211 calculate_md5 = mkOption {
212 type = types.bool;
213 default = false;
214 description = "Whether to calculate MD5 checksums for scene video files";
215 };
216 create_image_clip_from_videos = mkOption {
217 type = types.bool;
218 default = false;
219 description = "Create Image Clips from Video extensions when Videos are disabled in Library";
220 };
221 dangerous_allow_public_without_auth = mkOption {
222 type = types.bool;
223 default = false;
224 description = "Learn more at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet/";
225 };
226 gallery_cover_regex = mkOption {
227 type = types.str;
228 default = "(poster|cover|folder|board)\\.[^.]+$";
229 description = "Regex used to identify images as gallery covers";
230 };
231 no_proxy = mkOption {
232 type = types.str;
233 default = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12";
234 description = "A list of domains for which the proxy must not be used";
235 };
236 nobrowser = mkOption {
237 type = types.bool;
238 default = true;
239 description = "If we should not auto-open a browser window on startup";
240 };
241 notifications_enabled = mkOption {
242 type = types.bool;
243 default = true;
244 description = "If we should send notifications to the desktop";
245 };
246 parallel_tasks = mkOption {
247 type = types.int;
248 default = 1;
249 description = "Number of parallel tasks to start during scan/generate";
250 };
251 preview_audio = mkOption {
252 type = types.bool;
253 default = true;
254 description = "Include audio stream in previews";
255 };
256 preview_exclude_end = mkOption {
257 type = types.int;
258 default = 0;
259 description = "Duration of start of video to exclude when generating previews";
260 };
261 preview_exclude_start = mkOption {
262 type = types.int;
263 default = 0;
264 description = "Duration of end of video to exclude when generating previews";
265 };
266 preview_segment_duration = mkOption {
267 type = types.float;
268 default = 0.75;
269 description = "Preview segment duration, in seconds";
270 };
271 preview_segments = mkOption {
272 type = types.int;
273 default = 12;
274 description = "Number of segments in a preview file";
275 };
276 security_tripwire_accessed_from_public_internet = mkOption {
277 type = types.nullOr types.str;
278 default = "";
279 description = "Learn more at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet/";
280 };
281 sequential_scanning = mkOption {
282 type = types.bool;
283 default = false;
284 description = "Modifies behaviour of the scanning functionality to generate support files (previews/sprites/phash) at the same time as fingerprinting/screenshotting";
285 };
286 show_one_time_moved_notification = mkOption {
287 type = types.bool;
288 default = true;
289 description = "Whether a small notification to inform the user that Stash will no longer show a terminal window, and instead will be available in the tray";
290 };
291 sound_on_preview = mkOption {
292 type = types.bool;
293 default = false;
294 description = "Enable sound on mouseover previews";
295 };
296 theme_color = mkOption {
297 type = types.str;
298 default = "#202b33";
299 description = "Sets the `theme-color` property in the UI";
300 };
301 video_file_naming_algorithm = mkOption {
302 type = types.enum [
303 "OSHASH"
304 "MD5"
305 ];
306 default = "OSHASH";
307 description = "Hash algorithm to use for generated file naming";
308 };
309 write_image_thumbnails = mkOption {
310 type = types.bool;
311 default = true;
312 description = "Write image thumbnails to disk when generating on the fly";
313 };
314 };
315 };
316
317 pluginType =
318 kind:
319 mkOption {
320 type = types.listOf types.package;
321 default = [ ];
322 description = ''
323 The ${kind} Stash should be started with.
324 '';
325 apply =
326 srcs:
327 optionalString (srcs != [ ]) (
328 pkgs.runCommand "stash-${kind}"
329 {
330 inherit srcs;
331 nativeBuildInputs = [ pkgs.yq-go ];
332 preferLocalBuild = true;
333 }
334 ''
335 find $srcs -mindepth 1 -name '*.yml' | while read plugin_file; do
336 grep -q "^#pkgignore" "$plugin_file" && continue
337
338 plugin_dir=$(dirname $plugin_file)
339 out_path=$out/$(basename $plugin_dir)
340 mkdir -p $out_path
341 ls $plugin_dir | xargs -I{} ln -sf "$plugin_dir/{}" $out_path
342
343 env \
344 plugin_id=$(basename $plugin_file .yml) \
345 plugin_name="$(yq '.name' $plugin_file)" \
346 plugin_description="$(yq '.description' $plugin_file)" \
347 plugin_version="$(yq '.version' $plugin_file)" \
348 plugin_files="$(find -L $out_path -mindepth 1 -type f -printf "%P\n")" \
349 yq -n '
350 .id = strenv(plugin_id) |
351 .name = strenv(plugin_name) |
352 (
353 strenv(plugin_description) as $desc |
354 with(select($desc == "null"); .metadata = {}) |
355 with(select($desc != "null"); .metadata.description = $desc)
356 ) |
357 (
358 strenv(plugin_version) as $ver |
359 with(select($ver == "null"); .version = "Unknown") |
360 with(select($ver != "null"); .version = $ver)
361 ) |
362 .date = (now | format_datetime("2006-01-02 15:04:05")) |
363 .files = (strenv(plugin_files) | split("\n"))
364 ' > $out_path/manifest
365 done
366 ''
367 );
368 };
369in
370{
371 meta = {
372 buildDocsInSandbox = false;
373 maintainers = with lib.maintainers; [ DrakeTDL ];
374 };
375
376 options = {
377 services.stash = {
378 enable = mkEnableOption "stash";
379
380 package = mkPackageOption pkgs "stash" { };
381
382 user = mkOption {
383 type = types.str;
384 default = "stash";
385 description = "User under which Stash runs.";
386 };
387
388 group = mkOption {
389 type = types.str;
390 default = "stash";
391 description = "Group under which Stash runs.";
392 };
393
394 dataDir = mkOption {
395 type = types.path;
396 default = "/var/lib/stash";
397 description = "The directory where Stash stores its files.";
398 };
399
400 openFirewall = mkOption {
401 type = types.bool;
402 default = false;
403 description = "Open ports in the firewall for the Stash web interface.";
404 };
405
406 username = mkOption {
407 type = types.nullOr types.nonEmptyStr;
408 default = null;
409 example = "admin";
410 description = ''
411 Username for login.
412
413 ::: {.warning}
414 This option takes precedence over {option}`services.stash.settings.username`
415 ::
416
417 '';
418 };
419
420 passwordFile = mkOption {
421 type = types.nullOr types.path;
422 default = null;
423 example = "/path/to/password/file";
424 description = ''
425 Path to file containing password for login.
426
427 ::: {.warning}
428 This option takes precedence over {option}`services.stash.settings.password`
429 ::
430
431 '';
432 };
433
434 jwtSecretKeyFile = mkOption {
435 type = types.path;
436 description = "Path to file containing a secret used to sign JWT tokens.";
437 };
438 sessionStoreKeyFile = mkOption {
439 type = types.path;
440 description = "Path to file containing a secret for session store.";
441 };
442
443 mutableSettings = mkOption {
444 description = ''
445 Whether the Stash config.yml is writeable by Stash.
446
447 If `false`, Any config changes done from within Stash UI will be temporary and reset to those defined in {option}`services.stash.settings` upon `Stash.service` restart.
448 If `true`, the {option}`services.stash.settings` will only be used to initialize the Stash configuration if it does not exist, and are subsequently ignored.
449 '';
450 type = types.bool;
451 default = true;
452 };
453 mutablePlugins = mkEnableOption "Whether plugins/themes can be installed, updated, uninstalled manually.";
454 mutableScrapers = mkEnableOption "Whether scrapers can be installed, updated, uninstalled manually.";
455 plugins = pluginType "plugins";
456 scrapers = pluginType "scrapers";
457
458 settings = mkOption {
459 type = settingsType;
460 description = "Stash configuration";
461 };
462 };
463 };
464
465 config = mkIf cfg.enable {
466 assertions = [
467 {
468 assertion =
469 !lib.xor (cfg.username != null || cfg.settings.username or null != null) (
470 cfg.passwordFile != null || cfg.settings.password or null != null
471 );
472 message = "You must set either both username and password, or neither.";
473 }
474 ];
475
476 services.stash.settings = {
477 username = mkIf (cfg.username != null) cfg.username;
478 plugins_path = mkIf (!cfg.mutablePlugins) cfg.plugins;
479 scrapers_path = mkIf (!cfg.mutableScrapers) cfg.scrapers;
480 };
481
482 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.settings.port ];
483
484 users.users.${cfg.user} = {
485 inherit (cfg) group;
486 isSystemUser = true;
487 home = cfg.dataDir;
488 };
489 users.groups.${cfg.group} = { };
490
491 systemd = {
492 tmpfiles.settings."10-stash-datadir".${cfg.dataDir}."d" = {
493 inherit (cfg) user group;
494 mode = "0755";
495 };
496 services.stash = {
497 wantedBy = [ "multi-user.target" ];
498 after = [ "network.target" ];
499 path = with pkgs; [
500 ffmpeg-full
501 python3
502 ruby
503 ];
504 environment.STASH_CONFIG_FILE = "${cfg.dataDir}/config.yml";
505 serviceConfig = {
506 DynamicUser = false;
507 User = cfg.user;
508 Group = cfg.group;
509 Restart = "on-failure";
510 WorkingDirectory = cfg.dataDir;
511 StateDirectory = mkIf (cfg.dataDir == "/var/lib/stash") (baseNameOf cfg.dataDir);
512 ExecStartPre = pkgs.writers.writeBash "stash-setup.bash" (
513 ''
514 install -d ${cfg.settings.generated}
515 if [[ ! -z "${toString cfg.mutableSettings}" || ! -f ${cfg.dataDir}/config.yml ]]; then
516 env \
517 password=$(< ${cfg.passwordFile}) \
518 jwtSecretKeyFile=$(< ${cfg.jwtSecretKeyFile}) \
519 sessionStoreKeyFile=$(< ${cfg.sessionStoreKeyFile}) \
520 ${lib.getExe pkgs.yq-go} '
521 .jwt_secret_key = strenv(jwtSecretKeyFile) |
522 .session_store_key = strenv(sessionStoreKeyFile) |
523 (
524 strenv(password) as $password |
525 with(select($password != ""); .password = $password)
526 )
527 ' ${settingsFile} > ${cfg.dataDir}/config.yml
528 fi
529 ''
530 + optionalString cfg.mutablePlugins ''
531 install -d ${cfg.settings.plugins_path}
532 ls ${cfg.plugins} | xargs -I{} ln -sf '${cfg.plugins}/{}' ${cfg.settings.plugins_path}
533 ''
534 + optionalString cfg.mutableScrapers ''
535 install -d ${cfg.settings.scrapers_path}
536 ls ${cfg.scrapers} | xargs -I{} ln -sf '${cfg.scrapers}/{}' ${cfg.settings.scrapers_path}
537 ''
538 );
539 ExecStart = getExe cfg.package;
540
541 ProtectHome = "tmpfs";
542 BindReadOnlyPaths = mkIf (cfg.settings != { }) (map (stash: "${stash.path}") cfg.settings.stash);
543
544 # hardening
545
546 DevicePolicy = "auto"; # needed for hardware acceleration
547 PrivateDevices = false; # needed for hardware acceleration
548 AmbientCapabilities = [ "" ];
549 CapabilityBoundingSet = [ "" ];
550 ProtectSystem = "full";
551 LockPersonality = true;
552 NoNewPrivileges = true;
553 PrivateTmp = true;
554 PrivateUsers = true;
555 ProtectClock = true;
556 ProtectControlGroups = true;
557 ProtectHostname = true;
558 ProtectKernelLogs = true;
559 ProtectKernelModules = true;
560 ProtectKernelTunables = true;
561 ProcSubset = "pid";
562 ProtectProc = "invisible";
563 RemoveIPC = true;
564 RestrictAddressFamilies = [
565 "AF_UNIX"
566 "AF_INET"
567 "AF_INET6"
568 ];
569 RestrictNamespaces = true;
570 RestrictRealtime = true;
571 RestrictSUIDSGID = true;
572 MemoryDenyWriteExecute = true;
573 SystemCallArchitectures = "native";
574 SystemCallFilter = [
575 "~@cpu-emulation"
576 "~@debug"
577 "~@mount"
578 "~@obsolete"
579 "~@privileged"
580 ];
581 };
582 };
583 };
584 };
585}