at 25.11-pre 19 kB view raw
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}